Certaines décisions deviennent des réflexes.
Ouvrir Visual Studio, créer un projet ASP.NET Core, regarder le dossier Controllers/ apparaître. Créer une classe, hériter de ControllerBase, ajouter [ApiController], injecter les services dans le constructeur, écrire les attributs de routing. Retourner des ActionResult<T>.
Ce sont des conventions qu’on a apprises, intégrées, et qu’on reproduit naturellement d’un projet à l’autre sans vraiment les remettre en question.
Un après-midi, entre deux réunions, j’avais 45 minutes devant moi et une curiosité qui traînait depuis un moment. On venait d’écrire un nouveau endpoint assez classique, et j’ai décidé de le réécrire en Minimal API, juste pour voir ce que ça donnait concrètement.
45 minutes plus tard, l’endpoint tournait. Voilà ce que j’en ai retiré.
C’est quoi Minimal API, en une phrase ?
Une façon d’écrire des endpoints ASP.NET Core sans controller, sans héritage, sans attributs de routing. L’idée centrale est de réduire au strict nécessaire ce que le framework impose, pour se concentrer sur ce qui compte vraiment : la logique métier.
La suite de cet article ne cherche pas à convaincre que c’est mieux. Elle montre ce que ça change concrètement, sur un vrai endpoint, avec de l’auth, de la validation, du logging et des tests. À chacun de se faire sa propre opinion.
La première chose qui change : Program.cs
Dans la version avec controllers, Program.cs ressemble à ça :
MapControllers() enregistre l’ensemble des controllers de l’application en une seule ligne. C’est pratique, mais c’est aussi une boîte noire. Pour savoir ce que l’API expose réellement, quelles routes existent, lesquelles sont sécurisées, lesquelles appliquent une validation, il faut ouvrir chaque controller un par un.
Avec Minimal API, la situation est différente :
Les routes, la sécurité, les filtres de validation : tout est visible au même endroit, sans avoir à naviguer dans le code. C’est de la lisibilité au niveau architectural, et ça change la façon dont on appréhende un projet qu’on découvre ou qu’on reprend après plusieurs mois. Le code complet est disponible sur GitHub.
Le changement de CreateBuilder vers CreateSlimBuilder mérite aussi qu’on s’y arrête. Ce n’est pas juste un alias plus court. CreateBuilder initialise un pipeline complet : formatters XML, providers de configuration avancés, et une série de fonctionnalités dont une petite API n’a généralement pas besoin. CreateSlimBuilder prend le parti inverse : il démarre avec le strict nécessaire, et on ajoute explicitement ce dont on a besoin.
C’est un gain mesurable au démarrage, particulièrement en environnement conteneurisé, mais surtout un signal d’intention : cette application embarque uniquement ce dont elle a besoin.
Concrètement, certaines fonctionnalités courantes ne sont pas incluses par défaut et doivent être activées explicitement si nécessaire. La configuration HTTPS de Kestrel, par exemple :
Ou encore les contraintes de route avec expressions régulières, que certains projets utilisent pour valider le format des paramètres de route :
Mais la lisibilité de Program.cs n’est que la partie visible de l’iceberg. Ce qui change vraiment, c’est ce qu’il y a derrière : la façon dont le code métier est organisé et exprimé.
Ce que le controller cache
Voilà la structure du controller qu’on a remplacé :
Avant d’arriver à la première ligne de logique métier, il faut traverser l’héritage de ControllerBase, les attributs de routing, de documentation, et la déclaration du constructeur avec l’ensemble des dépendances du controller.
Ce qui est moins visible, c’est que le constructeur injecte toutes les dépendances pour tous les endpoints du controller. IPaymentService et IValidator sont injectés même pour GetOrders, qui n’en a pas besoin. Ce couplage implicite passe inaperçu quand le controller est petit, mais il devient une source de confusion à mesure que le nombre d’endpoints augmente.
Le handler équivalent déclare exactement ce dont il a besoin, et rien de plus :
En lisant cette signature, on sait immédiatement ce que le handler consomme, sans avoir à remonter au constructeur ni lire le corps de la méthode. La documentation OpenAPI et la sécurité ne sont plus dispersées dans des attributs sur la classe et sur la méthode — elles sont déclarées au même endroit que la route, dans le même flux de lecture.
On notera aussi l’utilisation de TypedResults à la place de Results. La différence est subtile mais utile : chaque branche de retour a un type précis connu à la compilation. TypedResults.NotFound() retourne un NotFound, TypedResults.Ok(...) retourne un Ok<OrderConfirmationDto>. Ce détail prend tout son sens, on le verra, au moment des tests.
La validation
Dans le controller, la validation est gérée inline, mélangée à la logique métier :
Ce n’est pas du mauvais code. Mais valider une requête et traiter une commande sont deux choses différentes, et les mélanger dans la même méthode rend le code plus difficile à lire et à réutiliser. Sur un controller avec plusieurs endpoints, ce bloc se répète, avec des variantes subtiles d’un endpoint à l’autre.
Minimal API propose une alternative plus propre avec IEndpointFilter. La validation est extraite dans une classe dédiée, réutilisable sur n’importe quel endpoint :
Et sur l’endpoint, une seule ligne suffit :
L’endpoint ne gère plus la validation. Il reçoit une requête déjà validée, et le code qui suit se concentre uniquement sur ce qui compte : la logique métier.
Les tests
Si vous avez regardé le code de près, vous avez peut-être remarqué quelque chose qui dérange : la logique métier est directement dans Program.cs. C’est pratique pour démarrer, mais ce n’est pas là qu’elle devrait vivre sur un vrai projet.
On va l’extraire dans un fichier dédié :
Le routing reste dans Program.cs, la logique métier va dans OrderEndpoints.cs. Et cette séparation apporte un bénéfice immédiat : les handlers deviennent des méthodes statiques, testables directement, sans avoir besoin de monter un pipeline HTTP complet.
Le test équivalent sur le controller, c’est une autre histoire :
Le ControllerContext, le DefaultHttpContext, le mock du validator même quand le scénario testé n’a rien à voir avec la validation — tout ça est du bruit. Et grâce à TypedResults, les assertions sont directes : le compilateur connaît le type exact de chaque branche de retour, sans cast, sans navigation dans un ActionResult<T>.
Ce que ça ne remplace pas
Minimal API n’est pas une solution universelle, et ce serait une erreur de le présenter comme tel.
Sur une codebase existante avec des dizaines de controllers déjà en place, migrer vers Minimal API est rarement une bonne idée. Le coût de la migration dépasse largement le bénéfice, et introduire deux styles d’écriture dans le même projet crée plus de confusion qu’autre chose.
Dans une équipe, la transition demande moins une montée en compétences qu’une mise en accord sur les conventions. Ce n’est pas une barrière technique, mais ça reste une décision collective qui mérite d’être prise ensemble plutôt que laissée à l’initiative de chacun.
Enfin, les projets qui s’appuient massivement sur les IActionFilter pour des comportements transversaux complexes devront réévaluer leur approche, car l’équivalent en Minimal API, IEndpointFilter, ne couvre pas exactement les mêmes cas.
Conclusion
Réécrire cet endpoint en Minimal API n’a pas pris 45 minutes parce que c’est simple. Ça a pris 45 minutes parce que le framework ne met plus rien entre le problème et sa solution.
Ce n’est pas une question de lignes de code en moins ou de performances en plus. C’est ce que le code dit de lui-même : quelles sont ses dépendances, ce qu’il valide, comment il est sécurisé. Avec les controllers, une partie de ces réponses est enfouie dans des conventions implicites. Avec Minimal API, elles sont là, explicites, au même endroit.
Est-ce que ça convient à tous les projets et toutes les équipes ? Non. Mais pour un nouveau projet ou un nouveau service, ne pas l’essayer serait dommage.
Le code est sur GitHub. Les deux versions tournent avec la même configuration. La meilleure façon de se faire une opinion, c’est encore de les comparer. 🙂










