Chaque requête qui arrive dans une application ASP.NET Core traverse une chaîne de middlewares avant d’atteindre un endpoint, puis la remonte en sens inverse avec la réponse. Authentification, gestion des erreurs, CORS, compression, fichiers statiques : tout est middleware. Comprendre ce pipeline, son ordre et ses pièges est indispensable dès qu’on dépasse le template par défaut.
1. Le principe du pipeline
Un middleware est un composant qui reçoit le HttpContext et une référence vers le suivant (next). Il peut agir avant d’appeler next, après, ou ne jamais l’appeler (court-circuit). Le pipeline est donc une suite de poupées russes : le premier middleware enregistré est le plus externe, le premier à voir la requête et le dernier à voir la réponse.
graph LR
C["Client"] -->|requête| M1["ExceptionHandler"]
M1 -->|next| M2["Authentication"]
M2 -->|next| M3["Authorization"]
M3 -->|next| E["Endpoint"]
E -.->|réponse| M3
M3 -.-> M2
M2 -.-> M1
M1 -.->|réponse| C
style C fill:#7ED321
style E fill:#4A90E2
style M1 fill:#F5A623
style M2 fill:#F5A623
style M3 fill:#F5A623
Techniquement, chaque middleware est un RequestDelegate (Task (HttpContext)) qui en enveloppe un autre. Le pipeline est construit une seule fois au démarrage, puis exécuté pour chaque requête.
2. Use, Run, Map, MapWhen, UseWhen
Ces cinq méthodes de IApplicationBuilder permettent de composer le pipeline.
Use : le middleware « classique »
app.Use(async (context, next) =>
{
// Avant : la requête descend
var start = Stopwatch.GetTimestamp();
await next(context);
// Après : la réponse remonte
var elapsed = Stopwatch.GetElapsedTime(start);
app.Logger.LogInformation("{Path} traité en {Elapsed} ms",
context.Request.Path, elapsed.TotalMilliseconds);
});
Run : middleware terminal
Run ne reçoit pas de next : il termine le pipeline. Tout ce qui est enregistré après n’est jamais atteint.
app.Run(async context => await context.Response.WriteAsync("Fin du pipeline"));
Map : branche par préfixe de chemin
// Toutes les requêtes /legacy/* partent dans une branche séparée
app.Map("/legacy", legacy =>
{
legacy.Use(async (ctx, next) => { /* ... */ await next(ctx); });
legacy.Run(ctx => ctx.Response.WriteAsync("API legacy"));
});
Dans la branche, Request.Path est amputé du préfixe et celui-ci est déplacé dans Request.PathBase.
MapWhen et UseWhen : branche par condition
La différence entre les deux est essentielle :
// MapWhen : branche TERMINALE, la requête ne rejoint jamais le pipeline principal
app.MapWhen(ctx => ctx.Request.Query.ContainsKey("debug"), branch =>
{
branch.Run(ctx => ctx.Response.WriteAsync("Mode debug"));
});
// UseWhen : branche qui REJOINT le pipeline principal ensuite
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), branch =>
{
branch.UseMiddleware<ApiKeyMiddleware>(); // middleware maison vérifiant un en-tête X-Api-Key, seulement pour /api
});
| Méthode | Condition | Rejoint le pipeline principal |
|---|---|---|
Use |
Toujours | ✅ (via next) |
Run |
Toujours | ❌ terminal |
Map |
Préfixe de chemin | ❌ |
MapWhen |
Prédicat | ❌ |
UseWhen |
Prédicat | ✅ (si la branche n’est pas court-circuitée) |
3. L’ordre recommandé
L’ordre d’enregistrement est l’ordre d’exécution. Un middleware d’authentification placé après l’endpoint ne sert à rien ; un gestionnaire d’exceptions placé en fin de chaîne ne voit pas les exceptions des middlewares précédents. Voici l’ordre recommandé par la documentation pour une API :
var app = builder.Build();
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); // implicite en Development avec WebApplication
else
{
app.UseExceptionHandler(); // 1. capture tout ce qui suit
app.UseHsts();
}
app.UseHttpsRedirection(); // 2.
app.UseStaticFiles(); // 3. peut court-circuiter tôt
app.UseRouting(); // 4. sélectionne l'endpoint
app.UseCors(); // 5. après le routing, avant l'auth
app.UseAuthentication(); // 6. qui est l'utilisateur ?
app.UseAuthorization(); // 7. a-t-il le droit ? (lit les métadonnées de l'endpoint)
app.UseRateLimiter(); // 8. politiques par endpoint
app.UseOutputCache(); // 9.
app.UseMiddleware<CorrelationIdMiddleware>(); // middlewares applicatifs
app.MapInventoryEndpoints(); // 10. endpoints (méthode d'extension regroupant les MapGet/MapPost)
app.Run();
graph TD
REQ["Requête"] --> EH["1. ExceptionHandler / HSTS"]
EH --> HTTPS["2. HttpsRedirection"]
HTTPS --> SF["3. StaticFiles<br/>(court-circuit si fichier)"]
SF --> RT["4. Routing<br/>sélection de l'endpoint"]
RT --> CORS["5. CORS"]
CORS --> AUTHN["6. Authentication"]
AUTHN --> AUTHZ["7. Authorization"]
AUTHZ --> RL["8-9. RateLimiter / OutputCache"]
RL --> CUSTOM["Middlewares applicatifs"]
CUSTOM --> EP["10. Endpoint<br/>(filtres + handler)"]
EP -.->|réponse| CUSTOM
CUSTOM -.-> RL
RL -.-> AUTHZ
AUTHZ -.-> AUTHN
AUTHN -.-> CORS
CORS -.-> RT
RT -.-> SF
SF -.-> HTTPS
HTTPS -.-> EH
EH -.->|réponse| REQ
style EH fill:#F5A623
style RT fill:#7ED321
style EP fill:#4A90E2
Avec WebApplication, UseRouting et l’exécution des endpoints sont ajoutés implicitement au début et à la fin du pipeline si on ne les appelle pas. On les appelle explicitement quand un middleware doit se placer avant le routing ou quand on veut rendre l’ordre visible.
4. Écrire un middleware
Classe conventionnelle
C’est la forme la plus courante. Aucune interface : une classe avec un constructeur prenant un RequestDelegate et une méthode InvokeAsync(HttpContext).
// Petite abstraction d'horloge, enregistrée en scoped pour l'exemple
public interface IClock { DateTimeOffset UtcNow { get; } }
public sealed class SystemClock : IClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }
// builder.Services.AddScoped<IClock, SystemClock>();
public sealed class RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context, IClock clock) // ← dépendance scoped injectée ici
{
var start = clock.UtcNow;
await next(context);
logger.LogInformation("Durée : {Duration}", clock.UtcNow - start);
}
}
app.UseMiddleware<RequestTimingMiddleware>();
Point crucial : une classe conventionnelle est instanciée une seule fois, au démarrage. Elle se comporte comme un singleton.
// AppDbContext : un DbContext EF Core, enregistré en scoped par AddDbContext
// ❌ DbContext scoped capturé dans le constructeur d'un singleton :
// même instance pour toutes les requêtes, problèmes de concurrence
public sealed class AuditMiddleware(RequestDelegate next, AppDbContext db) { /* ... */ }
// ✅ Les dépendances scoped passent par les paramètres d'InvokeAsync
public sealed class AuditMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, AppDbContext db) { /* ... */ }
}
IMiddleware : activation par le conteneur
L’alternative est d’implémenter IMiddleware. Le middleware est alors résolu depuis le conteneur DI à chaque requête, avec la durée de vie qu’on lui donne :
// Contexte par requête : le locataire (client) courant d'une application multi-tenant
public sealed class TenantContext { public string? TenantId { get; set; } }
public sealed class TenantMiddleware(TenantContext tenant) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
tenant.TenantId = context.Request.Headers["X-Tenant-Id"].ToString();
await next(context);
}
}
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<TenantMiddleware>(); // ← obligatoire, sinon exception au premier appel
app.UseMiddleware<TenantMiddleware>();
| Conventionnel | IMiddleware |
|
|---|---|---|
| Instanciation | Une fois au démarrage | À chaque requête, par le conteneur |
| Dépendances scoped | Paramètres d’InvokeAsync |
Constructeur |
| Enregistrement DI | Non requis | Requis |
| Typage | Par convention (pas d’interface) | Fortement typé |
Arguments supplémentaires à UseMiddleware |
✅ | ❌ |
5. Court-circuiter le pipeline
Ne pas appeler next arrête la descente : les middlewares suivants et l’endpoint ne sont pas exécutés, la réponse remonte immédiatement.
// Options liées à la section "Maintenance" de la configuration
public sealed class MaintenanceOptions { public bool Enabled { get; set; } }
// builder.Services.Configure<MaintenanceOptions>(builder.Configuration.GetSection("Maintenance"));
public sealed class MaintenanceMiddleware(RequestDelegate next, IOptionsMonitor<MaintenanceOptions> options)
{
public async Task InvokeAsync(HttpContext context)
{
if (options.CurrentValue.Enabled)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.Headers.RetryAfter = "120";
await context.Response.WriteAsync("Maintenance en cours.");
return; // ← court-circuit
}
await next(context);
}
}
Depuis .NET 8, un endpoint peut aussi court-circuiter les middlewares placés après le routing (authentification, autorisation, CORS…), utile pour les health checks ou les URL de bruit :
app.MapGet("/health", () => TypedResults.Ok()).ShortCircuit();
app.MapShortCircuit(404, "robots.txt", "favicon.ico");
6. Exemple complet : un middleware de corrélation
Objectif : chaque requête porte un identifiant de corrélation, repris de l’en-tête entrant s’il existe, sinon généré, renvoyé dans la réponse et ajouté au scope des logs.
using Microsoft.Extensions.Primitives;
public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
public const string HeaderName = "X-Correlation-ID";
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers.TryGetValue(HeaderName, out StringValues values)
&& !StringValues.IsNullOrEmpty(values)
&& values.ToString().Length <= 64 // ne pas faire confiance à l'entrée
? values.ToString()
: Guid.NewGuid().ToString("N");
context.TraceIdentifier = correlationId;
// ✅ L'en-tête est ajouté juste avant l'envoi de la réponse, quoi qu'il arrive en aval
context.Response.OnStarting(() =>
{
context.Response.Headers[HeaderName] = correlationId;
return Task.CompletedTask;
});
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
{
await next(context);
}
}
}
Placé juste après UseExceptionHandler, il garantit que tous les logs de la requête, y compris ceux de l’authentification, portent l’identifiant. Dans une application instrumentée avec OpenTelemetry, le TraceId W3C joue déjà ce rôle entre services ; un identifiant de corrélation métier reste utile pour les clients externes et le support.
Voici le parcours d’une requête à travers trois middlewares :
sequenceDiagram
participant C as Client
participant EH as ExceptionHandler
participant CID as CorrelationId
participant AUTH as Authentication
participant EP as Endpoint
C->>EH: GET /api/stock/42
EH->>CID: next(context)
Note over CID: lit ou génère X-Correlation-ID<br/>ouvre le scope de log
CID->>AUTH: next(context)
Note over AUTH: valide le jeton<br/>remplit HttpContext.User
AUTH->>EP: next(context)
EP-->>AUTH: 200 + corps JSON
AUTH-->>CID: retour
Note over CID: OnStarting ajoute<br/>X-Correlation-ID
CID-->>EH: retour
EH-->>C: 200 OK + X-Correlation-ID
7. Gestion des erreurs : UseExceptionHandler et IExceptionHandler
Historiquement, on passait une lambda à UseExceptionHandler pour fabriquer la réponse d’erreur à la main. Depuis .NET 8, l’approche plus propre est IExceptionHandler, combinée aux ProblemDetails (le format d’erreur JSON standard de la RFC 9457). L’exemple traduit deux exceptions métier en codes HTTP :
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
// Exceptions levées par la couche métier
public sealed class NotFoundException(string message) : Exception(message);
public sealed class ConflictException(string message) : Exception(message);
public sealed class DomainExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var (status, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Ressource introuvable"),
ConflictException => (StatusCodes.Status409Conflict, "Conflit"),
_ => (0, (string?)null)
};
if (status == 0)
return false; // ← pas pour moi : le handler suivant (ou le comportement par défaut) prend le relais
httpContext.Response.StatusCode = status;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = status,
Title = title,
Detail = exception.Message
}
});
}
}
builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier);
builder.Services.AddExceptionHandler<DomainExceptionHandler>(); // appelés dans l'ordre d'enregistrement
var app = builder.Build();
app.UseExceptionHandler(); // sans argument : s'appuie sur IExceptionHandler + ProblemDetails
app.UseStatusCodePages(); // ProblemDetails aussi pour les 404/405 sans corps
Si aucun handler ne renvoie true, le middleware produit une réponse 500 au format ProblemDetails, sans fuite de stack trace. Changement à connaître en .NET 10 : les exceptions traitées par un IExceptionHandler qui renvoie true ne sont plus journalisées en erreur par le middleware. Pour retrouver l’ancien comportement :
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = _ => false
});
8. Middleware ou filtre d’endpoint ?
Les deux interceptent une requête, mais pas au même niveau. Un filtre d’endpoint (IEndpointFilter) s’attache à un endpoint ou à un groupe de Minimal APIs et s’exécute autour du handler, une fois les paramètres bindés :
app.MapPost("/api/stock/{sku}/adjust", (string sku, int delta) => TypedResults.Ok())
.AddEndpointFilter(async (ctx, next) =>
{
var delta = ctx.GetArgument<int>(1); // argument déjà bindé
if (delta == 0)
return TypedResults.BadRequest("Ajustement nul.");
return await next(ctx);
});
Pour une comparaison plus large avec les filtres MVC, voir Minimal APIs vs Controllers.
| Middleware | Filtre d’endpoint | |
|---|---|---|
| Portée | Toute l’application (ou une branche) | Un endpoint ou un groupe |
| Position | Avant/après le routing | Après binding, autour du handler |
| Connaît l’endpoint | Seulement après UseRouting (GetEndpoint()) |
✅ toujours |
| Accès aux arguments bindés | ❌ | ✅ GetArgument<T>() |
| Accès au résultat typé | ❌ (seulement le flux de réponse) | ✅ valeur de retour du handler |
| Requêtes non routées (404, fichiers) | ✅ | ❌ |
| Exemples typiques | Erreurs, corrélation, CORS, compression | Validation, contrôle métier, audit d’un endpoint |
Règle simple : si la logique concerne le protocole HTTP et toutes les requêtes, c’est un middleware ; si elle concerne les arguments ou le résultat d’endpoints précis, c’est un filtre. On retrouve la même distinction côté gRPC avec les intercepteurs.
9. Les pièges classiques
Écrire après que la réponse a commencé
Dès que le premier octet du corps est envoyé, le code de statut et les en-têtes sont figés.
// ❌ InvalidOperationException si l'endpoint a déjà écrit le corps
app.Use(async (context, next) =>
{
await next(context);
context.Response.Headers["X-Server-Time"] = DateTime.UtcNow.ToString("O");
});
// ✅ OnStarting s'exécute juste avant l'envoi des en-têtes
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Server-Time"] = DateTime.UtcNow.ToString("O");
return Task.CompletedTask;
});
await next(context);
});
De même, dans un middleware de gestion d’erreurs maison, il faut tester context.Response.HasStarted avant de tenter de réécrire la réponse : si elle a commencé, on ne peut plus que journaliser et laisser la connexion se terminer.
Mauvais ordre routing / auth
// ❌ L'autorisation s'exécute avant que l'endpoint soit connu :
// les [Authorize] / RequireAuthorization() ne sont pas évalués correctement
app.UseAuthorization();
app.UseRouting();
// ❌ Autorisation avant authentification : HttpContext.User est encore anonyme
app.UseAuthorization();
app.UseAuthentication();
// ✅ Routing → CORS → Authentication → Authorization
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
Autres erreurs fréquentes
- Ne pas
awaitnext: la suite du pipeline s’exécute sans que le middleware attende sa fin ; les exceptions et le code « après » sont perdus. - Lire le corps de la requête sans
context.Request.EnableBuffering(): le flux n’est lisible qu’une fois, l’endpoint reçoit un corps vide. - Faire un travail coûteux dans un middleware global alors qu’il ne concerne que quelques routes : préférer
UseWhenou un filtre d’endpoint. - Capturer un service scoped dans le constructeur d’un middleware conventionnel (voir section 4).
Résumé
| Notion | À retenir |
|---|---|
| Pipeline | Chaîne de RequestDelegate, aller puis retour, ordre d’enregistrement = ordre d’exécution |
Use / Run |
Middleware avec next / middleware terminal |
Map / MapWhen |
Branche terminale par chemin / par prédicat |
UseWhen |
Branche conditionnelle qui rejoint le pipeline |
| Ordre | ExceptionHandler → HTTPS → StaticFiles → Routing → CORS → AuthN → AuthZ → RateLimiter → endpoints |
| Classe conventionnelle | Singleton, dépendances scoped dans InvokeAsync |
IMiddleware |
Résolu par le conteneur à chaque requête, enregistrement DI obligatoire |
| Court-circuit | Ne pas appeler next, ou ShortCircuit() / MapShortCircuit sur un endpoint |
| Erreurs | AddProblemDetails + IExceptionHandler + UseExceptionHandler() |
| Middleware vs filtre | HTTP transverse → middleware ; arguments/résultat d’un endpoint → filtre |
| Pièges | En-têtes après le début de la réponse (OnStarting), ordre routing/auth, await next |