Le langage avant le framework
Un langage n'est jamais seul. Il arrive avec son écosystème, ses conventions, et ses absences.
Il est une catégorie de décisions que les équipes prennent vite, souvent par habitude, rarement par conviction et qu'elles regrettent lentement, sur la durée, au fur et à mesure que les conséquences se révèlent. Le choix du langage d'un nouveau service en fait partie. Pour AgenticLayer, cette décision méritait mieux qu'un réflexe.
Le terrain que Python avait préparé
L'écosystème LLM vit en Python. LangChain est né là. LlamaIndex aussi. Les modèles d'embedding, les abstractions RAG, les outils d'évaluation agentique, les tracing libraries. Tout cela a été conçu d'abord pour Python, porté ensuite vers d'autres langages avec un délai variable et une parité rarement atteinte. J'ai sérieusement envisagé Python pendant environ deux jours.
C'était séduisant. Prendre l'outil pour lequel le travail avait été pensé, plutôt que d'adapter le travail à un autre outil. La boucle agentique, les tool calls, la gestion multi-providers, le tracing. Tout cela était plus mature, mieux documenté, plus activement maintenu côté Python. L'argument de performance n'était pas là non plus : pour un service dont le goulot d'étranglement est l'inférence LLM externe, la vitesse du runtime n'est pas le facteur limitant.
Go avait un cas différent à présenter. Démarrage quasi-instantané, concurrence native, empreinte mémoire minimale. Pour la couche purement infrastructure du microservice (la consommation RabbitMQ, le client NATS, la gestion des événements) Go aurait été excellent. Propre, rapide, fiable.
Les frontières qui ne se voient pas
Le problème, avec Python et Go, n'était pas dans ces langages eux-mêmes. Il était dans ce qu'ils auraient exigé du reste du système.
La plateforme existante tournait déjà en TypeScript. Le gateway était NestJS. Les contrats entre services: les payloads RabbitMQ, les messages NATS, les structures d'événements étaient des types TypeScript. Introduire Python pour AgenticLayer revenait à maintenir deux représentations du même domaine : une EvaluationResult TypeScript côté gateway, une EvaluationResult Python côté AgenticLayer, et une couche de sérialisation entre les deux que personne ne documenterait jamais assez.
Or ce n'est pas un détail opérationnel. C'est une source de dérive silencieuse. Quand un champ est renommé dans l'un des deux langages, le compilateur de l'autre ne dit rien. Le bug arrive en production, sous forme d'un objet undefined ou d'une clé manquante, sans aucun avertissement préalable. La frontière entre deux langages est une frontière que les outils ne surveillent pas.
Go était encore plus isolé : il n'existe pas d'écosystème sérieux pour l'orchestration agentique LLM en Go. Partir sur Go aurait signifié construire LangChain depuis zéro. Un choix qui aurait rapidement absorbé toute l'énergie disponible pour le développement lui-même.
La cohérence comme argument technique
LangChain.js existe. Il n'est pas LangChain Python, il lui est toujours légèrement postérieur, les nouvelles abstractions arrivent avec quelques semaines de décalage, la communauté est plus petite. Mais il était suffisant pour ce dont j'avais besoin : une boucle agentique avec tool calling, un support multi-providers, une intégration de tracing.
"Suffisant" est un mot qui mérite d'être défendu. Le réflexe d'un architecte est souvent de chercher le meilleur outil pour chaque problème localement: le plus mature, le plus expressif, le plus performant dans son domaine. Or l'optimum local n'est pas toujours l'optimum global. LangChain.js légèrement moins mature que LangChain Python, dans un écosystème TypeScript unifié, était préférable à LangChain Python natif dans un écosystème fragmenté.
La raison est simple : les contrats partagés ne sont pas une commodité. Ce sont de l'architecture. Quand AgentEvaluationRequest est le même type dans le gateway qui le publie et dans AgenticLayer qui le consomme, la cohérence est vérifiée à la compilation. Aucun test ne peut offrir cette garantie. Aucune documentation non plus. Le type est la documentation (à condition d'être partagé).
Il y a un second argument, plus discret, que la cohérence de l'écosystème avait tendance à éclipser. AgenticLayer est un service fondamentalement I/O-bound : chaque évaluation enchaîne des appels LLM distants, des requêtes Qdrant, des lectures Redis, des échanges NATS. Presque aucune opération n'est CPU-bound. Or c'est précisément le profil de charge pour lequel Node.js a été conçu: une boucle événementielle non bloquante qui continue de traiter d'autres travaux pendant qu'une opération I/O est en attente. Pendant qu'un agent attend la réponse d'un modèle de langage, le processus n'est pas suspendu. get_files lit plusieurs fichiers en parallèle avec Promise.all sans overhead de threads, sans gestion explicite d'un pool de workers. Python peut atteindre le même résultat avec asyncio, mais le support asynchrone dans l'écosystème LangChain reste inégal selon les versions et les providers, et le GIL de CPython introduit des contraintes réelles dès que plusieurs coroutines cherchent à progresser en parallèle. TypeScript n'était donc pas simplement le choix par défaut de l'écosystème. C'était le choix adapté au profil d'un service dont l'activité principale est d'attendre et de traiter les réponses avec méthode.

