Depuis .NET 6, ASP.NET Core propose deux façons d’écrire une API HTTP : les Controllers MVC, historiques et très outillés, et les Minimal APIs, plus légères et devenues le modèle mis en avant par les templates. Avec .NET 10, l’écart fonctionnel s’est presque refermé : validation intégrée, OpenAPI natif, filtres, groupes de routes. Cet article compare les deux approches sur le même endpoint, montre comment organiser proprement des Minimal APIs, et propose une grille de choix nuancée.
1. Le même endpoint, écrit des deux façons
L’exemple suivi dans tout l’article est une petite API de gestion de tâches (une todo list d’équipe). Premier besoin : récupérer une tâche par son identifiant, avec un 404 si elle n’existe pas. Les types utilisés sont volontairement minimaux :
// Contrat exposé par l'API
public record TodoResponse(Guid Id, string Title, bool IsDone, DateOnly? DueDate);
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total);
// Service applicatif (EF Core, Dapper ou en mémoire : peu importe pour la comparaison)
public interface ITodoService
{
Task<TodoResponse?> GetByIdAsync(Guid id, CancellationToken ct);
Task<PagedResult<TodoResponse>> SearchAsync(TodoSearch search, CancellationToken ct);
Task<TodoResponse> CreateAsync(CreateTodoRequest request, CancellationToken ct);
}
TodoSearch et CreateTodoRequest sont définis plus bas, dans les sections sur la validation et le binding.
Version Controller
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/todos")]
public class TodosController(ITodoService todos) : ControllerBase
{
[HttpGet("{id:guid}")]
[ProducesResponseType<TodoResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TodoResponse>> GetById(Guid id, CancellationToken ct)
{
var todo = await todos.GetByIdAsync(id, ct);
return todo is null ? NotFound() : Ok(todo);
}
}
// Program.cs
builder.Services.AddControllers();
// ...
app.MapControllers();
Version Minimal API
using Microsoft.AspNetCore.Http.HttpResults;
app.MapGet("/api/todos/{id:guid}",
async Task<Results<Ok<TodoResponse>, NotFound>> (
Guid id, ITodoService todos, CancellationToken ct) =>
{
var todo = await todos.GetByIdAsync(id, ct);
return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});
Les deux versions font exactement la même chose. Les différences tiennent à ce qui les entoure :
- Le Controller est une classe instanciée à chaque requête, avec ses dépendances injectées par le constructeur, et découverte par convention (
AddControllers+MapControllers). - La Minimal API est un délégué enregistré explicitement ; ses dépendances sont des paramètres du handler, résolues par requête.
- Le type de retour
Results<Ok<TodoResponse>, NotFound>décrit les réponses possibles dans la signature : le compilateur vérifie qu’on ne renvoie rien d’autre, et OpenAPI en déduit la documentation sans attribut.
2. Deux pipelines différents
Les deux modèles partagent le même socle : middlewares, routing par endpoints, autorisation. La divergence commence une fois l’endpoint sélectionné.
graph LR
REQ["Requête HTTP"] --> MW["Middlewares<br/>(auth, CORS...)"]
MW --> RT["Routing<br/>sélection de l'endpoint"]
RT --> B1["Binding généré<br/>(RequestDelegateFactory<br/>ou RDG en AOT)"]
B1 --> V1["Filtre de validation<br/>(AddValidation)"]
V1 --> EF["Filtres d'endpoint<br/>IEndpointFilter"]
EF --> H["Handler<br/>lambda / méthode"]
H --> IR["IResult"]
RT --> INV["Action invoker MVC"]
INV --> AF["Filtres<br/>d'autorisation"]
AF --> RF["Filtres<br/>de ressource"]
RF --> MB["Model binding<br/>+ ModelState"]
MB --> ACF["Filtres d'action"]
ACF --> ACT["Action du<br/>contrôleur"]
ACT --> RSF["Filtres de résultat"]
RSF --> AR["IActionResult"]
style H fill:#4A90E2
style ACT fill:#F5A623
style RT fill:#7ED321
Côté Minimal API, le pipeline est court : un délégué de binding (généré à l’exécution, ou à la compilation en AOT), puis une chaîne de filtres d’endpoint, puis le handler. Côté MVC, l’action invoker déroule un pipeline de filtres à plusieurs étages, la découverte des contrôleurs, les conventions, les formatters, le ModelState. C’est plus riche, et c’est aussi plus de travail par requête.
3. Organiser des Minimal APIs
La critique classique : « tout finit dans un Program.cs de 800 lignes ». Ce n’est pas une fatalité. Trois outils suffisent à structurer proprement.
MapGroup : préfixe et conventions partagés
var todos = app.MapGroup("/api/todos")
.WithTags("Todos") // regroupement dans OpenAPI
.RequireAuthorization(); // s'applique à tous les endpoints du groupe
todos.MapGet("/{id:guid}", GetById);
todos.MapPost("/", Create);
Un groupe porte tout ce qu’on mettrait sur un contrôleur : préfixe de route, autorisation, filtres, rate limiting, tags. Les groupes peuvent s’imbriquer (/api → /todos).
Une méthode d’extension par feature
using Microsoft.AspNetCore.Http.HttpResults;
namespace TeamTodo.Api.Features.Todos;
public static class TodoEndpoints
{
public static RouteGroupBuilder MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todos")
.WithTags("Todos")
.RequireAuthorization();
group.MapGet("/{id:guid}", GetById).WithName("GetTodoById");
group.MapGet("/", Search);
group.MapPost("/", Create);
return group;
}
// ✅ Handlers en méthodes statiques nommées : testables, lisibles dans les stack traces
private static async Task<Results<Ok<TodoResponse>, NotFound>> GetById(
Guid id, ITodoService todos, CancellationToken ct)
{
var todo = await todos.GetByIdAsync(id, ct);
return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
}
private static async Task<Ok<PagedResult<TodoResponse>>> Search(
[AsParameters] TodoSearch search, ITodoService todos, CancellationToken ct)
=> TypedResults.Ok(await todos.SearchAsync(search, ct));
private static async Task<CreatedAtRoute<TodoResponse>> Create(
CreateTodoRequest request, ITodoService todos, CancellationToken ct)
{
var todo = await todos.CreateAsync(request, ct);
return TypedResults.CreatedAtRoute(todo, "GetTodoById", new { id = todo.Id });
}
}
// Program.cs reste un sommaire
app.MapTodoEndpoints();
app.MapProjectEndpoints();
Cette organisation « un fichier par feature » rapproche naturellement les Minimal APIs d’une architecture par tranches verticales.
TypedResults et Results<…>
| Écriture | Type renvoyé | Métadonnées OpenAPI |
|---|---|---|
Results.Ok(x) |
IResult |
❌ à déclarer avec .Produces<T>() |
TypedResults.Ok(x) |
Ok<T> |
✅ déduites automatiquement |
Results<Ok<T>, NotFound> |
union de types | ✅ toutes les branches documentées |
// ❌ IResult : OpenAPI ne sait pas ce que renvoie l'endpoint
app.MapGet("/a/{id}", (int id) => id > 0 ? Results.Ok(id) : Results.NotFound());
// ✅ Union typée : vérifiée à la compilation, documentée automatiquement
app.MapGet("/b/{id}", Results<Ok<int>, NotFound> (int id) =>
id > 0 ? TypedResults.Ok(id) : TypedResults.NotFound());
Les types TypedResults ont un autre avantage : un test unitaire peut appeler le handler et vérifier directement result.Result is NotFound, sans serveur HTTP.
4. Validation
Controllers : ModelState et [ApiController]
Avec [ApiController], les DataAnnotations sont évaluées pendant le model binding et une réponse 400 ValidationProblemDetails est renvoyée automatiquement si le ModelState est invalide. C’est le comportement qu’on connaît depuis des années.
Minimal APIs : la validation intégrée de .NET 10
Jusqu’à .NET 9, les Minimal APIs n’avaient pas de validation : il fallait FluentValidation ou un filtre maison. .NET 10 ajoute une validation native, activée par un seul appel :
using System.ComponentModel.DataAnnotations;
builder.Services.AddValidation();
public record CreateTodoRequest(
[Required, StringLength(200)] string Title,
DateOnly? DueDate,
[Range(1, 5)] int Priority,
[MaxLength(20)] List<ChecklistItemRequest>? Checklist);
public record ChecklistItemRequest(
[Required, StringLength(100)] string Label);
Le fonctionnement mérite d’être compris :
AddValidations’appuie sur un source generator qui découvre les types utilisés comme paramètres des handlers (et leurs types imbriqués) ; pas de réflexion, donc compatible AOT.- Un filtre d’endpoint est ajouté à chaque endpoint concerné. Il valide la query, les en-têtes et le corps, et répond
400avec unProblemDetailslistant les erreurs avant même que le handler soit appelé. - Le générateur ne voit que l’assembly où
AddValidationest appelé : si les endpoints vivent dans une autre bibliothèque, il faut l’appeler depuis celle-ci. IValidatableObjectet lesValidationAttributepersonnalisés sont pris en charge pour les règles plus complexes.- Les API ont été déplacées dans le namespace et package
Microsoft.Extensions.Validation, réutilisable hors HTTP.
On peut désactiver la validation pour un endpoint précis :
app.MapPost("/import", (CreateTodoRequest request) => TypedResults.Accepted("/import"))
.DisableValidation();
FluentValidation reste pertinent pour des règles métier riches (règles conditionnelles, validation asynchrone avec accès base) ; pour la validation de forme, l’outil intégré suffit désormais.
5. Filtres d’endpoint vs filtres MVC
Les deux modèles ont leur mécanisme d’interception autour du handler.
Filtre d’endpoint (Minimal API)
public sealed class RequireTenantFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
if (!context.HttpContext.Request.Headers.ContainsKey("X-Tenant-Id"))
{
return TypedResults.Problem(
"L'en-tête X-Tenant-Id est obligatoire.",
statusCode: StatusCodes.Status400BadRequest);
}
// Les arguments déjà bindés sont accessibles : context.GetArgument<Guid>(0)
return await next(context);
}
}
// Sur un endpoint ou un groupe entier
app.MapGroup("/api/todos").AddEndpointFilter<RequireTenantFilter>();
Filtre d’action (MVC)
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public sealed class RequireTenantAttribute : ActionFilterAttribute
{
public override async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!context.HttpContext.Request.Headers.ContainsKey("X-Tenant-Id"))
{
context.Result = new BadRequestObjectResult("L'en-tête X-Tenant-Id est obligatoire.");
return; // court-circuit : l'action n'est pas exécutée
}
await next();
}
}
| Filtres d’endpoint | Filtres MVC | |
|---|---|---|
| Étages | Un seul type, chaîné | Autorisation, ressource, action, exception, résultat |
| Accès aux arguments | GetArgument<T>(index) |
ActionArguments (dictionnaire par nom) |
| Application | Endpoint ou groupe | Action, contrôleur ou global |
| Exceptions | try/catch dans le filtre |
Filtre d’exception dédié |
Accès au ModelState |
❌ (pas de ModelState) |
✅ |
Le modèle MVC est plus fin, mais en pratique un filtre d’endpoint couvre l’essentiel des besoins : validation, logging, contrôle d’en-têtes, transformation du résultat. Pour une logique transverse à toute l’application, un middleware reste le bon niveau : il voit passer toutes les requêtes, avant même la sélection de l’endpoint.
6. Binding des paramètres
Les Minimal APIs infèrent la source de chaque paramètre :
| Paramètre | Source inférée |
|---|---|
Nom présent dans le template de route ({id}) |
Route |
Type simple (int, string, Guid…) |
Query string |
| Type enregistré dans le conteneur DI | Services ([FromServices] implicite) |
HttpContext, CancellationToken, ClaimsPrincipal |
Spéciaux, fournis par le framework |
| Type complexe sur POST/PUT/PATCH | Corps JSON |
Le [FromServices] implicite existe aussi côté Controllers depuis .NET 7 pour les paramètres d’action, avec [ApiController]. Pour un service à clé, on utilise [FromKeyedServices("clé")] dans les deux modèles.
[AsParameters] : regrouper les paramètres
Quand la liste de paramètres s’allonge, [AsParameters] permet de les regrouper dans un type dont chaque propriété (ou paramètre de constructeur) est bindée selon les mêmes règles :
using Microsoft.AspNetCore.Mvc;
public record struct TodoSearch(
[FromQuery] bool? Done,
[FromQuery] int? Page,
[FromQuery(Name = "size")] int? PageSize);
// GET /api/todos?done=false&page=2&size=50
group.MapGet("/", Search); // Search([AsParameters] TodoSearch search, ...)
C’est l’équivalent Minimal API d’un [FromQuery] TodoSearch search côté Controller, avec la possibilité de mélanger route, query, en-têtes et services dans le même objet.
7. OpenAPI
Les deux modèles sont pris en charge par le générateur natif Microsoft.AspNetCore.OpenApi, qui produit par défaut des documents OpenAPI 3.1 en .NET 10 :
builder.Services.AddOpenApi();
app.MapOpenApi(); // /openapi/v1.json
- Controllers : la documentation vient des attributs (
[ProducesResponseType],[Consumes]) et des commentaires XML, désormais pris en charge nativement en .NET 10. - Minimal APIs :
TypedResultsetResults<…>alimentent automatiquement les réponses ; on complète avec.WithName(),.WithSummary(),.WithDescription(),.ProducesProblem().
group.MapGet("/{id:guid}", GetById)
.WithName("GetTodoById")
.WithSummary("Récupère une tâche par son identifiant")
.ProducesProblem(StatusCodes.Status500InternalServerError);
8. Performances et Native AOT
Sur le débit brut, les Minimal APIs ont un avantage, car leur pipeline est plus court et leur binding est un délégué spécialisé plutôt qu’un mécanisme générique. Dans une vraie application, la base de données et le réseau dominent largement ce coût : ce n’est presque jamais un critère décisif à lui seul.
Le vrai différenciateur est Native AOT :
- Les Minimal APIs sont compatibles AOT. Avec
<PublishAot>true</PublishAot>, le Request Delegate Generator (RDG) génère le code de binding à la compilation, etWebApplication.CreateSlimBuilderréduit la surface du framework. - MVC n’est pas compatible Native AOT : il repose massivement sur la réflexion (découverte des contrôleurs, model binding, filtres).
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
// En AOT, la sérialisation JSON doit passer par un contexte généré
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
[JsonSerializable(typeof(TodoResponse))]
[JsonSerializable(typeof(PagedResult<TodoResponse>))]
internal partial class AppJsonContext : JsonSerializerContext;
Pour une fonction serverless, un conteneur qui doit démarrer en quelques dizaines de millisecondes ou un service à faible empreinte mémoire, cela tranche le débat.
9. Comment choisir
| Critère | Minimal APIs | Controllers |
|---|---|---|
| Nouveau projet .NET 10 | ✅ choix par défaut des templates | ✅ toujours supporté |
| Native AOT | ✅ | ❌ |
| Validation intégrée | ✅ AddValidation (.NET 10) |
✅ [ApiController] + ModelState |
| Organisation | Méthodes d’extension + MapGroup, à structurer soi-même |
Conventions imposées (un contrôleur = une ressource) |
| Filtres | Un niveau, simple | Pipeline à cinq étages |
OData, JsonPatch, formatters XML, conventions avancées |
Partiel ou à la main | ✅ écosystème mature |
| Équipe habituée à MVC, base existante | Coût de migration | ✅ continuité |
| Tests unitaires des handlers | ✅ fonctions statiques + TypedResults |
✅ instanciation du contrôleur |
| Architecture par features / vertical slices | ✅ naturel | Possible, moins direct |
Recommandation
- Nouveau service, API HTTP classique : Minimal APIs, organisées par feature avec
MapGroupet des handlers statiques typés. C’est la direction prise par l’équipe ASP.NET Core, et les manques d’hier (validation, OpenAPI, filtres) sont comblés. - Application existante en Controllers : aucune urgence à migrer. Les deux modèles cohabitent dans la même application (
MapControllers()etMapGroup()côte à côte) : on peut écrire les nouvelles features en Minimal APIs sans toucher à l’existant. - Besoins couverts uniquement par MVC (OData, conventions applicatives globales, model binders exotiques) : restez sur les Controllers, c’est précisément leur point fort.
- AOT ou démarrage à froid critique : Minimal APIs, sans discussion.
Le piège à éviter n’est pas de choisir l’un ou l’autre, c’est d’écrire des Minimal APIs sans structure : la légèreté du modèle impose de s’imposer ses propres conventions.
Résumé
| Sujet | Minimal APIs | Controllers |
|---|---|---|
| Définition | Délégué enregistré avec MapGet/MapPost |
Classe ControllerBase + attributs |
| Organisation | MapGroup + méthode d’extension par feature |
Un contrôleur par ressource |
| Types de retour | TypedResults, Results<…> |
ActionResult<T>, IActionResult |
| Validation | AddValidation() (.NET 10, source generator) |
[ApiController] + ModelState |
| Interception | IEndpointFilter |
Filtres MVC (5 types) |
| Binding | Inféré, [AsParameters], services implicites |
Attributs [From*], services implicites (.NET 7+) |
| OpenAPI | Déduit des types + .WithSummary() |
Attributs + commentaires XML |
| Native AOT | ✅ (RDG) | ❌ |
| Recommandation | Défaut pour les nouveaux projets | Existant et besoins spécifiques MVC |