Pour un développeur junior : dans un système distribué, les types partagés entre services ne sont pas possibles dans des langages différents, il faut les recréer manuellement et les maintenir en synchronisation. Une solution courante est d'utiliser un schéma externe (JSON Schema, Protocol Buffers, OpenAPI) comme source de vérité commune, puis de générer les types dans chaque langage. C'est faisable mais c'est une couche de complexité supplémentaire à maintenir, documenter et tester. Rester dans le même langage à travers tous les services supprime ce problème entièrement.
Ce qu'un framework sait faire à ta place
TypeScript choisi, la question s'est déplacée : quel framework pour ce microservice ?
La première question n'était pas "quel framework ?" mais "ai-je besoin d'un framework ?". TSyringe (un conteneur d'injection de dépendances léger, conçu pour TypeScript standalone) aurait pu suffire pour un outil plus simple. Mais l'ambition du service tranchait la question avant même de la poser : un microservice hybrid, consommant RabbitMQ et parlant NATS, avec plusieurs entry points, une observabilité granulaire par step LLM, et un besoin de tester les composants en isolation. TSyringe n'avait pas de réponse à ces contraintes.
Express ou Fastify nus auraient pu suffire pour la couche transport. Mais ils n'auraient rien apporté pour l'injection de dépendances, la modularité, ou le cycle de vie des services. Il aurait fallu reconstituer cette infrastructure... et probablement retrouver NestJS par un autre chemin.
Ce que NestJS apporte, c'est une réponse architecturale à plusieurs questions simultanément. Le conteneur d'injection avec gestion des scopes. Une instance par appel, ou une instance partagée pour toute la durée du processus est la fondation sur laquelle reposera le découpage en modules. Les intercepteurs comme primitives architecturales permettent d'injecter le tracing Langfuse à chaque étape LLM sans modifier les services eux-mêmes. Et le pattern d'application hybrid, un seul processus qui est à la fois consommateur RabbitMQ et client NATS, simplifie considérablement le déploiement.

Pour un développeur junior : NestJS appelle "hybrid application" un processus qui combine plusieurs transports. Ici, un consommateur de messages RabbitMQ et un client NATS, dans la même application. Sans cela, il faudrait deux processus distincts, deux déploiements, deux configurations. Le pattern hybrid fusionne les deux dans un seul bootstrap, tout en permettant de les tester séparément.
cli.tsutilise la même arborescence de modules mais sans les transports réseau, ce qui donne un point d'entrée pour le développement local et le debugging, sans avoir à démarrer l'intégralité de l'infrastructure.
Ce que ce choix a rendu clair
Un choix de langage et de framework ne délimite pas seulement ce qu'on peut faire. Il délimite ce qu'on ne peut plus faire, et c'est souvent là que réside sa valeur.
Avec NestJS, on ne peut pas injecter un provider depuis un module qui ne l'exporte pas. On ne peut pas créer une dépendance circulaire sans que le framework la signale au démarrage. On ne peut pas ignorer le cycle de vie d'un service (Singleton ou Transient), le choix est explicite et vérifié. Ces contraintes ne sont pas des limitations. Ce sont des garde-fous que l'architecture impose à l'implémentation, plutôt que de laisser chaque développeur les réinventer.
Le langage est TypeScript. Le framework est NestJS. La question qui suit est celle des modules : comment découper ce service, selon quelle logique, et pourquoi ce découpage-là ne sera jamais neutre.
La prochaine fois : Le langage est choisi, le framework aussi. Reste la question la plus difficile : comment découper ce microservice en modules ? La prochaine fois, on cherche la ligne de fracture naturelle.