[{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/application-layer/","section":"Tags","summary":"","title":"Application-Layer"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/architecture/","section":"Categories","summary":"","title":"Architecture"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/","section":"Categories","summary":"","title":"Categories"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier CQS et CQRS, deux des acronymes les plus mal cités de la sphère .NET.\nOn entend \u0026ldquo;on fait du CQRS\u0026rdquo; pour dire \u0026ldquo;on a un pipeline MediatR\u0026rdquo;, ou \u0026ldquo;CQRS c\u0026rsquo;est overkill pour nous\u0026rdquo; pour dire en fait \u0026ldquo;on n\u0026rsquo;a pas besoin d\u0026rsquo;event sourcing\u0026rdquo;. Les deux idées sont liées, mais elles vivent à des niveaux différents : CQS est une règle sur les méthodes, CQRS est une règle sur des chemins entiers de lecture et d\u0026rsquo;écriture. Savoir laquelle on est en train d\u0026rsquo;appliquer, c\u0026rsquo;est la différence entre une couche application propre et deux ans de regret.\nCet article déplie les deux, avec du code ASP.NET Core réaliste, et montre où se situe la ligne en pratique.\nLe contexte : pourquoi ces idées existent #CQS (Command Query Separation) a été formulé par Bertrand Meyer dans les années 1980 dans Object-Oriented Software Construction. Sa règle est simple : une méthode doit soit exécuter une action (une commande, qui change l\u0026rsquo;état et ne retourne rien), soit répondre à une question (une query, qui retourne des données sans modifier d\u0026rsquo;état), mais pas les deux. Le but n\u0026rsquo;est pas une pureté religieuse, c\u0026rsquo;est de rendre le raisonnement sur le code plus facile. Si on sait qu\u0026rsquo;une méthode est une query, on peut l\u0026rsquo;appeler cent fois dans un debugger sans se soucier des effets de bord.\nCQRS (Command Query Responsibility Segregation) a été introduit par Greg Young vers 2010, en s\u0026rsquo;appuyant sur les idées d\u0026rsquo;Udi Dahan et sur sa propre expérience avec l\u0026rsquo;event sourcing. Young a pris CQS et l\u0026rsquo;a poussé d\u0026rsquo;un cran plus haut : au lieu de séparer les commandes et les queries au niveau de la méthode, on les sépare au niveau architectural. Les écritures et les lectures deviennent deux chemins différents à travers l\u0026rsquo;application, potentiellement avec deux modèles différents, deux stores différents, et deux équipes différentes. CQRS est une réponse à une douleur précise : essayer de servir un modèle d\u0026rsquo;écriture riche et une dizaine de vues de lecture différentes à partir du même ensemble d\u0026rsquo;entités transforme les classes de domaine en sac de IsXxx, HasYyy, et \u0026ldquo;à inclure seulement sur l\u0026rsquo;écran de résumé\u0026rdquo;.\nLes deux idées ont la même lignée, mais elles résolvent des problèmes à des échelles très différentes. CQS, c\u0026rsquo;est un truc qu\u0026rsquo;on devrait probablement faire dans chaque classe qu\u0026rsquo;on écrit. CQRS, c\u0026rsquo;est un truc qu\u0026rsquo;on sort quand la complexité des lectures diverge vraiment de la complexité des écritures.\nVue d\u0026rsquo;ensemble : où chacun vit # graph TD subgraph CQS[\"CQS : règle au niveau méthode\"] A[OrderService] --\u003e B[\"PlaceOrder cmd : void\"] A --\u003e C[\"GetTotal query : decimal\"] end subgraph CQRS[\"CQRS : séparation architecturale\"] D[API] --\u003e E[Côté commande] D --\u003e F[Côté query] E --\u003e G[Modèle de domaine] G --\u003e H[(Store d'écriture)] F --\u003e I[(Store de lectureou projections)] end CQS vit dans une classe. CQRS vit à travers le code. On peut faire CQS sans CQRS. On peut aussi faire CQRS sans event sourcing, sans deux bases de données, et sans bus de messages. Perdre ces distinctions, c\u0026rsquo;est comme ça que \u0026ldquo;ajoutons CQRS\u0026rdquo; devient un projet de six mois.\nZoom : CQS dans un service, de bout en bout #Partons d\u0026rsquo;un service banal qui viole CQS, puis corrigeons-le. Voilà le genre de méthode que tu as presque certainement écrit ou relu :\npublic sealed class OrderService { private readonly ShopDbContext _db; public OrderService(ShopDbContext db) =\u0026gt; _db = db; // Viole CQS : modifie l\u0026#39;état ET retourne des données. public async Task\u0026lt;Order\u0026gt; PlaceOrderAsync(PlaceOrderInput input, CancellationToken ct) { var order = new Order(input.CustomerId, input.Lines); _db.Orders.Add(order); await _db.SaveChangesAsync(ct); return order; } } La méthode enregistre une commande et retourne l\u0026rsquo;entité complète. L\u0026rsquo;appelant a maintenant un Order tracké avec des propriétés de navigation chargeables en lazy, qui peuvent ou non être utilisables en sécurité en dehors de cette transaction. Deux préoccupations indépendantes voyagent sur la même valeur de retour. Les tests qui veulent affirmer \u0026ldquo;la commande a été enregistrée\u0026rdquo; finissent par aussi affirmer \u0026ldquo;l\u0026rsquo;entité retournée a la forme attendue\u0026rdquo;.\nLa version CQS le sépare :\npublic sealed class OrderService { private readonly ShopDbContext _db; public OrderService(ShopDbContext db) =\u0026gt; _db = db; // Commande : modifie l\u0026#39;état, retourne uniquement ce dont l\u0026#39;appelant a strictement besoin (un id). public async Task\u0026lt;Guid\u0026gt; PlaceOrderAsync(PlaceOrderInput input, CancellationToken ct) { var order = new Order(input.CustomerId, input.Lines); _db.Orders.Add(order); await _db.SaveChangesAsync(ct); return order.Id; } // Query : retourne des données, ne mute jamais. public async Task\u0026lt;OrderSummary\u0026gt; GetOrderSummaryAsync(Guid id, CancellationToken ct) { return await _db.Orders .AsNoTracking() .Where(o =\u0026gt; o.Id == id) .Select(o =\u0026gt; new OrderSummary(o.Id, o.Status.ToString(), o.Total)) .FirstAsync(ct); } } Deux méthodes, deux intentions. La commande retourne un id (un puriste retournerait void, mais un id est l\u0026rsquo;information minimale dont l\u0026rsquo;appelant a besoin pour router vers l\u0026rsquo;écran suivant, et ça reste trivialement \u0026ldquo;pas de l\u0026rsquo;état du domaine\u0026rdquo;). La query est en AsNoTracking, projette dans un DTO, et n\u0026rsquo;a aucune logique métier.\n💡 Info : Retourner un id depuis une commande est le compromis pragmatique que toute base de code .NET sérieuse finit par faire. Le CQS strict façon Meyer retournerait void et obligerait l\u0026rsquo;appelant à enchaîner avec une query. En pratique, ça coûte un aller-retour pour rien. Retourner Guid (ou Result\u0026lt;Guid\u0026gt;), c\u0026rsquo;est très bien.\n✅ Bonne pratique : Écris les queries avec AsNoTracking() et des projections vers des DTOs par défaut. Le change tracker est une fonctionnalité qu\u0026rsquo;on paye à chaque chargement, et les queries n\u0026rsquo;en ont presque jamais besoin.\nZoom : CQRS avec MediatR, la version honnête #CQRS au niveau architectural, ça veut dire : le chemin des commandes et le chemin des queries sont deux formes différentes, même s\u0026rsquo;ils partagent la même base de données. En .NET, la façon la plus courante d\u0026rsquo;exprimer ça, c\u0026rsquo;est avec MediatR (ou n\u0026rsquo;importe quel mediator in-process), en envoyant des objets ICommand\u0026lt;T\u0026gt; et IQuery\u0026lt;T\u0026gt; depuis la couche API vers des handlers.\nVoilà un handler de commande, le chemin d\u0026rsquo;écriture, qui passe par le modèle de domaine :\n// Application/Orders/Commands/SubmitOrderCommand.cs public sealed record SubmitOrderCommand(Guid OrderId) : IRequest\u0026lt;SubmitOrderResult\u0026gt;; public sealed record SubmitOrderResult(Guid OrderId, string Status); // Application/Orders/Commands/SubmitOrderHandler.cs public sealed class SubmitOrderHandler : IRequestHandler\u0026lt;SubmitOrderCommand, SubmitOrderResult\u0026gt; { private readonly ShopDbContext _db; private readonly IPaymentGateway _payments; public SubmitOrderHandler(ShopDbContext db, IPaymentGateway payments) { _db = db; _payments = payments; } public async Task\u0026lt;SubmitOrderResult\u0026gt; Handle(SubmitOrderCommand cmd, CancellationToken ct) { var order = await _db.Orders .Include(o =\u0026gt; o.Lines) .FirstOrDefaultAsync(o =\u0026gt; o.Id == cmd.OrderId, ct) ?? throw new NotFoundException($\u0026#34;Commande {cmd.OrderId} introuvable.\u0026#34;); order.Submit(); // fait respecter les invariants dans l\u0026#39;agrégat var charge = await _payments.ChargeAsync(order.CustomerId, order.Total, ct); if (!charge.Success) throw new PaymentFailedException(charge.Error); await _db.SaveChangesAsync(ct); return new SubmitOrderResult(order.Id, order.Status.ToString()); } } Et voilà un handler de query, le chemin de lecture, qui court-circuite complètement le domaine et projette directement depuis le DbContext vers la forme que l\u0026rsquo;UI veut réellement :\n// Application/Orders/Queries/GetOrderListQuery.cs public sealed record GetOrderListQuery(int Page, int PageSize, string? Status) : IRequest\u0026lt;PagedResult\u0026lt;OrderListItem\u0026gt;\u0026gt;; public sealed record OrderListItem( Guid Id, string CustomerName, decimal Total, string Status, DateTime PlacedAt); // Application/Orders/Queries/GetOrderListHandler.cs public sealed class GetOrderListHandler : IRequestHandler\u0026lt;GetOrderListQuery, PagedResult\u0026lt;OrderListItem\u0026gt;\u0026gt; { private readonly ShopDbContext _db; public GetOrderListHandler(ShopDbContext db) =\u0026gt; _db = db; public async Task\u0026lt;PagedResult\u0026lt;OrderListItem\u0026gt;\u0026gt; Handle( GetOrderListQuery q, CancellationToken ct) { var query = _db.Orders.AsNoTracking(); if (!string.IsNullOrWhiteSpace(q.Status)) query = query.Where(o =\u0026gt; o.Status.ToString() == q.Status); var total = await query.CountAsync(ct); var items = await query .OrderByDescending(o =\u0026gt; o.PlacedAt) .Skip((q.Page - 1) * q.PageSize) .Take(q.PageSize) .Select(o =\u0026gt; new OrderListItem( o.Id, o.Customer.Name, o.Total, o.Status.ToString(), o.PlacedAt)) .ToListAsync(ct); return new PagedResult\u0026lt;OrderListItem\u0026gt;(items, total, q.Page, q.PageSize); } } Même DbContext. Une seule base. Deux formes de code très différentes. La commande passe par les agrégats pour protéger les invariants. La query contourne les agrégats pour livrer des pixels. C\u0026rsquo;est CQRS à son maximum d\u0026rsquo;utilité : une séparation architecturale propre sans deux bases, sans event sourcing, sans casse-tête de cohérence éventuelle.\n💡 Info : Une seule base, deux chemins de code. On appelle parfois ça \u0026ldquo;soft CQRS\u0026rdquo; ou \u0026ldquo;CQRS lite\u0026rdquo;, et pour la plupart des applications métier, c\u0026rsquo;est la version qui mérite sa place. Les variantes plus lourdes (store de lecture séparé, event sourcing) sont traitées dans la dernière section de cet article.\nZoom : où s\u0026rsquo;insère la couche endpoint #Les commandes et les queries doivent être dispatchées depuis quelque part. Les endpoints (Controllers ou Minimal APIs) deviennent de fins dispatchers : ils bindent la requête, appellent mediator.Send, et retournent le résultat.\n// Endpoint, style Minimal API orders.MapPost(\u0026#34;/{id:guid}/submit\u0026#34;, async ( Guid id, ISender mediator, CancellationToken ct) =\u0026gt; { var result = await mediator.Send(new SubmitOrderCommand(id), ct); return TypedResults.Ok(result); }); orders.MapGet(\u0026#34;/\u0026#34;, async ( int page, int pageSize, string? status, ISender mediator, CancellationToken ct) =\u0026gt; { var result = await mediator.Send(new GetOrderListQuery(page, pageSize, status), ct); return TypedResults.Ok(result); }); Aucune logique métier dans l\u0026rsquo;endpoint. Binding, dispatch, retour. Pour le compromis sur le style d\u0026rsquo;endpoint (et pourquoi les deux exemples ci-dessus utilisent Minimal APIs), voir Endpoints en .NET : Controllers vs Minimal API.\n✅ Bonne pratique : Ajoute un pipeline behavior MediatR pour les préoccupations transverses (validation, logging, transactions). Un seul behavior enregistré dans le conteneur de DI s\u0026rsquo;applique à chaque commande et chaque query, ce qui évite de parsemer des try/catch ou des using var tx = ... dans chaque handler.\npublic sealed class TransactionBehavior\u0026lt;TRequest, TResponse\u0026gt; : IPipelineBehavior\u0026lt;TRequest, TResponse\u0026gt; where TRequest : IRequest\u0026lt;TResponse\u0026gt; { private readonly ShopDbContext _db; public TransactionBehavior(ShopDbContext db) =\u0026gt; _db = db; public async Task\u0026lt;TResponse\u0026gt; Handle( TRequest request, RequestHandlerDelegate\u0026lt;TResponse\u0026gt; next, CancellationToken ct) { // On n\u0026#39;emballe que les commandes dans une transaction, pas les queries. if (request is not ICommand) return await next(); await using var tx = await _db.Database.BeginTransactionAsync(ct); var response = await next(); await tx.CommitAsync(ct); return response; } } Zoom : quand CQRS mérite sa complexité #Un setup soft CQRS (handlers séparés, base partagée) est presque toujours une bonne idée dans une application non triviale. Les variantes plus lourdes sont une autre conversation :\nStore de lecture séparé : quand le modèle de lecture est vraiment différent (dénormalisé, indexé full-text, distribué géographiquement), et que le garder en phase avec le store d\u0026rsquo;écriture vaut le coût opérationnel. Event sourcing : quand on a besoin de reconstruire l\u0026rsquo;historique, de supporter des queries temporelles, ou d\u0026rsquo;auditer chaque changement d\u0026rsquo;état. C\u0026rsquo;est un engagement énorme. Ça change la façon dont on conçoit les tests, les migrations et les déploiements. APIs de commande et de query séparées : quand des équipes différentes possèdent les lectures et les écritures, ou quand il faut scaler les deux indépendamment. Chacune de ces couches résout un vrai problème, et chacune ajoute un vrai coût. La bonne question n\u0026rsquo;est pas \u0026ldquo;doit-on faire CQRS\u0026rdquo;, c\u0026rsquo;est \u0026ldquo;quelle couche de CQRS est-ce que le problème réclame vraiment\u0026rdquo;.\n⚠️ Ça marche, mais\u0026hellip; : Ajouter une base de lecture séparée et un bus de messages à un système avec 3 agrégats et 12 endpoints est un classique de la sur-ingénierie. Les handlers séparés à l\u0026rsquo;intérieur d\u0026rsquo;une seule base donnent l\u0026rsquo;essentiel du bénéfice (chemins de lecture propres, pas de pollution du domaine, optimisation de queries plus facile) avec quasiment aucun du coût.\n❌ Ne jamais faire : Ne force pas les queries à passer par les agrégats du domaine \u0026ldquo;par cohérence\u0026rdquo;. Une query qui charge un agrégat Order, parcourt ses lignes, et les reformate en DTO en C#, sera plus lente, consommera plus de mémoire, et sera plus dure à maintenir qu\u0026rsquo;une simple projection Select. Les lectures et les écritures ont le droit d\u0026rsquo;avoir des formes différentes, et c\u0026rsquo;est voulu.\nWrap-up #Tu sais maintenant la différence entre CQS et CQRS : CQS est la règle au niveau méthode qui dit qu\u0026rsquo;une méthode doit soit faire soit demander, jamais les deux ; CQRS est le pattern architectural qui prend cette règle et la remonte à l\u0026rsquo;échelle des chemins de commande et de query de l\u0026rsquo;application. Tu peux écrire du CQS propre dans n\u0026rsquo;importe quelle classe de service dès aujourd\u0026rsquo;hui. Tu peux adopter un soft CQRS avec MediatR et une seule base sur n\u0026rsquo;importe quel projet .NET greenfield. Et tu peux sortir les variantes plus lourdes (stores séparés, event sourcing) uniquement quand le problème l\u0026rsquo;exige, pas parce qu\u0026rsquo;un article de blog l\u0026rsquo;a dit.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Endpoints en .NET : Controllers vs Minimal API, la comparaison honnête Vertical Slicing en .NET : organise par fonctionnalité, pas par couche Références # Command Query Separation, Bertrand Meyer (référence livre original) CQRS Documents, Greg Young MediatR sur GitHub Entity Framework Core, Microsoft Learn Pattern CQRS, Microsoft Learn ","date":"10 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/layer-focused-cqs-cqrs/","section":"Posts","summary":"","title":"Couche Application en .NET : CQS et CQRS sans le hype"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/cqrs/","section":"Tags","summary":"","title":"Cqrs"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/cqs/","section":"Tags","summary":"","title":"Cqs"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/dotnet/","section":"Tags","summary":"","title":"Dotnet"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/","section":"Posts","summary":"","title":"Posts"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/","section":"Road to Senior .NET Developer","summary":"","title":"Road to Senior .NET Developer"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/","section":"Tags","summary":"","title":"Tags"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier .NET Aspire, la proposition de Microsoft pour retirer le ciment qu\u0026rsquo;on écrivait à la main pour orchestrer plusieurs services .NET.\nPendant une décennie, l\u0026rsquo;écart entre \u0026ldquo;ma solution .NET tourne sur mon poste\u0026rdquo; et \u0026ldquo;ma solution .NET est déployée sur une plateforme cloud\u0026rdquo; a été comblé par de l\u0026rsquo;outillage que le développeur devait assembler lui-même : un docker-compose pour l\u0026rsquo;orchestration locale, un jeu séparé de manifests Kubernetes ou ACA pour le déploiement, du câblage OpenTelemetry par service, un dashboard pour regarder les traces, une façon de passer des chaînes de connexion aux containers. Chaque équipe réinventait le même ciment, un peu différemment, et la friction rendait les microservices .NET plus chers à démarrer qu\u0026rsquo;ils n\u0026rsquo;auraient dû l\u0026rsquo;être.\n.NET Aspire comble cet écart. Sorti en GA en mai 2024, c\u0026rsquo;est le framework opinionné de Microsoft pour composer, faire tourner, et déployer des applications .NET multi-services. Ce n\u0026rsquo;est pas une nouvelle plateforme d\u0026rsquo;hébergement. C\u0026rsquo;est une couche d\u0026rsquo;orchestration C#-first qui se pose au-dessus de l\u0026rsquo;hébergement déjà utilisé (Docker, Kubernetes, ACA), remplaçant le YAML à la main et les scripts shell par un projet AppHost typé qui décrit toute la topologie en C#. Pour beaucoup d\u0026rsquo;équipes .NET, surtout celles qui démarrent de nouvelles applications distribuées, cela retire une quantité significative de boilerplate sans verrouiller personne sur un cloud précis.\nCe dernier article de la série Deployment couvre ce qu\u0026rsquo;est réellement Aspire, comment l\u0026rsquo;utiliser comme outil de dev et de déploiement, et dans quels cas c\u0026rsquo;est le bon choix.\nLe contexte : pourquoi .NET Aspire existe #Aspire répond à une observation précise : chaque application .NET non triviale en 2024 se ressemblait dans sa couche d\u0026rsquo;orchestration. Elle avait une API, un worker, une base, un cache, peut-être un broker de messages. Chaque service avait besoin d\u0026rsquo;OpenTelemetry configuré, d\u0026rsquo;une chaîne de connexion câblée, de health checks enregistrés, et d\u0026rsquo;une façon de tourner en local contre les mêmes dépendances. Les équipes écrivaient les mêmes vingt lignes de ciment par service, à vie, et chacune les écrivait un peu différemment.\nLes objectifs d\u0026rsquo;Aspire, formulés concrètement :\nRemplacer docker-compose par un modèle C# typé. La topologie de l\u0026rsquo;application (quels services tournent, ce dont ils dépendent, ce avec quoi ils parlent) est décrite dans un projet .NET classique appelé le AppHost, avec typage fort, IntelliSense, et support du refactoring. Standardiser les préoccupations transverses. OpenTelemetry, health checks, service discovery, policies de résilience, et logging structuré sont packagés dans les Service Defaults, un projet partagé que chaque service de la solution référence. On ajoute la référence, on appelle une méthode d\u0026rsquo;extension, et on a tout. Fournir un dashboard local. Quand on appuie sur F5, Aspire démarre tous les services et ouvre un dashboard local qui montre les traces, les métriques, les logs, et la sortie console de chaque process, au même endroit. Émettre des manifests de déploiement pour de vraies cibles. Le même AppHost peut générer les manifests nécessaires pour déployer sur Azure Container Apps, Kubernetes, ou Docker Compose, sans que le dev les écrive à la main. C\u0026rsquo;est la partie qui remplace le problème du \u0026ldquo;je dois maintenir trois descriptions de déploiement différentes\u0026rdquo;. Vue d\u0026rsquo;ensemble : la forme d\u0026rsquo;un projet Aspire # graph TD A[Projet AppHosttopologie C#] --\u003e B[Shop.Api] A --\u003e C[Shop.Worker] A --\u003e D[Ressource Postgres] A --\u003e E[Ressource Redis] A --\u003e F[Azure Service Bus] B --\u003e D B --\u003e E C --\u003e D C --\u003e F G[Projet ServiceDefaultsOTel, health, résilience] --\u003e B G --\u003e C H[Dashboard Aspire] --\u003e B H --\u003e C H --\u003e D Une solution Aspire a une forme distinctive. Deux nouveaux projets se placent à côté des projets de service habituels :\nAppHost : un projet console qui référence chaque projet de service de la solution et déclare, en C#, les ressources dont chacun dépend. Quand on lance le AppHost, il démarre tous les projets référencés, lance les dépendances (Postgres, Redis, quoi que ce soit), et câble les chaînes de connexion automatiquement.\nServiceDefaults : une bibliothèque de classes que chaque projet de service référence. Elle contient les méthodes d\u0026rsquo;extension qui branchent OpenTelemetry, les endpoints de health check, le service discovery, et les policies de résilience en un seul appel. Au lieu de copier-coller 30 lignes de setup de télémétrie dans chaque Program.cs, on appelle builder.AddServiceDefaults() et c\u0026rsquo;est fait.\nLe reste de la solution (le projet API, le projet worker, la bibliothèque de domaine) est du code .NET classique, inchangé. Aspire ne demande pas de restructurer l\u0026rsquo;application. Il ajoute l\u0026rsquo;orchestration par-dessus.\nZoom : le projet AppHost #// Shop.AppHost/Program.cs var builder = DistributedApplication.CreateBuilder(args); // Dépendances managées. Aspire les démarre automatiquement en mode dev. var postgres = builder.AddPostgres(\u0026#34;db\u0026#34;) .WithDataVolume() .AddDatabase(\u0026#34;shopdb\u0026#34;); var redis = builder.AddRedis(\u0026#34;cache\u0026#34;) .WithDataVolume(); var servicebus = builder.AddAzureServiceBus(\u0026#34;sb\u0026#34;) .AddQueue(\u0026#34;orders-inbound\u0026#34;); // Le projet API, avec des références explicites à ses dépendances. var api = builder.AddProject\u0026lt;Projects.Shop_Api\u0026gt;(\u0026#34;shop-api\u0026#34;) .WithReference(postgres) .WithReference(redis) .WithReference(servicebus) .WithExternalHttpEndpoints() .WithReplicas(2); // Le projet worker. builder.AddProject\u0026lt;Projects.Shop_Worker\u0026gt;(\u0026#34;shop-worker\u0026#34;) .WithReference(postgres) .WithReference(servicebus); builder.Build().Run(); Douze lignes de C# décrivent toute la topologie d\u0026rsquo;une application distribuée. Cinq choses à noter.\nAddPostgres(\u0026quot;db\u0026quot;) avec WithDataVolume() ne se contente pas de démarrer un container. Cela déclare Postgres comme une ressource managée dans le AppHost, persiste ses données entre les runs via un volume Docker, et expose sa chaîne de connexion à tout projet qui appelle WithReference(postgres). L\u0026rsquo;appel AddDatabase(\u0026quot;shopdb\u0026quot;) crée la base à l\u0026rsquo;intérieur de l\u0026rsquo;instance Postgres automatiquement.\nAddAzureServiceBus(\u0026quot;sb\u0026quot;) est un cas intéressant. En mode dev, Aspire lance un émulateur (basé sur un container) qui parle le protocole Service Bus. En prod, le même descripteur AppHost mappe sur un vrai namespace Azure Service Bus. Le code de l\u0026rsquo;application ne change pas entre les deux ; Aspire résout la différence au moment du déploiement.\nWithReference(postgres) est la magie. Cela prend la chaîne de connexion qu\u0026rsquo;Aspire construit pour le Postgres managé et l\u0026rsquo;injecte dans le projet référencé comme variable d\u0026rsquo;environnement, en suivant la même convention de nommage qu\u0026rsquo;ASP.NET Core utilise (ConnectionStrings__db). Le projet la lit ensuite depuis IConfiguration sans aucune glu supplémentaire.\nWithExternalHttpEndpoints() marque le projet comme joignable de l\u0026rsquo;extérieur. En dev local, Aspire assigne un port aléatoire et l\u0026rsquo;affiche dans le dashboard. En prod, cela mappe sur une règle d\u0026rsquo;ingress sur la plateforme cible.\nWithReplicas(2) déclare combien d\u0026rsquo;instances du projet doivent tourner. En dev local, Aspire lance deux copies et load balance entre les deux. En prod, le nombre se traduit en nombre de réplicas sur Kubernetes ou ACA.\n💡 Info : Le catalogue de méthodes Add* d\u0026rsquo;Aspire couvre la plupart des dépendances courantes nativement : Postgres, SQL Server, MySQL, Redis, MongoDB, RabbitMQ, Kafka, Azure Service Bus, Azure Storage, Azure Cosmos DB, Azure Key Vault, et d\u0026rsquo;autres. La liste complète est dans les packages NuGet Aspire.Hosting.*. Les intégrations tierces (Dapr, NATS, Elastic) sont disponibles comme packages communautaires.\nZoom : le projet ServiceDefaults #Chaque service de la solution référence un projet ServiceDefaults partagé qui fournit le setup transverse commun :\n// Shop.ServiceDefaults/Extensions.cs public static class Extensions { public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) { builder.ConfigureOpenTelemetry(); builder.AddDefaultHealthChecks(); builder.Services.AddServiceDiscovery(); builder.Services.ConfigureHttpClientDefaults(http =\u0026gt; { http.AddStandardResilienceHandler(); http.AddServiceDiscovery(); }); return builder; } public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { builder.Logging.AddOpenTelemetry(logging =\u0026gt; { logging.IncludeFormattedMessage = true; logging.IncludeScopes = true; }); builder.Services.AddOpenTelemetry() .WithMetrics(metrics =\u0026gt; { metrics.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation(); }) .WithTracing(tracing =\u0026gt; { tracing.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation(); }); builder.AddOpenTelemetryExporters(); return builder; } } Et dans le Program.cs de chaque service :\n// Shop.Api/Program.cs var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddDbContext\u0026lt;ShopDbContext\u0026gt;(options =\u0026gt; options.UseNpgsql(builder.Configuration.GetConnectionString(\u0026#34;shopdb\u0026#34;))); var app = builder.Build(); app.MapDefaultEndpoints(); // /health/live, /health/ready app.MapOrdersEndpoints(); app.Run(); Deux appels : AddServiceDefaults() dans ConfigureServices et MapDefaultEndpoints() dans le pipeline. Chaque service a maintenant OpenTelemetry câblé au dashboard, des endpoints de health check, du service discovery via DNS, et des clients HTTP résilients avec retry et disjoncteur. Pas de copier-coller. Pas de dérive. Si l\u0026rsquo;équipe décide d\u0026rsquo;ajouter un nouvel exporter de télémétrie ou une nouvelle policy de résilience, cela se passe à un seul endroit.\n✅ Bonne pratique : Garder ServiceDefaults sous revue stricte. C\u0026rsquo;est le rayon d\u0026rsquo;impact pour le comportement de démarrage de chaque service. Les changements l\u0026rsquo;affectent tous d\u0026rsquo;un coup, ce qui est exactement ce qui le rend précieux et exactement ce qui le rend dangereux. Le traiter comme une bibliothèque partagée avec ses propres release notes.\nZoom : le Dashboard Aspire #Quand on appuie sur F5 sur le AppHost, Aspire démarre le dashboard sur un port local et l\u0026rsquo;ouvre dans le navigateur. Le dashboard montre :\nRessources : chaque service et dépendance, avec leur statut, leurs ports, leurs variables d\u0026rsquo;environnement, et leurs logs container. Logs console : une vue unifiée de stdout/stderr de chaque process qui tourne, avec filtrage par service et niveau de log. Logs structurés : les entrées ILogger, indexées et cherchables. Traces : les spans OpenTelemetry, avec tracing distribué entre services. Une seule requête qui tape l\u0026rsquo;API, interroge Postgres, publie sur Service Bus et déclenche le worker s\u0026rsquo;affiche comme une seule trace avec tous les spans. Métriques : les compteurs runtime (GC, thread pool, durée des requêtes HTTP) et toutes les métriques custom émises par l\u0026rsquo;application. C\u0026rsquo;est, pour beaucoup d\u0026rsquo;équipes, le bénéfice le plus visible de l\u0026rsquo;adoption d\u0026rsquo;Aspire. Obtenir le même niveau d\u0026rsquo;observabilité locale sans Aspire demande de faire tourner Jaeger, Prometheus, Grafana et un agrégateur de logs dans un fichier compose, de configurer chacun, et de s\u0026rsquo;assurer que chaque service exporte vers le bon endpoint. Aspire fait tout cela par défaut, in-process, avec zéro configuration.\n💡 Info : Le Dashboard Aspire est une application autonome. Il peut aussi tourner contre n\u0026rsquo;importe quelle charge compatible OpenTelemetry (y compris des applications non-Aspire) via l\u0026rsquo;image standalone mcr.microsoft.com/dotnet/aspire-dashboard. Certaines équipes l\u0026rsquo;adoptent comme stack d\u0026rsquo;observabilité locale même quand elles n\u0026rsquo;utilisent pas le reste d\u0026rsquo;Aspire.\nZoom : déployer une application Aspire #Aspire n\u0026rsquo;est pas une plateforme d\u0026rsquo;hébergement. Il génère des manifests ou des ressources pour une vraie plateforme d\u0026rsquo;hébergement. Le chemin de déploiement canonique utilise l\u0026rsquo;Azure Developer CLI (azd) pour déployer une solution Aspire sur Azure Container Apps avec une seule commande.\n# Une fois, à la racine de la solution azd init # wizard interactif, détecte le AppHost azd auth login # authentification Azure # Chaque déploiement ensuite azd up # provisionne les ressources Azure et déploie Sous le capot, azd up fait trois choses :\nProvisionne l\u0026rsquo;infrastructure. À partir de la description du AppHost, azd génère un template Bicep qui crée les ressources Azure nécessaires : un Container Apps Environment, un workspace Log Analytics, un namespace Service Bus (parce que le AppHost en référence un), un Postgres Flexible Server, et ainsi de suite. Construit les images container pour chaque projet de service de la solution, en utilisant le publish container standard du SDK .NET (dotnet publish -t:PublishContainer), et les pousse dans un Azure Container Registry que azd provisionne aussi. Déploie les Container Apps avec les bonnes variables d\u0026rsquo;environnement, secrets, configuration d\u0026rsquo;ingress, et comptes de réplicas, dérivés du AppHost. L\u0026rsquo;aller-retour complet, de git clone à un environnement de type prod qui tourne sur Azure, prend typiquement moins de 10 minutes sur un compte neuf.\nPour les équipes qui ciblent Kubernetes à la place, Aspire peut émettre un manifest via aspire publish :\naspire publish --publisher kubernetes --output ./deploy/k8s Cela génère des manifests Kubernetes pour chaque service du AppHost, qui peuvent ensuite être personnalisés avec Kustomize (couvert dans le primer Kubernetes) ou packagés avec Helm. La sortie générée est un point de départ, pas l\u0026rsquo;artefact final, mais elle capture le graphe de dépendances et le câblage d\u0026rsquo;environnement, qui est la partie fastidieuse.\n⚠️ Ça marche, mais\u0026hellip; : azd up est excellent pour le dev, les démos, et les environnements de proof of concept. Pour la prod, la plupart des équipes passent à un vrai pipeline CI/CD avec des étages build, test, et deploy séparés, en utilisant le manifest Aspire comme entrée de leur outillage de déploiement existant plutôt que d\u0026rsquo;appeler azd up depuis un poste de travail.\nZoom : quand Aspire est le bon choix #Aspire est particulièrement bien adapté pour :\nLes nouvelles applications distribuées .NET où l\u0026rsquo;équipe veut une rampe d\u0026rsquo;accès rapide au dev multi-services sans assembler le ciment depuis zéro. Les solutions existantes qui luttent avec les préoccupations transverses. Si l\u0026rsquo;équipe a cinq services et que chacun a un setup OpenTelemetry légèrement différent, les déplacer tous sous un ServiceDefaults partagé est un gain net. Les équipes qui veulent de l\u0026rsquo;observabilité locale sans faire tourner une stack compose parallèle pour Jaeger, Prometheus, et compagnie. Les boutiques .NET Azure-first. Le chemin de déploiement azd est l\u0026rsquo;expérience la plus fluide sur Azure. Cela marche ailleurs, mais les aspérités sont moins nombreuses sur Azure. Les démos, les ateliers, et les outils internes où la rapidité F5-to-running compte plus que la flexibilité de déploiement. Ce n\u0026rsquo;est pas le bon choix quand :\nLa solution est un seul service. La valeur d\u0026rsquo;Aspire vient de l\u0026rsquo;orchestration de plusieurs services. Pour une API seule, le AppHost est du surcoût sans bénéfice. L\u0026rsquo;équipe a un pipeline de déploiement mature. S\u0026rsquo;il y a déjà un setup Kubernetes + Helm + GitOps qui marche, introduire Aspire comme couche d\u0026rsquo;authoring peut créer de la friction plutôt que la réduire. Des services non-.NET font partie de la topologie. Aspire peut référencer des containers ou des exécutables de n\u0026rsquo;importe quel langage, mais sa meilleure intégration est avec les projets .NET. Un système polyglotte avec de gros services Python, Go ou Node.js s\u0026rsquo;intègre mieux dans un workflow compose-first ou Kubernetes-first. La cible n\u0026rsquo;est ni Azure ni Kubernetes. Aspire peut générer des fichiers compose, mais ses chemins de déploiement les plus forts sont ACA et K8s. Pour des VMs nues, IIS, ou des hôtes Docker classiques, le bénéfice est moindre. Wrap-up #.NET Aspire remplace le pattern du \u0026ldquo;chaque équipe réinvente le même ciment\u0026rdquo; par une couche d\u0026rsquo;orchestration C#-first et typée qui décrit toute la topologie dans le projet AppHost, standardise l\u0026rsquo;observabilité et la résilience via ServiceDefaults, fournit un dashboard local gratuitement, et génère des manifests de déploiement pour Azure Container Apps, Kubernetes ou Docker Compose. Tu peux démarrer une nouvelle application distribuée .NET avec deux projets supplémentaires et une poignée de lignes de code, obtenir les traces et les métriques sur le dashboard sans rien câbler, et déployer sur ACA avec azd up en quelques minutes. Tu peux aussi reconnaître quand une solution existante ne bénéficierait pas de la migration et rester sur les outils d\u0026rsquo;hébergement et de déploiement déjà en place.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core avec Docker : un guide pragmatique Héberger ASP.NET Core sur Kubernetes : l\u0026rsquo;essentiel pour les devs .NET Héberger ASP.NET Core sur Azure Container Apps Docker pour le déploiement .NET : Dockerfile et Compose en pratique Kubernetes : l\u0026rsquo;essentiel pour les devs .NET, de kubectl à Helm Références # Documentation .NET Aspire, Microsoft Learn Vue d\u0026rsquo;ensemble .NET Aspire Documentation Azure Developer CLI Dashboard Aspire standalone .NET Aspire sur GitHub ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/deployment-dotnet-aspire/","section":"Posts","summary":"","title":".NET Aspire : l'orchestration cloud-native simplifiée"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/aspire/","section":"Tags","summary":"","title":"Aspire"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/controllers/","section":"Tags","summary":"","title":"Controllers"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/dapper/","section":"Tags","summary":"","title":"Dapper"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va comprendre quand sortir Dapper et comment le faire cohabiter avec EF Core.\nDapper existe depuis 2011, écrit par l\u0026rsquo;équipe de Stack Overflow pour gérer la charge SQL qu\u0026rsquo;Entity Framework de l\u0026rsquo;époque n\u0026rsquo;arrivait pas à tenir. Il est toujours maintenu, toujours rapide, toujours la bonne réponse pour un ensemble précis de problèmes. L\u0026rsquo;erreur que font la plupart des équipes, c\u0026rsquo;est de traiter ça comme un choix entre Dapper et EF Core, comme s\u0026rsquo;il fallait en retenir un pour tout le projet. En pratique, les deux cohabitent proprement : EF Core pour les écritures et pour les 90% de lectures que LINQ sait bien exprimer, Dapper pour les requêtes de reporting, les dashboards, les exports massifs et les hot paths où le SQL n\u0026rsquo;est fondamentalement pas une requête LINQ.\nCet article décrit ce qu\u0026rsquo;est vraiment Dapper, où il gagne, comment l\u0026rsquo;utiliser sans reconstruire un mini-ORM autour de lui, et comment le faire tourner dans le même code qu\u0026rsquo;EF Core.\nLe contexte : pourquoi Dapper existe #Dapper est un ensemble de méthodes d\u0026rsquo;extension sur IDbConnection. C\u0026rsquo;est toute la bibliothèque. On ouvre une connexion, on appelle connection.QueryAsync\u0026lt;T\u0026gt;(\u0026quot;SELECT ...\u0026quot;, parameters), et Dapper mappe le result set vers T en faisant correspondre les noms de colonnes aux noms de propriétés. Pas de modèle, pas de change tracker, pas de migrations, pas de provider LINQ, pas de mapping de relations. On écrit le SQL, Dapper l\u0026rsquo;exécute et hydrate les objets.\nCe minimalisme, c\u0026rsquo;est le principe. EF Core résout le problème \u0026ldquo;j\u0026rsquo;ai un modèle de domaine et je veux que la base suive\u0026rdquo;. Dapper résout le problème \u0026ldquo;j\u0026rsquo;ai une requête SQL et je veux qu\u0026rsquo;elle me renvoie des objets typés\u0026rdquo;. Ce sont deux problèmes différents, et une équipe qui comprend la distinction arrête de se disputer sur lequel choisir.\nL\u0026rsquo;écart de performance entre Dapper et EF Core s\u0026rsquo;est nettement réduit depuis EF Core 6. Sur une requête d\u0026rsquo;entité simple, EF Core avec AsNoTracking() et une projection est typiquement à 10-15% de Dapper. L\u0026rsquo;écart se creuse sur les requêtes aux formes SQL inhabituelles : window functions, CTE récursives, UNION ALL sur des tables sans rapport, ordre de JOIN tuné à la main. Ce sont les requêtes où Dapper gagne son salaire, non pas parce qu\u0026rsquo;il est plus rapide sur les cas triviaux mais parce que les écrire en LINQ est soit impossible, soit produit un SQL traduit qu\u0026rsquo;on ne livrerait pas.\nVue d\u0026rsquo;ensemble : où Dapper trouve sa place # graph TD A[Besoin d'accès aux données] --\u003e B{Forme} B --\u003e|Écriture domaine : charger l'agrégat, modifier, sauver| C[EF Core] B --\u003e|Lecture typée liste/détail d'une entité| C B --\u003e|Requête de reporting, CTE, window function| D[Dapper] B --\u003e|Export massif, endpoint read-heavy| D B --\u003e|Stitching multi-tables avec SQL tuné à la main| D C --\u003e E[ShopDbContext] D --\u003e F[IDbConnection + fichiers SQL] Le partage se fait par forme de requête, pas par domaine fonctionnel. Un même module peut avoir EF Core pour ses command handlers et Dapper pour le read model qui alimente le dashboard.\nZoom : une installation Dapper propre #public sealed class OrderReports { private readonly string _connectionString; public OrderReports(IConfiguration config) { _connectionString = config.GetConnectionString(\u0026#34;Shop\u0026#34;) ?? throw new InvalidOperationException(\u0026#34;Connection string manquante\u0026#34;); } public async Task\u0026lt;IReadOnlyList\u0026lt;MonthlyRevenueRow\u0026gt;\u0026gt; GetMonthlyRevenueAsync(int year, CancellationToken ct) { const string sql = \u0026#34;\u0026#34;\u0026#34; SELECT date_trunc(\u0026#39;month\u0026#39;, o.created_at) AS Month, SUM(o.total_amount) AS Revenue, COUNT(*) AS OrderCount FROM orders o WHERE EXTRACT(YEAR FROM o.created_at) = @Year AND o.status = \u0026#39;completed\u0026#39; GROUP BY date_trunc(\u0026#39;month\u0026#39;, o.created_at) ORDER BY Month; \u0026#34;\u0026#34;\u0026#34;; await using var conn = new NpgsqlConnection(_connectionString); var rows = await conn.QueryAsync\u0026lt;MonthlyRevenueRow\u0026gt;( new CommandDefinition(sql, new { Year = year }, cancellationToken: ct)); return rows.ToList(); } } public sealed record MonthlyRevenueRow(DateTime Month, decimal Revenue, int OrderCount); Trois choses à noter. D\u0026rsquo;abord, CommandDefinition est la surcharge moderne qui accepte un CancellationToken. À utiliser sur chaque appel : l\u0026rsquo;annulation n\u0026rsquo;est pas gratuite sur un rapport long. Ensuite, les paramètres passent via un objet anonyme, que Dapper transforme en SQL paramétré. On ne concatène jamais une entrée utilisateur dans la chaîne SQL. Enfin, le SQL est dans un raw string literal (\u0026quot;\u0026quot;\u0026quot;), ce qui le garde lisible et indenté naturellement.\n💡 Info — Les raw string literals sont arrivés en C# 11. Pour des frameworks cibles plus anciens, on déplace le SQL dans un fichier .sql embarqué et on le lit avec typeof(OrderReports).Assembly.GetManifestResourceStream(...).\nZoom : réutiliser la connexion EF Core #Quand EF Core et Dapper cohabitent dans la même requête, l\u0026rsquo;erreur commune est d\u0026rsquo;ouvrir deux connexions séparées. EF Core en a une ouverte dans DbContext, et Dapper en ouvre une deuxième, ce qui double l\u0026rsquo;usage du pool de connexions et casse toute garantie transactionnelle qu\u0026rsquo;on aurait pu vouloir entre les deux. La correction, c\u0026rsquo;est de demander sa connexion au DbContext :\npublic sealed class OrderReports { private readonly ShopDbContext _db; public OrderReports(ShopDbContext db) =\u0026gt; _db = db; public async Task\u0026lt;IReadOnlyList\u0026lt;MonthlyRevenueRow\u0026gt;\u0026gt; GetMonthlyRevenueAsync(int year, CancellationToken ct) { var conn = _db.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); const string sql = /* même SQL que plus haut */; var rows = await conn.QueryAsync\u0026lt;MonthlyRevenueRow\u0026gt;( new CommandDefinition(sql, new { Year = year }, cancellationToken: ct)); return rows.ToList(); } } _db.Database.GetDbConnection() renvoie la DbConnection sous-jacente qu\u0026rsquo;EF Core gère. Dapper peut exécuter n\u0026rsquo;importe quelle requête dessus, et on reste dans la même transaction s\u0026rsquo;il y en a une d\u0026rsquo;active. Quand le DbContext est disposé à la fin du scope, la connexion retourne au pool.\n✅ Bonne pratique — Pour tout ce qui tourne dans une requête qui touche aussi EF Core, on partage la connexion. Pour les jobs d\u0026rsquo;arrière-plan ou les endpoints de reporting autonomes sans EF Core, un NpgsqlConnection dédié reste parfaitement correct.\nZoom : résultats multi-lignes et splitOn #Quand la requête joint plusieurs tables et qu\u0026rsquo;on veut un parent avec ses enfants mappés dans des types CLR différents, on utilise QueryAsync avec un splitOn :\nconst string sql = \u0026#34;\u0026#34;\u0026#34; SELECT o.id, o.reference, o.total_amount, c.id, c.email, c.loyalty_tier FROM orders o INNER JOIN customers c ON c.id = o.customer_id WHERE o.created_at \u0026gt; @Since; \u0026#34;\u0026#34;\u0026#34;; var rows = await conn.QueryAsync\u0026lt;OrderRow, CustomerRow, OrderWithCustomer\u0026gt;( sql, (order, customer) =\u0026gt; new OrderWithCustomer(order, customer), new { Since = since }, splitOn: \u0026#34;id\u0026#34;); splitOn: \u0026quot;id\u0026quot; dit à Dapper où couper la ligne de résultat entre les deux types mappés. Tout ce qui va de la première colonne jusqu\u0026rsquo;à la première colonne id suivante (exclue) devient le premier type, le reste devient le second. C\u0026rsquo;est le seul détail Dapper qui piège tout le monde la première fois.\n⚠️ Ça marche, mais\u0026hellip; — Pour les requêtes qui joignent quatre tables ou plus, la syntaxe splitOn devient difficile à lire. À ce stade, on renvoie des DTO plats et on fait le stitching en C#, ou on déplace la requête vers une vue SQL et on mappe la vue vers un seul DTO.\nZoom : DynamicParameters et procédures stockées #var parameters = new DynamicParameters(); parameters.Add(\u0026#34;CustomerId\u0026#34;, customerId, DbType.Guid); parameters.Add(\u0026#34;Since\u0026#34;, since, DbType.DateTime2); parameters.Add(\u0026#34;TotalOut\u0026#34;, dbType: DbType.Decimal, direction: ParameterDirection.Output); await conn.ExecuteAsync( \u0026#34;sp_compute_customer_total\u0026#34;, parameters, commandType: CommandType.StoredProcedure); var total = parameters.Get\u0026lt;decimal\u0026gt;(\u0026#34;TotalOut\u0026#34;); DynamicParameters est l\u0026rsquo;échappatoire pour tout ce qui dépasse les paramètres d\u0026rsquo;entrée simples : paramètres de sortie, valeurs de retour, procédures stockées, contrôle typé du DbType pour les cas où le mapping par défaut de Dapper est faux.\n❌ Ne jamais faire — Ne pas construire le SQL par concaténation de chaînes sous prétexte que \u0026ldquo;c\u0026rsquo;est juste pour un dashboard admin\u0026rdquo;. Le dashboard admin est le premier endroit où une injection SQL se remarque, parce que c\u0026rsquo;est là que quelqu\u0026rsquo;un finit par coller une valeur de filtre avec une apostrophe dedans. Des paramètres, toujours.\nZoom : quand ne pas utiliser Dapper #Dapper est le mauvais choix pour trois choses :\nÉcrire les agrégats de domaine. Pas de change tracker, donc soit on écrit tous les INSERT et UPDATE à la main, soit on finit par reconstruire un EF Core mal ficelé. On garde les écritures sur EF Core. Filtrage multi-tenant. Les global query filters d\u0026rsquo;EF Core sont déclaratifs et difficiles à oublier. En Dapper, le filtre de tenant est un WHERE tenant_id = @TenantId qu\u0026rsquo;il faut se rappeler sur chaque requête. Un oubli, et on lit les données d\u0026rsquo;un autre tenant. Projets avec des développeurs juniors et pas de culture de revue SQL. Dapper fait confiance à l\u0026rsquo;auteur. Il ne protège pas d\u0026rsquo;un mauvais ordre de JOIN, d\u0026rsquo;un index manquant, ou d\u0026rsquo;une requête qui a l\u0026rsquo;air bien en dev et qui met la prod à genoux. Sur une équipe qui ne sait pas relire le SQL, les garde-fous d\u0026rsquo;EF Core valent leur léger surcoût. Wrap-up #Tu sais maintenant que Dapper et EF Core sont complémentaires, pas concurrents. EF Core pour le côté écriture et les lectures typées que LINQ exprime proprement ; Dapper pour le reporting, les requêtes à base de CTE, les dashboards, et tout ce où le SQL est le langage de première classe. Partager la connexion via _db.Database.GetDbConnection() permet aux deux de cohabiter dans la même requête et la même transaction. Une fois le partage clair, le débat \u0026ldquo;EF Core ou Dapper\u0026rdquo; s\u0026rsquo;arrête d\u0026rsquo;être un débat.\nPrêt à sortir Dapper sur la prochaine requête de reporting qui résiste à LINQ, ou à poser ce partage dans ton équipe ?\nÀ la prochaine, a++ 👋\nPour aller plus loin # EF Core : Optimisation des Lectures Accès aux données en .NET : Repository Pattern, ou pas ? Couche Application : CQS et CQRS Références # Dapper sur GitHub Dapper sur NuGet EF Core : Accès à la connexion ADO.NET sous-jacente Documentation Npgsql ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/database-dapper/","section":"Posts","summary":"","title":"Dapper : quand et comment l'utiliser"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/database/","section":"Categories","summary":"","title":"Database"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/database/","section":"Tags","summary":"","title":"Database"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/deployment/","section":"Categories","summary":"","title":"Deployment"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/deployment/","section":"Tags","summary":"","title":"Deployment"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier le diagnostic d\u0026rsquo;un problème de performance base de données, de A à Z.\nLes incidents de performance base de données, c\u0026rsquo;est la catégorie où des ingénieurs expérimentés continuent de dégainer des hypothèses. \u0026ldquo;C\u0026rsquo;est sûrement un index manquant.\u0026rdquo; \u0026ldquo;Ça doit être un mauvais plan d\u0026rsquo;exécution.\u0026rdquo; \u0026ldquo;Le pool de connexions est plein.\u0026rdquo; Parfois ils tombent juste du premier coup ; la plupart du temps ils remplacent une hypothèse par une autre jusqu\u0026rsquo;à ce que les symptômes bougent. Le problème n\u0026rsquo;est pas la connaissance, c\u0026rsquo;est la méthode. Un flow de diagnostic reproductible bat une bonne hypothèse à chaque fois, parce qu\u0026rsquo;il converge sur la vraie cause en minutes plutôt qu\u0026rsquo;en heures, et parce qu\u0026rsquo;il produit des preuves qu\u0026rsquo;on peut attacher au post-mortem.\nCet article pose cette méthode : comment trier le symptôme, comment confirmer le vrai goulot avant de toucher à quoi que ce soit, comment lire un plan d\u0026rsquo;exécution sans s\u0026rsquo;y perdre, et les patterns côté .NET qui ressemblent à des problèmes de base mais ne le sont pas.\nLe contexte : pourquoi le diagnostic base demande une méthode #La base de données est une boîte noire avec une interface publique, et cette interface ment sur la cause de la latence. Un endpoint lent mesuré depuis le navigateur peut être la base, le réseau, la sérialisation, la taille de la réponse JSON, un foreach qui await une requête par itération, ou une pause GC sur le serveur applicatif. Une requête qui tourne en 200 ms dans SSMS et en 2 secondes depuis l\u0026rsquo;appli peut être du parameter sniffing, une option de chaîne de connexion différente, une collation différente, ou l\u0026rsquo;application qui ouvre une seconde transaction que la requête doit attendre. Chacun de ces cas a un correctif différent, et choisir la mauvaise hypothèse gâche les trente premières minutes de l\u0026rsquo;incident.\nLa méthode existe pour que ces trente premières minutes produisent des preuves plutôt que des théories.\nVue d\u0026rsquo;ensemble : le flow de diagnostic # flowchart TD A[Symptôme : endpoint lent ou timeout] --\u003e B[Étape 1 : mesurer où va le temps] B --\u003e C{Goulot ?} C --\u003e|Côté .NET| D[Profiler l'appli : GC, sérialisation, N+1] C --\u003e|Côté base| E[Étape 2 : capturer la requête] E --\u003e F[Étape 3 : plan d'exécution] F --\u003e G{Problème de plan ?} G --\u003e|Index manquant| H[Ajouter l'index] G --\u003e|Mauvaise estimation| I[Update statistics / réécrire] G --\u003e|Plan OK| J[Étape 4 : wait types] J --\u003e K{Il attend quoi ?} K --\u003e|Locks| L[Chaîne de blocage] K --\u003e|IO| M[Stockage / taille des données] K --\u003e|CPU| N[Requête chaude ou régression] Quatre étapes, dans l\u0026rsquo;ordre, à chaque fois. Sauter l\u0026rsquo;étape 1, c\u0026rsquo;est la raison pour laquelle une équipe passe une heure à tuner une requête qui n\u0026rsquo;était pas le goulot.\nÉtape 1 : mesurer où va le temps #Avant d\u0026rsquo;ouvrir SSMS, on confirme que l\u0026rsquo;endpoint lent est vraiment lent à cause de la base. Avec de l\u0026rsquo;instrumentation OpenTelemetry, la réponse est dans la trace :\nservices.AddOpenTelemetry() .WithTracing(t =\u0026gt; t .AddAspNetCoreInstrumentation() .AddEntityFrameworkCoreInstrumentation(o =\u0026gt; o.SetDbStatementForText = true) .AddNpgsql() .AddOtlpExporter()); Un span de requête avec ses spans enfants dit immédiatement si les 2 secondes de latence sont 1,9 seconde de base sur une seule requête, 2 secondes de 400 requêtes à 5 ms chacune (N+1), ou 100 ms de base et 1,9 seconde de quelque chose d\u0026rsquo;autre. Si la somme des spans base représente une petite fraction de la requête, le goulot est côté .NET et aucun tuning de requête n\u0026rsquo;aidera.\nSans traçage, la mesure minimale viable est un chrono autour de l\u0026rsquo;appel base et un compteur de requêtes par request :\nvar sw = Stopwatch.StartNew(); var count = 0; _db.Database.SetCommandInterceptor(new CountingInterceptor(() =\u0026gt; count++)); var result = await handler.HandleAsync(request, ct); _logger.LogInformation(\u0026#34;Request {Route} took {Elapsed} ms with {Count} queries\u0026#34;, HttpContext.Request.Path, sw.ElapsedMilliseconds, count); \u0026ldquo;400 requêtes dans une seule request\u0026rdquo;, c\u0026rsquo;est déjà un diagnostic avant d\u0026rsquo;en avoir lu une seule.\n💡 Info — Depuis EF Core 7, DbCommandInterceptor donne un hook propre pour compter les commandes sans toucher au LINQ. On garde l\u0026rsquo;intercepteur en développement uniquement ; en production, le compteur de spans OpenTelemetry porte la même information.\nÉtape 2 : capturer la requête exacte #Une fois qu\u0026rsquo;on sait que la base est le goulot, on a besoin du SQL exact, avec les vraies valeurs de paramètres, pour le reproduire hors de l\u0026rsquo;appli. LogTo d\u0026rsquo;EF Core ou le span OpenTelemetry donne ça. On copie la requête à l\u0026rsquo;identique. On ne la réécrit pas \u0026ldquo;pour plus de clarté\u0026rdquo; : les décisions de tuning dépendent du SQL littéral que le driver a envoyé.\nSELECT o.id, o.reference, o.total_amount, c.email FROM orders o INNER JOIN customers c ON c.id = o.customer_id WHERE o.status = \u0026#39;completed\u0026#39; AND o.created_at \u0026gt; \u0026#39;2026-01-01\u0026#39; ORDER BY o.created_at DESC LIMIT 50; On la fait tourner sur une copie de production. Une vraie copie, pas la donnée de seed dev. La majorité des requêtes lentes le sont à cause de la forme des données : la base dev a 500 lignes et un hash join ; la production a 40 millions de lignes et un nested loop join sur un index manquant. On ne diagnostique pas ça sur une table de 500 lignes.\nÉtape 3 : lire le plan d\u0026rsquo;exécution #Toute base relationnelle expose le plan d\u0026rsquo;exécution. Sur PostgreSQL :\nEXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ... ; Sur SQL Server :\nSET STATISTICS IO, TIME ON; -- ou activer \u0026#34;Include Actual Execution Plan\u0026#34; dans SSMS SELECT ... ; On lit le plan pour trois choses, dans l\u0026rsquo;ordre :\n1. Où va vraiment le temps ? Le plan est un arbre d\u0026rsquo;opérations ; chaque nœud rapporte son propre coût et son propre nombre de lignes. Le nœud coûteux est celui sur lequel on met son attention. Ce n\u0026rsquo;est souvent pas là où on s\u0026rsquo;attendait.\n2. L\u0026rsquo;estimation de lignes est-elle loin de la réalité ? Un écart de 100x entre estimé et réel signale presque toujours un mauvais choix du planificateur : un nested loop là où un hash join aurait été meilleur, un tri qui a débordé sur disque, un bitmap qui n\u0026rsquo;a pas aidé. La correction est en général UPDATE STATISTICS, ou une réécriture du prédicat d\u0026rsquo;une forme que le planificateur sait estimer.\n3. Y a-t-il un scan séquentiel sur une grosse table ? Un scan séquentiel sur 40 millions de lignes, c\u0026rsquo;est la signature classique de \u0026ldquo;index manquant\u0026rdquo;. Ce n\u0026rsquo;est pas toujours faux : si la requête ramène 30% des lignes, le scan séquentiel est correct. Mais si la requête ramène 50 lignes et que le plan en scanne 40 millions, il manque un index.\n-- La correction pour la requête d\u0026#39;exemple, sur PostgreSQL : CREATE INDEX idx_orders_status_created_at ON orders (status, created_at DESC) WHERE status = \u0026#39;completed\u0026#39;; Un index partiel sur les lignes qui matchent le prédicat le plus fréquent est plus petit, plus rapide à maintenir, et plus sélectif qu\u0026rsquo;un index complet. La plupart des dashboards filtrent sur un ou deux statuts ; un index partiel épouse exactement ce pattern d\u0026rsquo;accès.\n✅ Bonne pratique — Ajouter le plan d\u0026rsquo;exécution (le \u0026ldquo;avant\u0026rdquo; et le \u0026ldquo;après\u0026rdquo;) à la pull request qui introduit l\u0026rsquo;index. Le toi du futur, en relisant le commit dans six mois, voudra savoir pourquoi cet index précis existe.\n⚠️ Ça marche, mais\u0026hellip; — Ajouter un index à partir du missing-index hint sans lire le plan fonctionne assez souvent pour être tentant. Ça produit aussi des index en doublon, des ralentissements côté écriture et des index qui couvrent des requêtes que plus personne ne lance. On lit le plan ; on ajoute l\u0026rsquo;index dont on a besoin.\nÉtape 4 : wait types #Quand le plan est bon et que la requête est toujours lente, la base attend quelque chose. Chaque SGBD majeur expose les wait types :\n-- PostgreSQL SELECT pid, wait_event_type, wait_event, state, query FROM pg_stat_activity WHERE state = \u0026#39;active\u0026#39;; -- SQL Server SELECT session_id, wait_type, wait_time, blocking_session_id, last_wait_type FROM sys.dm_exec_requests WHERE session_id \u0026gt; 50; Trois catégories d\u0026rsquo;attente couvrent la quasi-totalité des cas :\nLock waits : la requête est bloquée par une autre transaction. La correction est en amont : transactions plus courtes, niveau d\u0026rsquo;isolation différent, ou découpage de la requête bloquante en plus petites. IO waits : la requête attend le stockage. La correction est en général une question de forme de données (lignes plus petites, meilleure compression, archivage du vieux) ou d\u0026rsquo;infrastructure (disques plus rapides, plus de mémoire pour que la donnée chaude tienne en cache). CPU : pas d\u0026rsquo;attente, la requête calcule. La correction est une réécriture ou un index couvrant. 💡 Info — pg_stat_activity et sys.dm_exec_requests sont des vues point-in-time. Pour les incidents récurrents, on installe un sampler qui en prend un snapshot toutes les 5 secondes dans un log, pour que le prochain incident ait un historique.\nZoom : les pièges côté .NET qui ressemblent à un problème de base #La moitié des incidents \u0026ldquo;la base est lente\u0026rdquo; vus en 15 ans n\u0026rsquo;étaient pas des incidents base. Les classiques :\nÉpuisement du pool de connexions. Quand toutes les connexions sont prises, la requête suivante bloque jusqu\u0026rsquo;à la libération d\u0026rsquo;une. Ça ressemble à une requête lente dans le log applicatif, mais la base montre la requête comme \u0026ldquo;4 ms une fois démarrée\u0026rdquo;. La correction, c\u0026rsquo;est trouver la fuite de connexion (en général un DbContext gardé plus longtemps qu\u0026rsquo;une request, ou un await using oublié), pas tuner la requête.\nTask.Run autour d\u0026rsquo;un appel EF Core synchrone. Lancer une requête sync sur un thread du pool a l\u0026rsquo;air correct jusqu\u0026rsquo;à ce que le thread pool sature, moment où chaque requête commence à faire la queue sur le thread pool avant même d\u0026rsquo;atteindre la base. On convertit en async, toujours.\nUn foreach qui await une requête par itération. La latence totale est N * query_time, ce qui ressemble à une requête lente jusqu\u0026rsquo;à ce qu\u0026rsquo;on réalise qu\u0026rsquo;il y en a 400. La correction est le pattern projection/include de EF Core : Optimisation des Lectures.\nLa sérialisation qui domine la réponse. Une réponse JSON de 20 Mo prend une seconde à sérialiser, quel que soit l\u0026rsquo;état de la base. Le span base dans la trace est à 20 ms ; le temps de réponse est à 1200 ms. La correction, c\u0026rsquo;est la pagination.\n❌ Ne jamais faire — Ne pas \u0026ldquo;corriger\u0026rdquo; un goulot côté .NET en ajoutant un index en base. L\u0026rsquo;index n\u0026rsquo;aidera pas et le chemin d\u0026rsquo;écriture ralentira pour tout le monde.\nWrap-up #Tu sais maintenant la méthode : quatre étapes, dans l\u0026rsquo;ordre. Mesurer où va le temps, capturer la requête exacte, lire le plan d\u0026rsquo;exécution, et si le plan est bon, regarder les wait types. Ajouter un index quand le plan le dit, pas quand le ticket d\u0026rsquo;incident dit \u0026ldquo;sûrement un index manquant\u0026rdquo;. Et toujours confirmer que la base est le goulot avant d\u0026rsquo;y toucher, parce que la moitié des incidents \u0026ldquo;la base est lente\u0026rdquo; sont des problèmes côté .NET déguisés. Avec ce flow, les incidents cessent d\u0026rsquo;être du deviner pour devenir une checklist.\nPrêt à sortir cette méthode au prochain ticket de perf, ou à la partager avec ton équipe pour que le prochain incident commence par une mesure plutôt qu\u0026rsquo;une hypothèse ?\nÀ la prochaine, a++ 👋\nPour aller plus loin # EF Core : Optimisation des Lectures Dapper : quand et comment l\u0026rsquo;utiliser EF Core : les Migrations en pratique Références # PostgreSQL : Using EXPLAIN PostgreSQL : pg_stat_activity SQL Server : Plans d\u0026rsquo;exécution SQL Server : Wait Statistics EF Core : Logging et diagnostics OpenTelemetry .NET : instrumentation EF Core ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/database-database-performance-diagnosis/","section":"Posts","summary":"","title":"Diagnostic : comment enquêter et résoudre les problèmes de performance de la base"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/docker/","section":"Tags","summary":"","title":"Docker"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va comprendre la sécurité Docker pour .NET, la couche de contrôles qu\u0026rsquo;on applique aux containers et qui change leur posture réelle en prod.\nUn container n\u0026rsquo;est pas automatiquement sécurisé parce que c\u0026rsquo;est un container. L\u0026rsquo;image Docker par défaut d\u0026rsquo;une application .NET typique, construite sans précaution, tourne en root, embarque tout un userland Linux, expose une large surface d\u0026rsquo;attaque à quiconque peut joindre l\u0026rsquo;interface réseau, et porte des CVE connues depuis la dernière date de publication de son image de base. Ce n\u0026rsquo;est pas un problème hypothétique. C\u0026rsquo;est le baseline dont chaque équipe .NET hérite le jour où elle livre son premier container, et le durcir n\u0026rsquo;est pas optionnel pour tout ce qui touche à de la donnée utilisateur.\nCet article est le zoom sécurité de la série Deployment. Il complète l\u0026rsquo;article Hosting Docker, qui couvrait les patterns runtime, avec les préoccupations spécifiquement sécurité qui s\u0026rsquo;appliquent au build et au déploiement : scan d\u0026rsquo;image, génération de SBOM, signature d\u0026rsquo;image, gestion des secrets, et attestation de supply chain. L\u0026rsquo;objectif n\u0026rsquo;est pas de transformer un dev .NET en ingénieur sécurité. C\u0026rsquo;est de donner à une équipe .NET la poignée de pratiques qui éliminent les risques les plus courants avec le moins de friction.\nLe contexte : pourquoi la sécurité container est différente #La pensée sécurité traditionnelle en .NET se concentre sur l\u0026rsquo;application : OWASP Top 10, authentification, validation des entrées, injection SQL, XSS. Tout cela reste nécessaire. Mais un container ajoute une seconde surface : l\u0026rsquo;image elle-même. Trois choses concrètes peuvent mal tourner au niveau container même dans une application au code parfait :\nL\u0026rsquo;image de base contient une vulnérabilité connue. Une CVE dans glibc, openssl, zlib, ou n\u0026rsquo;importe quelle bibliothèque système est livrée avec chaque image construite par-dessus la base affectée. Si l\u0026rsquo;image de base n\u0026rsquo;a pas été reconstruite récemment, la vulnérabilité voyage jusqu\u0026rsquo;en prod. Le container qui tourne a plus de privilèges qu\u0026rsquo;il n\u0026rsquo;en a besoin. Tourner en root, avoir un accès en écriture sur le système de fichiers racine, monter le socket Docker, et exposer des capabilities de l\u0026rsquo;hôte élargissent tous le rayon d\u0026rsquo;impact de n\u0026rsquo;importe quelle compromission applicative. La supply chain elle-même est compromise. L\u0026rsquo;image tirée du registry peut ne pas être l\u0026rsquo;image que le pipeline de CI a construite, si un attaquant a un accès en écriture au registry ou peut intercepter le pull. Sans signatures ni provenance, il n\u0026rsquo;y a aucun moyen de prouver que l\u0026rsquo;image est authentique. Ces trois risques ont des mitigations dédiées. Le reste de l\u0026rsquo;article les couvre une par une.\nVue d\u0026rsquo;ensemble : la défense en couches # graph TD A[Code source] --\u003e B[SBOM généréau build] B --\u003e C[Image scannéeTrivy ou Scout] C --\u003e D[Image signéecosign] D --\u003e E[Attestation de provenanceSLSA niveau 3] E --\u003e F[Registry] F --\u003e G[Vérification au runtimesignature + policy] G --\u003e H[Container non-rootFS en lecture seuleaucune capability] Le pipeline ajoute une préoccupation sécurité par étape. Aucune ne remplace les autres, et sauter l\u0026rsquo;une d\u0026rsquo;elles laisse une classe de risque précise à découvert. La bonne nouvelle, c\u0026rsquo;est que la plupart peuvent être ajoutées à un pipeline de build existant en une journée, pas en un trimestre.\nZoom : la configuration runtime durcie #Avant de scanner et signer, le container lui-même doit tourner avec le minimum de privilèges. Quatre réglages font l\u0026rsquo;essentiel du travail :\n# securityContext d\u0026#39;un pod Kubernetes (marche aussi sur ACA avec des différences mineures) spec: securityContext: runAsNonRoot: true runAsUser: 64198 fsGroup: 64198 seccompProfile: type: RuntimeDefault containers: - name: api image: myregistry.azurecr.io/shop-api:1.4.7 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: [ALL] volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {} runAsNonRoot: true et runAsUser: 64198 : forcent le container à tourner sous un utilisateur non-root. Les images .NET chiseled utilisent déjà UID 64198 par défaut, mais le déclarer au niveau du pod est une mesure de défense en profondeur qui attrape le cas où quelqu\u0026rsquo;un substitue l\u0026rsquo;image par une qui tourne encore en root.\nallowPrivilegeEscalation: false : empêche le process de gagner plus de privilèges que le parent, même si un binaire setuid est présent. Cela bloque toute une classe d\u0026rsquo;exploits d\u0026rsquo;escalade de privilèges au niveau kernel.\nreadOnlyRootFilesystem: true : monte le système de fichiers racine en lecture seule. Un attaquant qui obtient une exécution de code ne peut pas écrire de web shell, modifier un binaire, ou déposer un payload persistant. ASP.NET Core n\u0026rsquo;a pas besoin d\u0026rsquo;écrire ailleurs que dans /tmp, qui est fourni comme volume emptyDir séparé.\ncapabilities: drop: [ALL] et seccompProfile: RuntimeDefault : suppriment toutes les capabilities Linux (les privilèges fins sous root) et restreignent les appels système que le container peut faire via le filtre seccomp du kernel. ASP.NET Core n\u0026rsquo;a besoin d\u0026rsquo;aucune des capabilities spéciales, donc les supprimer ne coûte rien et ferme une grande surface d\u0026rsquo;attaque.\nEnsemble, ces quatre réglages transforment un container de \u0026ldquo;a un pied sur l\u0026rsquo;hôte si compromis\u0026rdquo; en \u0026ldquo;sandbox très contraint sans chemin d\u0026rsquo;escalade facile\u0026rdquo;. La plupart des applications .NET fonctionnent dessous sans modification.\n✅ Bonne pratique : Mettre ces réglages dans une chart Helm ou une base Kustomize partagée dont chaque service hérite. Les standardiser au niveau plateforme est la seule façon d\u0026rsquo;éviter la dérive sur des dizaines de services.\nZoom : scan d\u0026rsquo;image en CI #Toute image poussée en prod doit être scannée pour les CVE connues avant le déploiement. Les deux outils open source largement adoptés sont Trivy (Aqua Security) et Grype (Anchore). Microsoft fournit aussi Docker Scout, intégré dans Docker Desktop et Docker Hub.\nUne étape CI typique avec Trivy :\n# .github/workflows/deploy.yml - name: Scan image for CVEs uses: aquasecurity/trivy-action@master with: image-ref: myregistry.azurecr.io/shop-api:${{ github.sha }} format: sarif output: trivy-results.sarif severity: HIGH,CRITICAL exit-code: 1 ignore-unfixed: true - name: Upload scan results uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: trivy-results.sarif Trois décisions à rendre explicites.\nseverity: HIGH,CRITICAL : la plupart des images .NET ont des dizaines de CVE LOW et MEDIUM à tout moment, et faire échouer le build sur celles-ci produit du bruit qui entraîne l\u0026rsquo;équipe à ignorer le scanner. Échouer seulement sur HIGH et CRITICAL, trier le reste dans un tracker.\nexit-code: 1 : le scan doit réellement faire échouer le build, pas juste logger des avertissements. Un scanner qui ne bloque pas le déploiement est du théâtre de conformité, pas un contrôle de sécurité.\nignore-unfixed: true : certaines CVE n\u0026rsquo;ont pas encore de fix disponible. Bloquer le pipeline sur des CVE qu\u0026rsquo;on ne peut pas corriger punit l\u0026rsquo;équipe pour quelque chose hors de son contrôle. Les logger, les tracker, les revoir chaque semaine, mais ne pas faire échouer le build.\n💡 Info : Les images .NET chiseled de Microsoft sont reconstruites à chaque mise à jour d\u0026rsquo;image de base, ce qui veut dire que les CVE dans glibc ou des bibliothèques similaires sont patchées plus vite que dans les images complètes basées sur Debian. C\u0026rsquo;est un avantage significatif pour les équipes qui scannent agressivement : une image chiseled a typiquement zéro CVE HIGH ou CRITICAL le jour de sa sortie, tandis que l\u0026rsquo;image complète en a une poignée.\nZoom : les SBOMs et leur utilité #Un Software Bill of Materials (SBOM) est une liste lisible par machine de chaque package et version à l\u0026rsquo;intérieur d\u0026rsquo;une image. Il ne prévient aucune vulnérabilité par lui-même, mais il active trois workflows importants :\nRéponse rétroactive aux CVE. Quand une nouvelle CVE est divulguée (log4shell, xz, spring4shell), un SBOM permet à l\u0026rsquo;équipe de demander \u0026ldquo;laquelle de nos 50 images déployées contient le package affecté\u0026rdquo; en quelques secondes, sans tout re-scanner. Conformité et audit. Les clients, les régulateurs, et les auditeurs SOC 2 demandent de plus en plus des SBOMs comme preuve de ce qu\u0026rsquo;il y a réellement dans un produit livré. Vérification de supply chain. Appairer un SBOM avec une signature crée une attestation qui peut être vérifiée au moment du pull. BuildKit génère les SBOMs nativement :\ndocker buildx build \\ --sbom=true \\ --provenance=true \\ --tag myregistry.azurecr.io/shop-api:1.4.7 \\ --push \\ . Le flag --sbom=true attache un SBOM au manifeste de l\u0026rsquo;image au format SPDX. Le flag --provenance=true attache une attestation de provenance SLSA qui décrit comment l\u0026rsquo;image a été construite : le repo source, le commit, la version du builder, les paramètres de build. Les deux sont stockés comme artefacts OCI à côté de l\u0026rsquo;image, et aucun ne change la façon dont l\u0026rsquo;image tourne.\nZoom : signer les images avec cosign #Une image signée prouve deux choses : qui l\u0026rsquo;a construite, et qu\u0026rsquo;elle n\u0026rsquo;a pas été modifiée depuis. L\u0026rsquo;outil de choix en 2026 est cosign du projet Sigstore, qui supporte à la fois la signature sans clé (via des tokens OIDC de courte durée du provider CI) et la signature traditionnelle par paire de clés.\nSignature sans clé depuis un workflow GitHub Actions :\n- name: Sign the image env: COSIGN_EXPERIMENTAL: \u0026#34;true\u0026#34; run: | cosign sign --yes \\ myregistry.azurecr.io/shop-api@${{ steps.build.outputs.digest }} La signature est stockée dans le registry à côté de l\u0026rsquo;image, en la référençant par son digest de contenu (pas un tag mutable). Au moment du déploiement, une étape de vérification fait échouer le déploiement si la signature ne correspond pas :\n- name: Verify the image signature run: | cosign verify \\ --certificate-identity-regexp \u0026#39;^https://github.com/myorg/shop-api/\u0026#39; \\ --certificate-oidc-issuer https://token.actions.githubusercontent.com \\ myregistry.azurecr.io/shop-api:1.4.7 Cette policy dit : \u0026ldquo;n\u0026rsquo;accepter cette image que si elle a été signée par un workflow GitHub Actions dans le repo shop-api de mon organisation\u0026rdquo;. Un attaquant qui pousse une image modifiée dans le registry ne peut pas produire de signature correspondante sans compromettre aussi l\u0026rsquo;issuer OIDC de GitHub, ce qui est une barre bien plus haute que compromettre le registry seul.\n⚠️ Ça marche, mais\u0026hellip; : Signer sans vérifier est du théâtre de sécurité. L\u0026rsquo;étape de signature en CI n\u0026rsquo;est que la moitié de la valeur ; l\u0026rsquo;étape de vérification au déploiement (dans Kubernetes avec un admission controller comme Kyverno ou OPA Gatekeeper, ou dans ACA avec une policy de validation d\u0026rsquo;image) est ce qui impose réellement la garantie.\nZoom : les secrets, revus #L\u0026rsquo;article Hosting Docker a couvert la règle : ne jamais cuire de secrets dans l\u0026rsquo;image. Cette règle a deux corollaires qui méritent une attention explicite dans un contexte sécurité.\nLes secrets au build doivent passer par --secret, pas par ENV ou ARG. Si un package fetch pendant dotnet restore a besoin d\u0026rsquo;un token d\u0026rsquo;authentification, BuildKit fournit un mécanisme de secret basé sur le mount :\nRUN --mount=type=secret,id=nuget-auth,target=/root/.nuget/NuGet/NuGet.Config \\ dotnet restore docker buildx build \\ --secret id=nuget-auth,src=./nuget-auth.config \\ ... Le secret est monté dans le container de build pendant l\u0026rsquo;étape RUN et n\u0026rsquo;est pas cuit dans un layer. Après l\u0026rsquo;étape, le secret a disparu. Utiliser ENV ou ARG pour la même chose fait fuiter la valeur dans l\u0026rsquo;historique de l\u0026rsquo;image, où quiconque avec un accès pull peut la récupérer.\nLes secrets runtime doivent venir d\u0026rsquo;un store de secrets, pas de variables d\u0026rsquo;environnement. Les variables d\u0026rsquo;environnement sont visibles dans la liste des process, les dumps de crash, et tout outil d\u0026rsquo;introspection de container. Pour tout ce qui est plus sensible qu\u0026rsquo;un feature flag, utiliser des Secrets Kubernetes montés comme fichiers, des références Azure Key Vault, ou un sidecar comme vault-agent qui écrit dans un tmpfs. L\u0026rsquo;application lit depuis le fichier au démarrage et ne garde jamais la valeur dans une variable d\u0026rsquo;environnement.\n❌ Ne jamais faire : Ne pas accepter l\u0026rsquo;argument \u0026ldquo;c\u0026rsquo;est un registry privé, donc c\u0026rsquo;est bon\u0026rdquo;. Les registries privés sont compromis régulièrement via des fuites de credentials, des policies d\u0026rsquo;accès mal configurées, ou des attaques de supply chain sur le registry lui-même. La défense en profondeur suppose que chaque couche peut être compromise.\nZoom : l\u0026rsquo;hygiène de l\u0026rsquo;image de base #La pratique de sécurité à l\u0026rsquo;impact le plus important pour les containers .NET est de rester à jour avec les mises à jour d\u0026rsquo;image de base. Microsoft reconstruit les images de base .NET à chaque mise à jour de sécurité de l\u0026rsquo;OS sous-jacent, et les variantes chiseled sont patchées particulièrement vite parce qu\u0026rsquo;elles ont moins de packages à gérer.\nLe workflow concret :\nPinner sur la version mineure (10.0-noble-chiseled), pas sur une version de patch ou un digest. Ainsi, les rebuilds récupèrent automatiquement la dernière image de base patchée sans bump de tag manuel. Reconstruire l\u0026rsquo;image sur une planification, pas uniquement sur les changements de code. Un run CI planifié hebdomadaire reconstruit l\u0026rsquo;image avec la même source, tire l\u0026rsquo;image de base qui a été patchée entre-temps, et pousse un nouveau tag. Toute image déployée est à une semaine maximum d\u0026rsquo;écart. Surveiller les advisories de sécurité Microsoft pour .NET et s\u0026rsquo;abonner aux advisories d\u0026rsquo;images container. Microsoft publie des mises à jour de sécurité chaque deuxième mardi du mois, et les images de base sont généralement mises à jour dans les 24 heures. # .github/workflows/weekly-rebuild.yml on: schedule: - cron: \u0026#39;0 2 * * 1\u0026#39; # Chaque lundi à 02:00 UTC workflow_dispatch: jobs: rebuild: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Rebuild and push run: | VERSION=$(date +%Y%m%d) docker buildx bake --push ✅ Bonne pratique : Appairer le rebuild hebdomadaire avec un déploiement progressif en pré-prod et un rollout canary en prod, gate par les tests baseline couverts dans l\u0026rsquo;article sur le baseline load testing. Cela transforme l\u0026rsquo;hygiène d\u0026rsquo;image de base de \u0026ldquo;une corvée que personne ne fait\u0026rdquo; en \u0026ldquo;le pipeline le fait automatiquement\u0026rdquo;.\nWrap-up #La sécurité Docker pour .NET en 2026 n\u0026rsquo;est pas une question de perfection, c\u0026rsquo;est la poignée de contrôles qui ferment les plus grosses brèches : un securityContext runtime durci avec non-root, système de fichiers en lecture seule, et capabilities droppées ; un scan d\u0026rsquo;image avec Trivy ou Scout comme étape bloquante en CI ; SBOMs et attestation de provenance via les flags BuildKit ; signature d\u0026rsquo;image avec cosign et vérification au déploiement ; --mount=type=secret BuildKit pour les secrets au build ; secrets runtime depuis un store, jamais depuis des variables d\u0026rsquo;environnement ; et une planification de rebuild hebdomadaire pour garder l\u0026rsquo;image de base à jour. Tu peux ajouter tout cela à un pipeline de déploiement existant en un jour ou deux, et le résultat est une posture container qui bloque les vraies classes d\u0026rsquo;attaque sans transformer la sécurité en job à temps plein.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core avec Docker : un guide pragmatique Héberger ASP.NET Core sur Kubernetes : l\u0026rsquo;essentiel pour les devs .NET Docker pour le déploiement .NET : Dockerfile et Compose en pratique Références # Documentation Trivy Docker Scout, docs Docker Documentation cosign Sigstore Framework SLSA Pod Security Standards Kubernetes Sécurité des containers .NET, Microsoft Learn Secrets de build BuildKit ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/deployment-docker-security/","section":"Posts","summary":"","title":"Docker : bonnes pratiques de sécurité pour .NET"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer le build et le déploiement Docker pour .NET, la moitié de l\u0026rsquo;histoire qui n\u0026rsquo;est pas couverte par l\u0026rsquo;article sur l\u0026rsquo;hébergement : le pipeline lui-même.\nL\u0026rsquo;article de la série Hosting sur Docker a couvert comment faire tourner un container ASP.NET Core correctement au runtime : image de base chiseled, probes de santé, gestion des signaux, utilisateur non-root. Cet article regarde l\u0026rsquo;autre moitié, le pipeline de build et de déploiement lui-même. Un Dockerfile excellent au runtime peut rester terrible en CI s\u0026rsquo;il rebuilde tout depuis zéro à chaque commit, ne produit que du linux/amd64 alors que la moitié des hôtes sont en linux/arm64, ou ne peut pas être composé dans une stack multi-services pour la staging.\nL\u0026rsquo;objectif est concret : un Dockerfile prêt pour la prod qui utilise les BuildKit cache mounts pour transformer un build d\u0026rsquo;image de deux minutes en vingt secondes, une structure multi-stage qui joue bien avec la CI, un setup docker bake qui build des images multi-architectures en une seule commande, et un fichier docker compose réellement utilisable au-delà de docker compose up sur un poste de dev.\nLe contexte : pourquoi le pipeline de build compte #Un déploiement, ce n\u0026rsquo;est pas \u0026ldquo;le moment où le container tourne en prod\u0026rdquo;. C\u0026rsquo;est tout ce qui se passe entre un git push et un replica sain qui sert du trafic, et le Dockerfile est la charnière de ce processus. Trois douleurs concrètes justifient l\u0026rsquo;attention :\nLes minutes de CI sont de l\u0026rsquo;argent réel. Un Dockerfile qui refait le restore NuGet à chaque commit perd 60 à 120 secondes par run. Multiplié par 50 commits par jour, sur plusieurs branches, cela devient une part significative du budget CI qui part en travail redondant. Le multi-architecture n\u0026rsquo;est plus optionnel. Les développeurs sur Apple Silicon en arm64, les providers cloud qui proposent des instances arm64 moins chères (Graviton, Ampere, Azure Cobalt), et les appareils edge ont tous besoin de la même image dans plusieurs architectures. Un Dockerfile qui ne produit que du amd64 commence à paraître daté très vite. Le déploiement est souvent multi-services. Une API backend seule est rarement toute l\u0026rsquo;unité de déploiement. Il y a un worker, un reverse proxy, un scheduler de fond, un frontend. La composition fait partie de l\u0026rsquo;artefact de déploiement, et la traiter comme un détail mène à de la dérive entre les environnements. Vue d\u0026rsquo;ensemble : la forme du pipeline de build # graph LR A[git push] --\u003e B[Runner CI] B --\u003e C[docker buildxBuildKit] C --\u003e D[Layer de cacheregistry ou local] C --\u003e E[Image multi-archamd64 + arm64] E --\u003e F[Registry container] F --\u003e G[Cible de déploiement] Trois outils portent l\u0026rsquo;essentiel du travail dans un déploiement container .NET moderne : BuildKit (le builder Docker moderne, défaut depuis Docker 23), buildx (le frontend CLI pour les builds multi-plateformes), et bake (un orchestrateur de build déclaratif qui remplace les scripts shell ad hoc).\nAucun n\u0026rsquo;est strictement obligatoire, mais ensemble ils transforment un pipeline de déploiement d\u0026rsquo;une séquence fragile d\u0026rsquo;appels docker build et docker push en un build reproductible, cacheable, et multi-cibles sur lequel une équipe peut raisonner.\nZoom : le Dockerfile compatible CI ## syntax=docker/dockerfile:1.9 ARG DOTNET_VERSION=10.0 ARG TARGETARCH # --- Stage de build --- FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build WORKDIR /src # Copie des csproj d\u0026#39;abord pour maximiser les hits de cache sur le restore. COPY [\u0026#34;Shop.Api/Shop.Api.csproj\u0026#34;, \u0026#34;Shop.Api/\u0026#34;] COPY [\u0026#34;Shop.Domain/Shop.Domain.csproj\u0026#34;, \u0026#34;Shop.Domain/\u0026#34;] COPY [\u0026#34;Shop.Application/Shop.Application.csproj\u0026#34;, \u0026#34;Shop.Application/\u0026#34;] COPY [\u0026#34;Shop.Infrastructure/Shop.Infrastructure.csproj\u0026#34;, \u0026#34;Shop.Infrastructure/\u0026#34;] # Cache mount BuildKit pour le dossier global-packages NuGet. # Persiste entre les builds, donc le restore est quasi instantané sur un runner CI chaud. RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \\ dotnet restore \u0026#34;Shop.Api/Shop.Api.csproj\u0026#34; \\ -a $TARGETARCH COPY . . WORKDIR /src/Shop.Api RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \\ dotnet publish \u0026#34;Shop.Api.csproj\u0026#34; \\ --configuration Release \\ --no-restore \\ --arch $TARGETARCH \\ --output /app/publish \\ /p:UseAppHost=false # --- Stage runtime --- FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled AS final WORKDIR /app COPY --from=build /app/publish . EXPOSE 8080 ENV ASPNETCORE_URLS=http://+:8080 \\ ASPNETCORE_ENVIRONMENT=Production \\ DOTNET_RUNNING_IN_CONTAINER=true ENTRYPOINT [\u0026#34;dotnet\u0026#34;, \u0026#34;Shop.Api.dll\u0026#34;] Cinq détails qui diffèrent du Dockerfile vu côté hébergement et qui ciblent spécifiquement le pipeline de build :\n# syntax=docker/dockerfile:1.9 en tête active la dernière frontend Dockerfile, ce qui autorise --mount=type=cache et les fonctionnalités de build plus récentes. Sans cette ligne, les versions plus anciennes de Docker interprètent le fichier avec une syntaxe plus restreinte.\n--mount=type=cache,id=nuget,... est le cache mount BuildKit. Il persiste /root/.nuget/packages entre les builds sur la même instance de builder, pour que le deuxième build (et les suivants) saute entièrement le restore NuGet lent. Un runner CI froid paie encore le coût de téléchargement une fois ; un runner chaud restore en une seconde. L\u0026rsquo;identifiant partagé id=nuget permet aux étapes restore et publish d\u0026rsquo;utiliser le même cache.\n--platform=$BUILDPLATFORM sur le stage de build garde la compilation sur l\u0026rsquo;architecture native de l\u0026rsquo;hôte (rapide) même quand la sortie vise une autre architecture. L\u0026rsquo;alternative, lancer tout le build sous émulation, est 3 à 5 fois plus lente sur amd64 → arm64.\n-a $TARGETARCH sur dotnet restore et --arch $TARGETARCH sur dotnet publish disent au SDK .NET de produire la sortie pour l\u0026rsquo;architecture cible alors que le build lui-même tourne sur l\u0026rsquo;architecture hôte. C\u0026rsquo;est la façon .NET de faire du cross-compile et c\u0026rsquo;est significativement plus rapide que l\u0026rsquo;émulation.\nLe stage final n\u0026rsquo;a pas de --platform explicite, il hérite donc de la plateforme cible depuis le flag docker buildx build --platform. Le résultat est un manifeste multi-arch où le runtime de chaque architecture correspond à sa cible, sans overhead d\u0026rsquo;émulation.\n💡 Info : Les cache mounts BuildKit persistent par instance de builder, pas par image. Sur un runner CI avec un workspace persistant (GitHub Actions avec cache, GitLab CI avec runner partagé), le cache survit entre les jobs. Sur un runner éphémère, utiliser un cache basé sur le registry avec --cache-to type=registry,... pour l\u0026rsquo;externaliser.\nZoom : builds multi-architectures avec buildx #Une seule commande produit une image multi-arch et la pousse :\ndocker buildx build \\ --platform linux/amd64,linux/arm64 \\ --cache-from type=registry,ref=myregistry.azurecr.io/shop-api:cache \\ --cache-to type=registry,ref=myregistry.azurecr.io/shop-api:cache,mode=max \\ --tag myregistry.azurecr.io/shop-api:1.4.7 \\ --push \\ . Le flag --platform linux/amd64,linux/arm64 dit à buildx de builder les deux architectures en parallèle. Les flags --cache-from et --cache-to externalisent le cache BuildKit vers le registry container, ce qui est le pattern qui marche sur des runners CI éphémères. Le flag --push pousse directement le manifeste résultant ; sans lui, on obtient une image multi-arch locale qui ne peut pas être inspectée avec docker images.\nLe registry stocke ensuite un manifest list : un seul tag (1.4.7) qui pointe vers deux images (une amd64, une arm64), et n\u0026rsquo;importe quel runtime qui tire le tag récupère l\u0026rsquo;architecture dont il a réellement besoin. C\u0026rsquo;est transparent pour Kubernetes, ACA, Azure Web App, et tout runtime moderne.\n✅ Bonne pratique : Tagger les images avec à la fois une version et un alias cache dans le même registry. Le tag de version (1.4.7) est immuable et avance à chaque release ; le tag cache n\u0026rsquo;est utilisé que par le builder. Cela garde le cache de build séparé des artefacts de release et simplifie le garbage collection.\nZoom : docker bake pour des builds déclaratifs #Lancer la commande docker buildx build depuis un Makefile ou un YAML CI marche, mais devient vite moche quand un repository a plusieurs images (API, worker, UI admin) avec une configuration de base partagée. docker bake remplace les incantations shell par un fichier HCL :\n# docker-bake.hcl variable \u0026#34;VERSION\u0026#34; { default = \u0026#34;dev\u0026#34; } variable \u0026#34;REGISTRY\u0026#34; { default = \u0026#34;myregistry.azurecr.io\u0026#34; } group \u0026#34;default\u0026#34; { targets = [\u0026#34;api\u0026#34;, \u0026#34;worker\u0026#34;, \u0026#34;admin\u0026#34;] } target \u0026#34;_common\u0026#34; { platforms = [\u0026#34;linux/amd64\u0026#34;, \u0026#34;linux/arm64\u0026#34;] cache-from = [\u0026#34;type=registry,ref=${REGISTRY}/shop-cache:latest\u0026#34;] cache-to = [\u0026#34;type=registry,ref=${REGISTRY}/shop-cache:latest,mode=max\u0026#34;] args = { DOTNET_VERSION = \u0026#34;10.0\u0026#34; } } target \u0026#34;api\u0026#34; { inherits = [\u0026#34;_common\u0026#34;] context = \u0026#34;.\u0026#34; dockerfile = \u0026#34;Shop.Api/Dockerfile\u0026#34; tags = [\u0026#34;${REGISTRY}/shop-api:${VERSION}\u0026#34;] } target \u0026#34;worker\u0026#34; { inherits = [\u0026#34;_common\u0026#34;] context = \u0026#34;.\u0026#34; dockerfile = \u0026#34;Shop.Worker/Dockerfile\u0026#34; tags = [\u0026#34;${REGISTRY}/shop-worker:${VERSION}\u0026#34;] } target \u0026#34;admin\u0026#34; { inherits = [\u0026#34;_common\u0026#34;] context = \u0026#34;.\u0026#34; dockerfile = \u0026#34;Shop.Admin/Dockerfile\u0026#34; tags = [\u0026#34;${REGISTRY}/shop-admin:${VERSION}\u0026#34;] } # Build les trois targets pour les deux architectures, avec cache partagé. VERSION=1.4.7 docker buildx bake --push Une seule commande build les trois images pour les deux architectures, partage le cache entre elles, et pousse tout. Le target _common contient la configuration partagée, et inherits = [\u0026quot;_common\u0026quot;] sur chaque image évite la répétition. Un pipeline de build qui faisait 150 lignes de shell se réduit à 30 lignes de HCL plus une seule invocation.\n⚠️ Ça marche, mais\u0026hellip; : docker bake est puissant mais pas encore universel. Certains providers CI ne l\u0026rsquo;ont pas installé par défaut, et certaines versions de Docker plus anciennes ont besoin d\u0026rsquo;un docker buildx install d\u0026rsquo;abord. Vérifier l\u0026rsquo;environnement CI avant de standardiser sur bake, ou l\u0026rsquo;installer dans une étape de préparation du pipeline.\nZoom : docker compose pour le déploiement multi-services #docker compose est largement utilisé pour le dev local (couvert dans l\u0026rsquo;article hosting), mais c\u0026rsquo;est aussi une cible de déploiement légitime pour des systèmes petits à moyens. Un seul hôte Linux avec Docker Engine, qui fait tourner un fichier Compose, peut servir du vrai trafic de prod pour des outils internes, des environnements de staging, ou de petits produits SaaS.\nLa clé est un fichier Compose conscient de son environnement, pas codé en dur pour \u0026ldquo;mon poste\u0026rdquo; :\n# compose.yaml services: api: image: myregistry.azurecr.io/shop-api:${VERSION:-latest} restart: unless-stopped environment: ASPNETCORE_ENVIRONMENT: Production ConnectionStrings__Default: ${DB_CONNECTION} depends_on: postgres: condition: service_healthy healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;curl\u0026#34;, \u0026#34;--fail\u0026#34;, \u0026#34;http://localhost:8080/health/live\u0026#34;] interval: 10s timeout: 2s retries: 3 deploy: resources: limits: cpus: \u0026#34;0.5\u0026#34; memory: 512M logging: driver: json-file options: max-size: \u0026#34;10m\u0026#34; max-file: \u0026#34;3\u0026#34; postgres: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U ${DB_USER}\u0026#34;] interval: 5s retries: 5 reverse-proxy: image: caddy:2-alpine restart: unless-stopped ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data depends_on: - api volumes: pgdata: caddy_data: # Déploiement VERSION=1.4.7 docker compose up -d # Mise à jour vers une nouvelle version VERSION=1.4.8 docker compose up -d # Compose tire la nouvelle image et recrée uniquement l\u0026#39;api Sept détails font de ce Compose un Compose de déploiement.\n${VERSION:-latest} pour la substitution pilote le tag d\u0026rsquo;image depuis une variable d\u0026rsquo;environnement, ce qui permet d\u0026rsquo;utiliser le même fichier pour plusieurs versions sans l\u0026rsquo;éditer. restart: unless-stopped redémarre automatiquement en cas d\u0026rsquo;échec ou de reboot. healthcheck donne à Docker une façon de savoir quand le container est réellement prêt. deploy.resources.limits plafonne CPU et mémoire. La configuration logging tourne les logs de container pour éviter le remplissage du disque. Les variables d\u0026rsquo;environnement pour les secrets viennent d\u0026rsquo;un fichier env ou du shell, jamais codées en dur. Un reverse proxy (Caddy ici, pourrait être Traefik ou NGINX) gère la terminaison TLS avec des certificats Let\u0026rsquo;s Encrypt automatiques.\nPour des systèmes plus grands qu\u0026rsquo;un seul hôte, Compose n\u0026rsquo;est pas la bonne réponse et les prochains articles de la série (et l\u0026rsquo;article hosting Kubernetes) couvrent le chemin de migration.\n✅ Bonne pratique : Garder les secrets dans un fichier .env gitignoré, et les charger avec docker compose --env-file prod.env up -d. Compose substitue les variables au lancement, et le fichier .env ne rejoint jamais le contrôle de version. Pour des garanties plus fortes, utiliser les Docker secrets (en mode Swarm) ou externaliser vers un store de secrets.\nZoom : compose profiles pour les variantes d\u0026rsquo;environnement #Un seul fichier Compose peut décrire plusieurs variantes d\u0026rsquo;environnement via les profils :\nservices: api: { ... } postgres: { ... } # Démarre uniquement avec --profile debug adminer: image: adminer:latest ports: [\u0026#34;8081:8080\u0026#34;] profiles: [\u0026#34;debug\u0026#34;] # Démarre uniquement avec --profile monitoring prometheus: image: prom/prometheus:latest volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro profiles: [\u0026#34;monitoring\u0026#34;] docker compose up -d # api + postgres seulement docker compose --profile debug up -d # + adminer docker compose --profile monitoring up -d # + prometheus docker compose --profile debug --profile monitoring up -d # tout Les profils permettent à un seul fichier de servir plusieurs environnements : prod classique, prod-avec-observabilité, dev-avec-ui-admin. L\u0026rsquo;alternative de maintenir trois fichiers Compose séparés mène à de la dérive entre eux ; les profils les gardent en phase.\nWrap-up #Construire et déployer des containers .NET proprement en 2026 signifie un Dockerfile qui utilise les cache mounts BuildKit pour garder les builds CI rapides, le flag --platform pour produire des images multi-architectures sans overhead d\u0026rsquo;émulation, docker buildx ou docker bake pour orchestrer des builds multi-images de façon déclarative, et un fichier Compose assez conscient de son environnement pour servir d\u0026rsquo;artefact de déploiement réel sur des systèmes petits à moyens. Tu peux réduire les temps de build CI de moitié juste avec les cache mounts, livrer des images multi-arch en une seule commande, et garder la topologie de déploiement dans un seul fichier versionné lu à la fois par le pipeline et le runtime.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core avec Docker : un guide pragmatique Héberger ASP.NET Core sur Kubernetes : l\u0026rsquo;essentiel pour les devs .NET Tests d\u0026rsquo;Intégration avec TestContainers pour .NET Références # Référence Dockerfile Cache mounts BuildKit, docs Docker Builds multi-plateformes, docs Docker Référence docker bake Spécification Docker Compose Cross-targeting pour la CLI .NET, Microsoft Learn ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/deployment-docker-dockerfile-compose/","section":"Posts","summary":"","title":"Docker pour le déploiement .NET : Dockerfile et Compose en pratique"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer la configuration de modèle EF Core et le seeding des données de référence.\nIl existe deux manières de configurer un modèle EF Core, et celle que la plupart des tutoriels montrent est celle qu\u0026rsquo;on finit par abandonner en premier. Les data annotations sur les propriétés d\u0026rsquo;entités suffisent pour une démo. Les applications réelles finissent avec des types de colonnes qui ne se mappent pas un-pour-un aux types CLR, des owned types, des value converters, des index filtrés, des contraintes check, et des données de référence qui doivent exister en base avant le démarrage de l\u0026rsquo;application. Tout ça vit dans la Fluent API, et depuis EF Core 9 l\u0026rsquo;histoire du seeding a été réécrite.\nCet article montre comment configurer proprement un modèle EF Core non trivial, où ranger cette configuration, et comment seeder les données de référence avec les nouveaux hooks UseSeeding / UseAsyncSeeding.\nLe contexte : pourquoi la configuration compte plus qu\u0026rsquo;il n\u0026rsquo;y paraît #Imaginons que nous ayons un DbContext laissé aux conventions. EF Core construit le modèle au démarrage en reflétant sur le contexte et en appliquant, dans l\u0026rsquo;ordre, les conventions, puis les data annotations, puis les appels Fluent API dans OnModelCreating. Chaque étape peut écraser la précédente. Quand on laisse tout aux conventions, EF Core devine les types de colonnes, la nullabilité, la longueur des strings et les relations à partir de la forme CLR. Ces devinettes sont raisonnables pour une démo, et fausses pour la production dès qu\u0026rsquo;on tient à la précision des decimal, au rowversion SQL Server, au jsonb PostgreSQL ou à un index unique composite.\nLes data annotations ([Required], [MaxLength(200)], [Column(TypeName = \u0026quot;decimal(18,2)\u0026quot;)]) résolvent les cas simples et polluent les classes du domaine avec des préoccupations de persistance. Et elles ne savent pas exprimer la moitié de ce dont on a besoin : pas de value converter, pas d\u0026rsquo;index filtré, pas de clé composite ordonnée, pas de seed. Donc dans tout projet qui dépasse le CRUD sur une table, on déplace la configuration vers la Fluent API, et on la range dans des classes dédiées IEntityTypeConfiguration\u0026lt;T\u0026gt; plutôt que de laisser OnModelCreating devenir un mur de 600 lignes.\nVue d\u0026rsquo;ensemble : où vit la configuration # graph TD A[DbContext] --\u003e B[OnModelCreating] B --\u003e C[ApplyConfigurationsFromAssembly] C --\u003e D[OrderConfiguration : IEntityTypeConfiguration\u0026lt;Order\u0026gt;] C --\u003e E[CustomerConfiguration : IEntityTypeConfiguration\u0026lt;Customer\u0026gt;] C --\u003e F[ProductConfiguration : IEntityTypeConfiguration\u0026lt;Product\u0026gt;] A --\u003e G[UseSeeding / UseAsyncSeeding] G --\u003e H[Données de référence : Pays, Rôles, Devises] Trois idées à garder en tête :\nUne classe de configuration par agrégat racine ou par entité. À ranger à côté de l\u0026rsquo;entité ou dans un dossier Configurations/ dédié. OnModelCreating se réduit à un seul appel modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly). Le seeding ne passe plus par HasData dès que la donnée est un peu dynamique. On utilise UseSeeding pour le chemin synchrone (outillage de migration, EnsureCreated) et UseAsyncSeeding pour le runtime. Zoom : un DbContext réaliste #Prenons un petit backend e-commerce avec des commandes, des clients et des produits.\npublic sealed class ShopDbContext : DbContext { public ShopDbContext(DbContextOptions\u0026lt;ShopDbContext\u0026gt; options) : base(options) { } public DbSet\u0026lt;Customer\u0026gt; Customers =\u0026gt; Set\u0026lt;Customer\u0026gt;(); public DbSet\u0026lt;Order\u0026gt; Orders =\u0026gt; Set\u0026lt;Order\u0026gt;(); public DbSet\u0026lt;Product\u0026gt; Products =\u0026gt; Set\u0026lt;Product\u0026gt;(); public DbSet\u0026lt;Country\u0026gt; Countries =\u0026gt; Set\u0026lt;Country\u0026gt;(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopDbContext).Assembly); } } C\u0026rsquo;est l\u0026rsquo;intégralité de OnModelCreating. Chaque détail de chaque entité part dans son propre fichier.\nZoom : IEntityTypeConfiguration en pratique #public sealed class OrderConfiguration : IEntityTypeConfiguration\u0026lt;Order\u0026gt; { public void Configure(EntityTypeBuilder\u0026lt;Order\u0026gt; builder) { builder.ToTable(\u0026#34;orders\u0026#34;); builder.HasKey(o =\u0026gt; o.Id); builder.Property(o =\u0026gt; o.Id) .ValueGeneratedNever(); builder.Property(o =\u0026gt; o.Reference) .HasMaxLength(32) .IsRequired(); builder.HasIndex(o =\u0026gt; o.Reference).IsUnique(); builder.Property(o =\u0026gt; o.Status) .HasConversion\u0026lt;string\u0026gt;() .HasMaxLength(20); builder.Property(o =\u0026gt; o.TotalAmount) .HasColumnType(\u0026#34;decimal(18,2)\u0026#34;); builder.Property(o =\u0026gt; o.CreatedAt) .HasColumnType(\u0026#34;timestamptz\u0026#34;); builder.OwnsOne(o =\u0026gt; o.ShippingAddress, addr =\u0026gt; { addr.Property(a =\u0026gt; a.Street).HasColumnName(\u0026#34;shipping_street\u0026#34;).HasMaxLength(200); addr.Property(a =\u0026gt; a.City).HasColumnName(\u0026#34;shipping_city\u0026#34;).HasMaxLength(100); addr.Property(a =\u0026gt; a.PostalCode).HasColumnName(\u0026#34;shipping_postal\u0026#34;).HasMaxLength(20); addr.Property(a =\u0026gt; a.CountryCode).HasColumnName(\u0026#34;shipping_country\u0026#34;).HasMaxLength(2); }); builder.HasOne(o =\u0026gt; o.Customer) .WithMany(c =\u0026gt; c.Orders) .HasForeignKey(o =\u0026gt; o.CustomerId) .OnDelete(DeleteBehavior.Restrict); builder.HasMany(o =\u0026gt; o.Lines) .WithOne() .HasForeignKey(\u0026#34;OrderId\u0026#34;) .OnDelete(DeleteBehavior.Cascade); builder.Property\u0026lt;uint\u0026gt;(\u0026#34;RowVersion\u0026#34;).IsRowVersion(); } } Plusieurs choses ici que les data annotations ne savent pas faire : un owned type avec noms de colonnes explicites, un enum persisté en string pour la lisibilité en base, une clé primaire ValueGeneratedNever parce que l\u0026rsquo;ID est généré dans le domaine, un DeleteBehavior explicite par relation, et une propriété shadow RowVersion pour la concurrence optimiste.\n💡 Info — HasConversion\u0026lt;string\u0026gt;() est la manière moderne de persister un enum en texte. Ça survit au réordonnancement de l\u0026rsquo;enum et c\u0026rsquo;est bien plus lisible dans un dump SQL que des codes entiers.\n✅ Bonne pratique — Une entité, un fichier de configuration. Quand la configuration grossit, on étend le fichier, pas OnModelCreating. Le test du \u0026ldquo;c\u0026rsquo;est propre ?\u0026rdquo; : est-ce qu\u0026rsquo;un nouveau développeur peut trouver la définition de la table Order sans un full-text search.\n⚠️ Ça marche, mais\u0026hellip; — Saupoudrer [Column(TypeName = \u0026quot;...\u0026quot;)] sur les propriétés d\u0026rsquo;entités fonctionne, mais ça couple le domaine au provider de base. On garde les annotations pour les règles vraiment universelles ([Required] quand c\u0026rsquo;est un invariant métier) et on pose les types spécifiques au provider dans la Fluent API.\nZoom : value converters pour les types du domaine #Quand le domaine utilise des IDs fortement typés ou des value objects, les value converters sont ce qui garde le mapping honnête.\npublic readonly record struct CustomerId(Guid Value) { public static CustomerId New() =\u0026gt; new(Guid.CreateVersion7()); } public sealed class CustomerConfiguration : IEntityTypeConfiguration\u0026lt;Customer\u0026gt; { public void Configure(EntityTypeBuilder\u0026lt;Customer\u0026gt; builder) { builder.ToTable(\u0026#34;customers\u0026#34;); builder.HasKey(c =\u0026gt; c.Id); builder.Property(c =\u0026gt; c.Id) .HasConversion( id =\u0026gt; id.Value, value =\u0026gt; new CustomerId(value)) .ValueGeneratedNever(); builder.Property(c =\u0026gt; c.Email) .HasMaxLength(256) .IsRequired(); builder.HasIndex(c =\u0026gt; c.Email).IsUnique(); } } CustomerId reste un type de première classe dans le domaine et se mappe quand même sur une simple colonne uuid. EF Core 8 a introduit le support des collections primitives et amélioré les complex types, donc les value objects n\u0026rsquo;obligent plus à aplatir les propriétés à la main.\n💡 Info — Guid.CreateVersion7() est disponible depuis .NET 9 et produit des GUID ordonnés dans le temps, bien plus sympathiques à l\u0026rsquo;indexation qu\u0026rsquo;un GUID v4 aléatoire en clé primaire ou clustered.\nZoom : seeding avec UseSeeding et UseAsyncSeeding #EF Core 9 a introduit UseSeeding et UseAsyncSeeding, et c\u0026rsquo;est enfin un endroit où ranger la logique de seed qui tourne à la fois au design time (quand on exécute dotnet ef database update) et au runtime (quand l\u0026rsquo;application appelle context.Database.MigrateAsync()), sans les limites de HasData.\nHasData stocke les lignes de seed dans les migrations sous forme d\u0026rsquo;INSERT littéraux. Ça marche pour des données de référence vraiment statiques, ça casse dès que la ligne dépend de quelque chose de dynamique (mot de passe haché, clé étrangère calculée, configuration), et chaque modification de ligne produit une migration. Pour tout ce qui dépasse une liste fixe de codes pays, on veut UseSeeding.\nservices.AddDbContext\u0026lt;ShopDbContext\u0026gt;(options =\u0026gt; { options.UseNpgsql(connectionString) .UseSeeding((context, _) =\u0026gt; { var shop = (ShopDbContext)context; if (!shop.Countries.Any()) { shop.Countries.AddRange( new Country { Code = \u0026#34;FR\u0026#34;, Name = \u0026#34;France\u0026#34; }, new Country { Code = \u0026#34;BE\u0026#34;, Name = \u0026#34;Belgique\u0026#34; }, new Country { Code = \u0026#34;CH\u0026#34;, Name = \u0026#34;Suisse\u0026#34; }); shop.SaveChanges(); } }) .UseAsyncSeeding(async (context, _, ct) =\u0026gt; { var shop = (ShopDbContext)context; if (!await shop.Countries.AnyAsync(ct)) { shop.Countries.AddRange( new Country { Code = \u0026#34;FR\u0026#34;, Name = \u0026#34;France\u0026#34; }, new Country { Code = \u0026#34;BE\u0026#34;, Name = \u0026#34;Belgique\u0026#34; }, new Country { Code = \u0026#34;CH\u0026#34;, Name = \u0026#34;Suisse\u0026#34; }); await shop.SaveChangesAsync(ct); } }); }); Les deux hooks tournent quand on appelle context.Database.EnsureCreatedAsync() ou quand l\u0026rsquo;outillage EF amorce la base. Au runtime, la variante async tourne sur le thread pool ; au design time, la variante sync tourne depuis l\u0026rsquo;outillage. EF Core ne les appelle pas tout seul à chaque démarrage d\u0026rsquo;application : on déclenche le seed explicitement, en général juste après MigrateAsync() dans le pipeline de démarrage.\npublic static async Task MigrateAndSeedAsync(this IServiceProvider services) { await using var scope = services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService\u0026lt;ShopDbContext\u0026gt;(); await db.Database.MigrateAsync(); await db.Database.EnsureCreatedAsync(); } ✅ Bonne pratique — Le seed doit être idempotent. Chaque bloc de seed vérifie \u0026ldquo;cette ligne existe-t-elle déjà ?\u0026rdquo; avant d\u0026rsquo;insérer. Redémarrer l\u0026rsquo;application, relancer les migrations en CI ou un pod qui redémarre en Kubernetes, tout ça doit être sûr.\n❌ Ne jamais faire — Ne pas seeder de la donnée utilisateur (clients, commandes, fixtures de test) via UseSeeding. Ce hook est réservé aux données de référence : pays, devises, rôles, permissions, tables de lookup. La donnée de test appartient aux fixtures de test, pas à la configuration du DbContext.\n⚠️ Ça marche, mais\u0026hellip; — Si un code legacy s\u0026rsquo;appuie encore sur HasData, ça continue de fonctionner. Le coût de migration vers UseSeeding reste faible et apporte la possibilité de seeder n\u0026rsquo;importe quelle donnée calculée, y compris des lignes qui dépendent de variables d\u0026rsquo;environnement ou de clés étrangères existantes.\nZoom : les conventions qu\u0026rsquo;on veut souvent écraser #Quelques petits changements au niveau du modèle payent sur tout le contexte :\nprotected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.Properties\u0026lt;string\u0026gt;() .HaveMaxLength(512); configurationBuilder.Properties\u0026lt;decimal\u0026gt;() .HavePrecision(18, 2); configurationBuilder.Properties\u0026lt;DateTime\u0026gt;() .HaveColumnType(\u0026#34;timestamptz\u0026#34;); } ConfigureConventions (EF Core 6+) permet de définir des valeurs par défaut pour toute propriété d\u0026rsquo;un type CLR donné. Ça divise drastiquement le boilerplate par entité et fait disparaître le warning \u0026ldquo;j\u0026rsquo;ai oublié de fixer la précision du decimal\u0026rdquo;.\nWrap-up #Tu sais maintenant comment poser une configuration EF Core qui tient en production : Fluent API plutôt qu\u0026rsquo;annotations, un IEntityTypeConfiguration\u0026lt;T\u0026gt; par entité, des value converters pour les IDs typés et les value objects, et les hooks modernes UseSeeding / UseAsyncSeeding pour les données de référence. Tu peux supprimer le OnModelCreating de 600 lignes et le remplacer par un seul ApplyConfigurationsFromAssembly, et arrêter de gonfler les migrations avec des INSERT générés par HasData.\nPrêt à repasser sur le DbContext de ton prochain projet et à déplacer la configuration là où elle mérite d\u0026rsquo;être, ou à partager la recette avec ton équipe ?\nÀ la prochaine, a++ 👋\nPour aller plus loin # Accès aux données en .NET : Repository Pattern, ou pas ? Couche Application : CQS et CQRS Références # EF Core : Modeling EF Core : IEntityTypeConfiguration EF Core 9 : UseSeeding et UseAsyncSeeding EF Core : Value Conversions EF Core : Configuration de conventions ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/database-efcore-config-seed/","section":"Posts","summary":"","title":"EF Core : Configuration et Seed"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier les migrations EF Core telles qu\u0026rsquo;on les pratique en équipe.\nLes migrations ont l\u0026rsquo;air simples dans la première démo : on change le modèle, on lance dotnet ef migrations add, puis dotnet ef database update, et le schéma suit. Et puis arrivent le deuxième développeur, le premier conflit de pull request sur le snapshot du modèle, la première table de production à 40 millions de lignes, et le premier renommage de colonne qui perd silencieusement la donnée. À ce moment-là, l\u0026rsquo;outil est le même mais le modèle mental doit changer : une migration est un bout de code versionné qui va tourner sur une base vivante, sur une machine qui n\u0026rsquo;est pas la tienne, à un moment que tu ne choisis pas. Traite ça comme du code, relis-le comme du code, et les surprises s\u0026rsquo;arrêtent.\nCet article déroule l\u0026rsquo;écriture d\u0026rsquo;une migration dans une équipe, sa relecture avant production, et les patterns qui gardent le déploiement sans interruption possible.\nLe contexte : pourquoi les migrations demandent leur propre discipline #EF Core génère les migrations en diffant le modèle courant contre un snapshot du modèle précédent. Le snapshot vit dans ModelSnapshot.cs, commité dans le repo à côté des fichiers de migration. Deux développeurs qui ajoutent une migration sur deux branches produisent deux snapshots, et quand les branches fusionnent on obtient un conflit de snapshot qui ne se résout pas par \u0026ldquo;accept both\u0026rdquo;. L\u0026rsquo;outillage impose un rebase et une régénération, ce qui est la première raison pour laquelle les migrations demandent de la coordination.\nLa deuxième raison, c\u0026rsquo;est que le code C# de la migration n\u0026rsquo;est que la moitié de l\u0026rsquo;histoire. EF Core le traduit en SQL au runtime selon le provider. Ce SQL dépend de la version de la base, de la collation, des données existantes, des verrous qu\u0026rsquo;il prend, et de si le déploiement applique la migration hors-ligne ou sur un cluster en production. Une migration qui drop une colonne est instantanée sur une base dev vide et un verrou de table de six minutes sur une table de 40 millions de lignes en prod. Ni EF Core ni la suite de tests ne préviennent : il faut lire le SQL généré.\nVue d\u0026rsquo;ensemble : le cycle de vie d\u0026rsquo;une migration # flowchart LR A[Changer le modèle] --\u003e B[dotnet ef migrations add Name] B --\u003e C[Relire le fichier C# de la migration] C --\u003e D[Relire le SQL générédotnet ef migrations script] D --\u003e E{Safe ?} E --\u003e|Non| F[Découper en plusieurs étapes] F --\u003e B E --\u003e|Oui| G[Commit de la migrationet du snapshot] G --\u003e H[CI applique sur l'env d'intégration] H --\u003e I[Déploiement productionapplique la migration] Deux idées qui font tenir ce flow :\nLe fichier de migration est relu comme du code source. Up et Down sont lus par un humain, pas juste générés et commités les yeux fermés. Le SQL généré est scripté et relu avant le merge. dotnet ef migrations script émet un SQL idempotent qu\u0026rsquo;on attache à la pull request. Zoom : ajouter une migration #dotnet ef migrations add AddCustomerLoyaltyTier \\ --project src/Shop.Infrastructure \\ --startup-project src/Shop.Api \\ --output-dir Persistence/Migrations Trois flags comptent dans un vrai repo. --project pointe vers l\u0026rsquo;assembly qui contient le DbContext. --startup-project pointe vers l\u0026rsquo;exécutable qui le configure, parce qu\u0026rsquo;EF Core doit construire un service provider pour lire la chaîne de connexion et la configuration design-time. --output-dir range les migrations dans un dossier dédié.\nLe fichier généré ressemble à ça :\npublic partial class AddCustomerLoyaltyTier : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn\u0026lt;string\u0026gt;( name: \u0026#34;loyalty_tier\u0026#34;, table: \u0026#34;customers\u0026#34;, type: \u0026#34;varchar(20)\u0026#34;, nullable: false, defaultValue: \u0026#34;standard\u0026#34;); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( name: \u0026#34;loyalty_tier\u0026#34;, table: \u0026#34;customers\u0026#34;); } } On lit les deux méthodes. Up, c\u0026rsquo;est ce qui se passe au déploiement ; Down, c\u0026rsquo;est ce qui se passe au rollback, et un Down qui supprime une colonne qu\u0026rsquo;on vient de remplir avec de la vraie donnée, c\u0026rsquo;est un piège.\n💡 Info — Le fichier de migration est une partial class parce qu\u0026rsquo;EF Core génère un second fichier .Designer.cs à côté, qui capture le snapshot du modèle au moment de la migration. Les deux fichiers appartiennent à git. Ne jamais supprimer l\u0026rsquo;un sans l\u0026rsquo;autre.\nZoom : relire le SQL généré #Le fichier C# est une description. Le SQL est ce qui tourne vraiment. On le génère avant le merge :\ndotnet ef migrations script LastMigration AddCustomerLoyaltyTier \\ --project src/Shop.Infrastructure \\ --startup-project src/Shop.Api \\ --idempotent \\ --output migration.sql --idempotent fait en sorte que le script vérifie __EFMigrationsHistory avant chaque étape, donc on peut le rejouer deux fois. La sortie, c\u0026rsquo;est le SQL exact que la base de production va exécuter. On l\u0026rsquo;attache à la pull request. Le relire prend une minute et attrape ce qu\u0026rsquo;EF Core ne signale pas : un ALTER TABLE avec un default qui verrouille la table, un DROP COLUMN qui perd de la donnée, une nouvelle colonne non-null sur une grosse table qui échoue parce que les lignes existantes n\u0026rsquo;ont pas de valeur.\n✅ Bonne pratique — Imposer \u0026ldquo;le script SQL de la migration est attaché à la PR\u0026rdquo; dans la checklist. C\u0026rsquo;est le filtre à défauts le moins cher qu\u0026rsquo;on ajoutera jamais à un workflow base de données.\nZoom : les opérations qui demandent deux migrations #Tout changement qui ne peut pas s\u0026rsquo;appliquer en une étape atomique se découpe en plusieurs migrations. Les classiques :\nRenommer une colonne. EF Core ne voit pas un renommage, il voit un drop suivi d\u0026rsquo;un add, ce qui perd la donnée. Il faut l\u0026rsquo;écrire comme deux étapes : ajouter la nouvelle colonne, copier la donnée dans un script de déploiement, puis dans une migration ultérieure supprimer l\u0026rsquo;ancienne. Ou alors, utiliser migrationBuilder.RenameColumn à la main :\nprotected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.RenameColumn( name: \u0026#34;customer_email\u0026#34;, table: \u0026#34;customers\u0026#34;, newName: \u0026#34;email\u0026#34;); } EF Core ne génère RenameColumn que quand on renomme la propriété avec exactement le même type CLR. Si on renomme et qu\u0026rsquo;on change le type, on obtient un drop-and-add et on perd la donnée.\nAjouter une colonne non-null à une table peuplée. La migration naïve échoue sur la première ligne existante. La correction, c\u0026rsquo;est une approche en deux temps : l\u0026rsquo;ajouter en nullable avec une valeur par défaut, backfill la donnée, puis dans une migration ultérieure la passer en non-null.\n// Migration 1 migrationBuilder.AddColumn\u0026lt;string\u0026gt;(\u0026#34;loyalty_tier\u0026#34;, \u0026#34;customers\u0026#34;, nullable: true, defaultValue: \u0026#34;standard\u0026#34;); migrationBuilder.Sql(\u0026#34;UPDATE customers SET loyalty_tier = \u0026#39;standard\u0026#39; WHERE loyalty_tier IS NULL;\u0026#34;); // Migration 2 (release ultérieure) migrationBuilder.AlterColumn\u0026lt;string\u0026gt;(\u0026#34;loyalty_tier\u0026#34;, \u0026#34;customers\u0026#34;, nullable: false, type: \u0026#34;varchar(20)\u0026#34;); Séparer une table. Jamais en une seule migration. Ajouter la nouvelle table, faire du dual-write depuis l\u0026rsquo;application pendant une release, backfill la donnée historique, puis dans une release ultérieure supprimer les colonnes de l\u0026rsquo;ancienne table.\n⚠️ Ça marche, mais\u0026hellip; — Une migration unique qui fait \u0026ldquo;ajout de la nouvelle structure, backfill, suppression de l\u0026rsquo;ancienne\u0026rdquo; marche aussi sur un environnement de dev, et le jour où on la déploie en production elle tient un verrou pendant toute la durée du backfill. Sur une grosse table, c\u0026rsquo;est l\u0026rsquo;incident.\n❌ Ne jamais faire — Ne pas éditer une migration qui a déjà été appliquée dans un environnement partagé. L\u0026rsquo;historique des migrations est le contrat. Si on doit corriger quelque chose, on ajoute une nouvelle migration. Éditer l\u0026rsquo;historique fait échouer le dotnet ef database update du développeur suivant avec un checksum mismatch incompréhensible.\nZoom : appliquer les migrations au runtime ou au déploiement #Deux endroits peuvent appliquer les migrations : l\u0026rsquo;application elle-même au démarrage via context.Database.MigrateAsync(), ou une étape dédiée du pipeline de déploiement via dotnet ef database update ou un bundle SQL généré.\nLes migrations au runtime sont les plus simples pour les petites apps. Une instance boot, migre et commence à servir. Le mode d\u0026rsquo;échec, c\u0026rsquo;est le déploiement multi-instances : dix pods appellent MigrateAsync() en même temps, le verrou de migration d\u0026rsquo;EF Core (__EFMigrationsHistory) les sérialise, mais ceux qui perdent la course prennent le temps de démarrage supplémentaire pendant que le gagnant applique une longue migration. Pire, une migration qui échoue à mi-chemin laisse la base dans un état où aucun des pods ne peut démarrer.\nLes migrations au déploiement découplent le changement de schéma du démarrage de l\u0026rsquo;application. Le pipeline applique la migration une fois, puis déroule l\u0026rsquo;application. Le revers, c\u0026rsquo;est que schéma et code doivent être compatibles pour la fenêtre de recouvrement où le nouveau schéma est en place mais certains pods tournent encore sur l\u0026rsquo;ancien code. C\u0026rsquo;est toute la raison d\u0026rsquo;être du pattern des deux migrations : chaque déploiement est rétrocompatible avec la version précédente.\n# Option A : runtime (simple, une seule instance) app.Services.GetRequiredService\u0026lt;ShopDbContext\u0026gt;().Database.Migrate(); # Option B : pipeline de déploiement (recommandé en multi-instance) dotnet ef migrations bundle \\ --project src/Shop.Infrastructure \\ --startup-project src/Shop.Api \\ --self-contained \\ --output efbundle ./efbundle --connection \u0026#34;Host=db;Database=shop;Username=deploy;Password=***\u0026#34; migrations bundle produit un exécutable autonome qui applique les migrations sans nécessiter le SDK .NET sur la machine cible. C\u0026rsquo;est le remplaçant moderne de l\u0026rsquo;envoi d\u0026rsquo;un script SQL, parce qu\u0026rsquo;il gère le suivi __EFMigrationsHistory tout en tournant en dehors du processus de l\u0026rsquo;application.\n💡 Info — Depuis EF Core 7, les migration bundles sont la manière recommandée d\u0026rsquo;appliquer les migrations en CI/CD. Ils fonctionnent avec n\u0026rsquo;importe quel provider et ne demandent pas le SDK .NET sur la cible.\nZoom : le conflit de snapshot #Deux développeurs ajoutent chacun une migration sur leur branche. Les deux commits modifient ModelSnapshot.cs. Quand le second merge, git signale un conflit dans le fichier snapshot, et il n\u0026rsquo;y a pas de manière saine de \u0026ldquo;accept both\u0026rdquo;.\nLa correction, c\u0026rsquo;est un rebase, pas une édition manuelle du merge :\ngit fetch origin git rebase origin/main # conflit dans ModelSnapshot.cs et dans une des migrations dotnet ef migrations remove --project src/Shop.Infrastructure --startup-project src/Shop.Api # les changements de modèle sont toujours là, on reconstruit la migration dotnet ef migrations add YourMigrationName --project src/Shop.Infrastructure --startup-project src/Shop.Api git add . git rebase --continue migrations remove supprime les fichiers de migration et remet le snapshot dans l\u0026rsquo;état précédent. On rajoute ensuite la migration par-dessus la branche fraîchement rebasée, et le snapshot redevient cohérent.\n✅ Bonne pratique — Merger les migrations sur main une pull request à la fois. On sérialise l\u0026rsquo;ordre. Des merges de migrations en parallèle produisent un conflit de snapshot à 100%, et la correction est toujours un rebase pour l\u0026rsquo;un des deux auteurs.\nWrap-up #Tu sais maintenant que les migrations sont du code source, pas de la magie. Tu peux relire le fichier C#, générer et lire le SQL avant le merge, découper les opérations risquées en deux déploiements, et choisir consciemment entre l\u0026rsquo;application au runtime ou depuis le pipeline. Une fois que l\u0026rsquo;équipe s\u0026rsquo;accorde sur ce flow, le bug de migration sort du rapport d\u0026rsquo;incident pour de bon.\nPrêt à imposer la relecture du script SQL sur ta prochaine PR, ou à partager cette discipline avec ton équipe ?\nÀ la prochaine, a++ 👋\nPour aller plus loin # EF Core : Configuration et Seed Accès aux données en .NET : Repository Pattern, ou pas ? Références # EF Core : Gérer les migrations EF Core : Appliquer les migrations EF Core : Migration Bundles EF Core : Opérations de migration personnalisées ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/database-efcore-migrations/","section":"Posts","summary":"","title":"EF Core : les Migrations en pratique"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va découvrir comment rendre les lectures EF Core vraiment rapides, sans sortir de LINQ.\nLa plupart des problèmes de performance EF Core sont des problèmes de lecture, et ils se ressemblent tous : une page qui chargeait en 40 millisecondes avec 20 lignes en dev prend 3 secondes en staging avec 20 000 lignes. Le développeur sort Dapper, migre cinq requêtes, et le reste du code reste sur LINQ-to-EF. La réponse honnête, c\u0026rsquo;est qu\u0026rsquo;EF Core est assez rapide pour 95% des lectures depuis la version 6, à condition de savoir quels leviers actionner. Cet article parle de ces leviers : change tracking, projections, split queries, compiled queries, et les deux pièges (N+1 et explosion cartésienne) qui causent la majorité des tickets \u0026ldquo;requête lente\u0026rdquo;.\nLe contexte : pourquoi les lectures EF Core peuvent être lentes par défaut #Le mode par défaut d\u0026rsquo;EF Core est pensé pour l\u0026rsquo;écriture : chaque entité chargée entre dans le change tracker, chaque navigation peut être lazy-loadée, chaque requête hydrate des entités complètes. Ce défaut est correct pour un command handler qui charge un agrégat, le modifie et sauvegarde. Il est dispendieux pour les requêtes qui ne font que lire. Le change tracker prend de la mémoire et du CPU pour snapshotter chaque entité chargée. L\u0026rsquo;hydratation construit un graphe d\u0026rsquo;objets complet alors qu\u0026rsquo;on n\u0026rsquo;avait besoin que de quatre colonnes. Les navigations incluses joignent des tables dont on n\u0026rsquo;a pas besoin pour cette page précise. Et quand un seul .Include() traverse deux navigations de type collection, le result set explose en produit cartésien lent à transférer et lent à matérialiser.\nCorriger une lecture lente, c\u0026rsquo;est décider, par requête, lesquels de ces défauts on désactive. La forme du LINQ reste la même ; on ajoute un ou deux appels de méthode.\nVue d\u0026rsquo;ensemble : la boîte à outils de l\u0026rsquo;optimisation de lecture # graph TD A[Requête lente] --\u003e B{Diagnostic} B --\u003e C[Pattern N+1] B --\u003e D[Explosion cartésienne] B --\u003e E[Coût du tracking] B --\u003e F[Sur-lecture de colonnes] B --\u003e G[Coût de compilation de la requête] C --\u003e H[.Include ou projection] D --\u003e I[AsSplitQuery] E --\u003e J[AsNoTracking] F --\u003e K[Select projection vers DTO] G --\u003e L[EF.CompileAsyncQuery] Cinq leviers, cinq problèmes distincts. On ne les applique pas tous à la fois : on diagnostique d\u0026rsquo;abord, puis on prend celui qui correspond.\nZoom : AsNoTracking, le gain le moins cher #Toute requête qui ne modifie pas le résultat devrait sauter le change tracker :\nvar customers = await _db.Customers .AsNoTracking() .Where(c =\u0026gt; c.LoyaltyTier == \u0026#34;gold\u0026#34;) .ToListAsync(ct); AsNoTracking() dit à EF Core de ne pas snapshotter les entités chargées, ce qui économise de la mémoire et du CPU proportionnels à la taille du résultat. Pour un endpoint de liste en lecture seule, c\u0026rsquo;est en général un gain de 20 à 40% côté .NET sans changer le SQL. Si on a beaucoup de requêtes de ce genre, on le passe par défaut au niveau du contexte :\nservices.AddDbContext\u0026lt;ShopDbContext\u0026gt;(options =\u0026gt; { options.UseNpgsql(conn) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); }); Les commandes qui doivent modifier s\u0026rsquo;y opposent alors explicitement avec .AsTracking(). Ça inverse le défaut en faveur des lectures, ce qui est le bon pari pour la plupart des applications web où les lectures dépassent les écritures dans un ratio de 10 pour 1.\n💡 Info — AsNoTrackingWithIdentityResolution() est l\u0026rsquo;intermédiaire. Il saute le change tracking mais déduplique quand même les références à l\u0026rsquo;intérieur du résultat, ce qui compte quand on charge un graphe avec des parents partagés. À utiliser quand un simple AsNoTracking() rend des lignes enfants dupliquées.\nZoom : les projections, le plus gros gain #La requête la plus rapide est celle qui ne demande que ce que l\u0026rsquo;endpoint retourne vraiment. Si la réponse API a 5 champs, on lit 5 colonnes, pas les 30 colonnes de l\u0026rsquo;entité.\npublic sealed record CustomerListItem(Guid Id, string Email, string LoyaltyTier); var customers = await _db.Customers .Where(c =\u0026gt; c.LoyaltyTier == \u0026#34;gold\u0026#34;) .Select(c =\u0026gt; new CustomerListItem(c.Id, c.Email, c.LoyaltyTier)) .ToListAsync(ct); Trois choses se passent automatiquement quand on projette :\nEF Core génère un SELECT avec uniquement les colonnes projetées. Pas de jointures inutiles, pas de colonnes BLOB trimballées sur le réseau. Le type du résultat n\u0026rsquo;est pas une entité, donc il n\u0026rsquo;y a rien à tracker. AsNoTracking() devient redondant sur une requête projetée. EF Core sait traduire une projection à travers une navigation sans avoir besoin d\u0026rsquo;un .Include(). Ce troisième point, c\u0026rsquo;est celui que la plupart des développeurs ratent. Pas besoin de .Include(c =\u0026gt; c.Orders) si on veut juste la date du dernier order :\nvar customers = await _db.Customers .Select(c =\u0026gt; new { c.Id, c.Email, LastOrderDate = c.Orders.Max(o =\u0026gt; (DateTime?)o.CreatedAt) }) .ToListAsync(ct); EF Core traduit ça en LEFT JOIN LATERAL (ou une sous-requête équivalente selon le provider) et renvoie une ligne par client. Pas de N+1, pas d\u0026rsquo;aller-retour supplémentaire.\n✅ Bonne pratique — Pour toute requête qui alimente une vue liste, on projette vers un DTO. .Include() est fait pour les commandes qui chargent un agrégat, pas pour les lectures qui alimentent une UI.\nZoom : l\u0026rsquo;explosion cartésienne #var orders = await _db.Orders .Include(o =\u0026gt; o.Lines) .Include(o =\u0026gt; o.Payments) .Where(o =\u0026gt; o.CreatedAt \u0026gt; since) .ToListAsync(ct); Ça a l\u0026rsquo;air innocent et c\u0026rsquo;est un piège classique. EF Core génère un seul SQL avec deux LEFT JOIN. Un order avec 5 lignes et 2 paiements produit 10 lignes dans le result set, une par combinaison. Passe à 1 000 orders et on transfère des dizaines de milliers de lignes sur le réseau pour hydrater ce que le domaine voit comme 1 000 objets. EF Core prévient au démarrage dans les logs quand il détecte ça, et la correction tient en un appel :\nvar orders = await _db.Orders .AsSplitQuery() .Include(o =\u0026gt; o.Lines) .Include(o =\u0026gt; o.Payments) .Where(o =\u0026gt; o.CreatedAt \u0026gt; since) .ToListAsync(ct); AsSplitQuery() fait émettre à EF Core trois SELECT distincts (un pour orders, un pour lines, un pour payments) et les recolle en mémoire. On paie trois aller-retours au lieu d\u0026rsquo;un, mais on transfère N + L + P lignes au lieu de N * L * P. Pour toute collection qui a plus que quelques enfants, les split queries gagnent.\nOn peut aussi régler le défaut au niveau du contexte :\noptions.UseNpgsql(conn, npgsql =\u0026gt; npgsql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); ⚠️ Ça marche, mais\u0026hellip; — Le mode single-query convient aux navigations 1:1 et aux petites collections 1:N. Dès qu\u0026rsquo;on chaîne deux .Include() sur des collections, on est en territoire d\u0026rsquo;explosion cartésienne. En cas de doute, on bascule en split query.\nZoom : le piège du N+1 #Le N+1 classique vient de l\u0026rsquo;itération sur des entités chargées avec accès à une navigation sur chacune :\nvar orders = await _db.Orders.Where(o =\u0026gt; o.CreatedAt \u0026gt; since).ToListAsync(ct); foreach (var order in orders) { var customer = order.Customer; // lazy load, un SQL par order Console.WriteLine($\u0026#34;{order.Id} - {customer.Email}\u0026#34;); } Le lazy loading tire une requête par itération. La correction, c\u0026rsquo;est soit un .Include(o =\u0026gt; o.Customer) explicite, soit, mieux, une projection qui ramène directement l\u0026rsquo;email du client :\nvar orderSummaries = await _db.Orders .Where(o =\u0026gt; o.CreatedAt \u0026gt; since) .Select(o =\u0026gt; new { o.Id, CustomerEmail = o.Customer.Email }) .ToListAsync(ct); ❌ Ne jamais faire — Ne pas activer le lazy loading global sur une API CRUD. Ça transforme chaque .Include() oublié en N+1 silencieux qui ne sort qu\u0026rsquo;en production. EF Core livre le lazy loading désactivé par défaut précisément pour ça. Si on en a besoin, on l\u0026rsquo;active par contexte, et on audite chaque requête qui touche une navigation.\nZoom : compiled queries pour les hot paths #EF Core parse et traduit l\u0026rsquo;expression tree LINQ à chaque appel. Pour la plupart des requêtes, le coût est négligeable face à l\u0026rsquo;aller-retour base, mais pour un hot path appelé des milliers de fois par seconde, la traduction commence à apparaître dans le profiler. Les compiled queries mettent la traduction en cache :\nprivate static readonly Func\u0026lt;ShopDbContext, Guid, CancellationToken, Task\u0026lt;Customer?\u0026gt;\u0026gt; GetCustomerById = EF.CompileAsyncQuery((ShopDbContext db, Guid id, CancellationToken ct) =\u0026gt; db.Customers.AsNoTracking().FirstOrDefault(c =\u0026gt; c.Id == id)); public Task\u0026lt;Customer?\u0026gt; FindAsync(Guid id, CancellationToken ct) =\u0026gt; GetCustomerById(_db, id, ct); EF.CompileAsyncQuery prend l\u0026rsquo;expression LINQ, la traduit une fois, et renvoie un delegate qui réutilise le plan compilé. Sur une requête appelée 10 000 fois, ça économise typiquement 20 à 30% de latence côté .NET. À utiliser chirurgicalement : les 5 requêtes en tête des télémétries, pas chaque méthode de repository.\n💡 Info — Depuis EF Core 6, les résultats de compilation de requête sont aussi mis en cache en interne par forme distincte d\u0026rsquo;expression LINQ. EF.CompileAsyncQuery gagne encore sur le débit brut, mais l\u0026rsquo;écart est plus étroit qu\u0026rsquo;avant.\nZoom : lire le SQL généré #Toute décision d\u0026rsquo;optimisation dépend de la lecture du SQL réel. En dev, on active le logger et on logge le SQL au niveau debug :\noptions.UseNpgsql(conn) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging(); En production, on garde la catégorie EF Core en Warning pour attraper les avertissements d\u0026rsquo;explosion cartésienne et de multi-collection, mais on ne logge pas chaque statement. À la place, on s\u0026rsquo;appuie sur l\u0026rsquo;instrumentation OpenTelemetry, qui émet chaque requête base comme un span avec le SQL attaché.\nservices.AddOpenTelemetry() .WithTracing(t =\u0026gt; t.AddEntityFrameworkCoreInstrumentation(o =\u0026gt; o.SetDbStatementForText = true)); Ça donne une timeline de chaque requête qu\u0026rsquo;une request exécute, et c\u0026rsquo;est en général assez pour repérer celle qui prend 400 ms quand les 19 autres sont à 2 ms.\nWrap-up #Tu sais maintenant manier les cinq leviers qui couvrent la majorité des problèmes de performance en lecture. AsNoTracking pour chaque requête en lecture seule. Projection vers un DTO pour tout endpoint de liste. AsSplitQuery dès qu\u0026rsquo;on chaîne des .Include() sur des collections. Chasser les N+1 de lazy loading en lisant le SQL généré, pas en inspectant le C#. Et compiler les trois ou quatre requêtes qui sortent vraiment dans le hot path. Une fois tout ça en place, les 5% de requêtes encore lentes sont celles qui ont vraiment besoin de SQL brut, sujet du prochain article.\nPrêt à repasser sur le repository le plus chaud de ton projet et à appliquer ces leviers, ou à partager cette grille d\u0026rsquo;analyse avec ton équipe ?\nÀ la prochaine, a++ 👋\nPour aller plus loin # EF Core : Configuration et Seed EF Core : les Migrations en pratique Références # EF Core : Performance EF Core : Efficient Querying EF Core : Split Queries EF Core : Compiled Queries EF Core : Tracking vs No-Tracking ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/database-efcore-read-optimization/","section":"Posts","summary":"","title":"EF Core : Optimisation des Lectures"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/efcore/","section":"Tags","summary":"","title":"Efcore"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/endpoints/","section":"Tags","summary":"","title":"Endpoints"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier le choix entre Controllers et Minimal APIs en ASP.NET Core.\nTout projet ASP.NET Core commence par la même décision. Tu scaffoldes un dossier Controllers/ avec des classes [ApiController], ou tu déclares les endpoints dans Program.cs avec app.MapPost(...) et une lambda ? Les deux styles font le même boulot, vivent dans le même framework, et sont supportés à long terme. Les différences apparaissent dans la quantité de cérémonie qu\u0026rsquo;ils imposent, dans la façon dont les filtres et le middleware se composent, et dans la manière dont ça tient la route quand l\u0026rsquo;équipe grossit et que la base de code mûrit.\nCet article compare les deux styles tête à tête, avec du code réaliste, et explique quand chacun mérite sa place.\nLe contexte : pourquoi ce choix existe #Les controllers sont dans ASP.NET depuis MVC 1 en 2009. Les controllers Web API sont arrivés en 2012, et les deux ont été unifiés sous le modèle unique d\u0026rsquo;ASP.NET Core en 2016. Pendant une décennie, quand on écrivait un endpoint HTTP en .NET, on écrivait un controller. Le model binding, les filtres, les attributs pour la métadonnée, les conventions : tout est profondément enraciné.\nLes Minimal APIs sont arrivées avec .NET 6 en novembre 2021, en réponse à une observation très précise : pour un petit service ou un endpoint simple, la cérémonie d\u0026rsquo;une classe controller, d\u0026rsquo;une classe de base, d\u0026rsquo;attributs de routage et d\u0026rsquo;une méthode d\u0026rsquo;action représente beaucoup de frappes pour une ligne de travail utile. L\u0026rsquo;équipe menée par Damian Edwards et David Fowler est partie de la question \u0026ldquo;quel est le minimum absolu de code pour mapper une URL à un handler ?\u0026rdquo; et a construit à partir de là.\nPendant la première année, il manquait des morceaux visibles aux Minimal APIs : pas d\u0026rsquo;endpoint filters, pas de typed results, OpenAPI faible, pas de raccourci [FromServices]. .NET 7 et .NET 8 ont comblé la plupart de ces trous. Avec .NET 9, l\u0026rsquo;écart est suffisamment fin pour que le choix soit vraiment une décision de style et d\u0026rsquo;architecture, pas une question de capacités.\nVue d\u0026rsquo;ensemble : les deux modèles mentaux #Avant le code, voici comment chaque style pose le même endpoint dans ta tête.\ngraph LR subgraph Controllers direction TB C1[Program.csAddControllers] --\u003e C2[OrdersController] C2 --\u003e C3[Méthode d'action] C3 --\u003e C4[Pipeline de filtres] C4 --\u003e C5[Logique du handler] end subgraph Minimal[\"Minimal API\"] direction TB M1[Program.cs] --\u003e M2[Lambda MapPost] M2 --\u003e M3[Endpoint filters] M3 --\u003e M4[Logique du handler] end Même requête, même réponse, même middleware, même injection de dépendances. La différence, c\u0026rsquo;est où l\u0026rsquo;endpoint est déclaré et comment la métadonnée lui est attachée. Les controllers s\u0026rsquo;appuient sur les attributs et les conventions. Les Minimal APIs s\u0026rsquo;appuient sur le chaînage fluent.\nZoom : le même endpoint, écrit des deux façons #Implémentons un endpoint classique, POST /orders, dans les deux styles, avec validation, autorisation, typed results et métadonnée OpenAPI. C\u0026rsquo;est ce qu\u0026rsquo;on livrerait vraiment en production.\nVersion Controller #// Controllers/OrdersController.cs [ApiController] [Route(\u0026#34;orders\u0026#34;)] [Authorize] public sealed class OrdersController : ControllerBase { private readonly ISender _mediator; public OrdersController(ISender mediator) =\u0026gt; _mediator = mediator; [HttpPost] [ProducesResponseType(typeof(CreateOrderResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task\u0026lt;ActionResult\u0026lt;CreateOrderResponse\u0026gt;\u0026gt; Create( [FromBody] CreateOrderCommand command, CancellationToken ct) { var response = await _mediator.Send(command, ct); return CreatedAtAction(nameof(GetById), new { id = response.OrderId }, response); } [HttpGet(\u0026#34;{id:guid}\u0026#34;)] [ProducesResponseType(typeof(OrderDetailsResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task\u0026lt;ActionResult\u0026lt;OrderDetailsResponse\u0026gt;\u0026gt; GetById( Guid id, CancellationToken ct) { var response = await _mediator.Send(new GetOrderDetailsQuery(id), ct); return Ok(response); } } Câblé dans Program.cs en deux lignes :\nbuilder.Services.AddControllers(); // ... app.MapControllers(); Version Minimal API #// Features/Orders/OrdersEndpoints.cs public static class OrdersEndpoints { public static IEndpointRouteBuilder MapOrders(this IEndpointRouteBuilder app) { var group = app.MapGroup(\u0026#34;/orders\u0026#34;) .WithTags(\u0026#34;Orders\u0026#34;) .RequireAuthorization(); group.MapPost(\u0026#34;/\u0026#34;, async ( CreateOrderCommand command, ISender mediator, CancellationToken ct) =\u0026gt; { var response = await mediator.Send(command, ct); return TypedResults.Created($\u0026#34;/orders/{response.OrderId}\u0026#34;, response); }) .WithName(\u0026#34;CreateOrder\u0026#34;) .Produces\u0026lt;CreateOrderResponse\u0026gt;(StatusCodes.Status201Created) .ProducesValidationProblem(); group.MapGet(\u0026#34;/{id:guid}\u0026#34;, async Task\u0026lt;Results\u0026lt;Ok\u0026lt;OrderDetailsResponse\u0026gt;, NotFound\u0026gt;\u0026gt; ( Guid id, ISender mediator, CancellationToken ct) =\u0026gt; { var response = await mediator.Send(new GetOrderDetailsQuery(id), ct); return TypedResults.Ok(response); }) .WithName(\u0026#34;GetOrderById\u0026#34;); return app; } } Câblé dans Program.cs en une ligne :\napp.MapOrders(); Deux styles, même comportement, même table de routage, même document OpenAPI. La version controller est plus déclarative (les attributs parlent), la version Minimal est plus explicite (la chaîne te dit exactement ce qui s\u0026rsquo;applique à quoi).\n💡 Info : TypedResults (la variante typée de Results) est le type de retour recommandé en Minimal APIs depuis .NET 7. Il permet d\u0026rsquo;exprimer la réponse dans la signature, ce qui améliore à la fois la testabilité et donne au générateur OpenAPI assez d\u0026rsquo;information pour décrire chaque branche sans attributs supplémentaires.\nZoom : les filtres, le moment où les deux styles divergent #Les préoccupations transverses (logging, validation, résolution de tenant, idempotence) sont l\u0026rsquo;endroit où les deux modèles se sentent le plus différents.\nLes controllers utilisent les action filters, qui existent depuis MVC 1 et forment un pipeline riche : authorization filters, resource filters, action filters, exception filters, result filters. Ils peuvent court-circuiter le pipeline, modifier les arguments de l\u0026rsquo;action, emballer le résultat. Ils sont puissants et bien compris.\npublic sealed class IdempotencyFilter : IAsyncActionFilter { private readonly IIdempotencyStore _store; public IdempotencyFilter(IIdempotencyStore store) =\u0026gt; _store = store; public async Task OnActionExecutionAsync( ActionExecutingContext context, ActionExecutionDelegate next) { var key = context.HttpContext.Request.Headers[\u0026#34;Idempotency-Key\u0026#34;].ToString(); if (string.IsNullOrWhiteSpace(key)) { context.Result = new BadRequestObjectResult(\u0026#34;Header Idempotency-Key requis.\u0026#34;); return; } if (await _store.HasSeenAsync(key)) { context.Result = new StatusCodeResult(StatusCodes.Status409Conflict); return; } await next(); await _store.MarkAsync(key); } } // Utilisation [ServiceFilter(typeof(IdempotencyFilter))] [HttpPost] public async Task\u0026lt;ActionResult\u0026lt;CreateOrderResponse\u0026gt;\u0026gt; Create(...) { } Les Minimal APIs utilisent les endpoint filters, introduits en .NET 7. Ils sont plus simples (une interface, un delegate), se composent par chaînage de .AddEndpointFilter(...), et s\u0026rsquo;exécutent après le model binding mais avant le handler.\npublic sealed class IdempotencyFilter : IEndpointFilter { private readonly IIdempotencyStore _store; public IdempotencyFilter(IIdempotencyStore store) =\u0026gt; _store = store; public async ValueTask\u0026lt;object?\u0026gt; InvokeAsync( EndpointFilterInvocationContext ctx, EndpointFilterDelegate next) { var key = ctx.HttpContext.Request.Headers[\u0026#34;Idempotency-Key\u0026#34;].ToString(); if (string.IsNullOrWhiteSpace(key)) return TypedResults.BadRequest(\u0026#34;Header Idempotency-Key requis.\u0026#34;); if (await _store.HasSeenAsync(key)) return TypedResults.Conflict(); var result = await next(ctx); await _store.MarkAsync(key); return result; } } // Utilisation group.MapPost(\u0026#34;/\u0026#34;, Handler) .AddEndpointFilter\u0026lt;IdempotencyFilter\u0026gt;(); L\u0026rsquo;endpoint filter est plus petit, mais il fait aussi moins de choses. Il n\u0026rsquo;y a pas de séparation entre les étapes d\u0026rsquo;autorisation, de ressource, d\u0026rsquo;action et d\u0026rsquo;exception. Pour une grosse application avec des dizaines de règles transverses à des étapes différentes, le pipeline de controllers plus riche peut être un avantage. Pour la plupart des applications, une poignée d\u0026rsquo;endpoint filters est plus simple à comprendre.\n✅ Bonne pratique : En Minimal APIs, attache les filtres au niveau du groupe de route, pas de l\u0026rsquo;endpoint individuel. Un seul .AddEndpointFilter\u0026lt;IdempotencyFilter\u0026gt;() sur le groupe /orders l\u0026rsquo;applique à chaque endpoint de commande, ce qui est à la fois moins bruyant et moins sujet aux oublis que de l\u0026rsquo;ajouter à chaque MapPost.\nZoom : model binding et validation #Les controllers ont une longue histoire avec le model binding. [FromBody], [FromQuery], [FromRoute], [FromForm], [FromHeader], plus le binding implicite basé sur le type et la source. Combiné avec [ApiController], on a les 400 automatiques sur les modèles invalides et le formatage ProblemDetails automatique.\nLes Minimal APIs bindent par convention : un paramètre de type complexe est lu depuis le body (sauf s\u0026rsquo;il est décoré de [FromServices]), les primitifs sont lus depuis la route ou la query, IFormFile vient du form, les services sont résolus depuis l\u0026rsquo;injection de dépendances automatiquement. On peut toujours utiliser les attributs [From...] quand la convention est ambiguë.\nLa validation est l\u0026rsquo;endroit où les deux styles laissent un trou. Aucun n\u0026rsquo;intègre FluentValidation par défaut. La réponse canonique dans les deux mondes aujourd\u0026rsquo;hui est de pousser la validation dans un pipeline behavior MediatR, ou dans un endpoint filter pour les Minimal APIs. Comme ça le validator vit avec la commande, et la définition de l\u0026rsquo;endpoint reste propre.\n⚠️ Ça marche, mais\u0026hellip; : Les data annotations ([Required], [StringLength]) fonctionnent techniquement en Minimal APIs via un ValidationFilter ou un middleware équivalent, mais elles laissent fuir les règles de validation dans les records de requête et ne se composent pas bien avec les règles conditionnelles. Dès qu\u0026rsquo;on dépasse la validation jouet, utilise FluentValidation dans un filtre.\nZoom : tests, OpenAPI et AOT #Pour les tests unitaires, les controllers ont un léger avantage : une méthode d\u0026rsquo;action est juste une méthode sur une classe. On instancie le controller, on appelle la méthode, on assert sur l\u0026rsquo;ActionResult. Les handlers de Minimal APIs sont en général des lambdas à l\u0026rsquo;intérieur de MapPost, ce qui les rend plus difficiles à tester directement. La solution, c\u0026rsquo;est d\u0026rsquo;extraire le handler dans une méthode statique nommée ou dans un handler MediatR, ce qui est une bonne pratique dans les deux cas.\nPour les tests d\u0026rsquo;intégration, les deux styles fonctionnent de façon identique avec WebApplicationFactory. Ce qu\u0026rsquo;on teste, c\u0026rsquo;est la surface HTTP, et elle ne se soucie pas de la façon dont les endpoints ont été déclarés.\nPour OpenAPI, .NET 9 a introduit le nouveau package Microsoft.AspNetCore.OpenApi qui remplace Swashbuckle par défaut. Il fonctionne aussi bien avec les deux modèles. Les Minimal APIs récupèrent gratuitement une métadonnée un peu plus riche quand on utilise TypedResults, parce que le type de résultat fait partie de la signature.\nPour la compilation AOT (Ahead of Time), les Minimal APIs sont le chemin recommandé. L\u0026rsquo;équipe ASP.NET Core a beaucoup investi pour rendre RequestDelegateFactory généré par source et trim-safe. Les controllers s\u0026rsquo;appuient sur plus de réflexion au démarrage et ont plus de mal à devenir AOT-friendly.\n💡 Info : Pour le détail de pourquoi l\u0026rsquo;AOT compte et ce qu\u0026rsquo;il apporte en .NET, voir Compilation AOT en .NET.\nLa matrice de décision honnête #Aucun style n\u0026rsquo;est objectivement meilleur. Voilà comment on choisit en pratique :\nChoisir Minimal APIs quand :\nLe projet est un petit ou moyen service, un microservice, une charge de type fonction. On veut la compilation AOT, des démarrages à froid rapides, une image conteneur légère. On utilise un code organisé par fonctionnalité (chaque feature mappe ses propres endpoints) et on veut que la déclaration de l\u0026rsquo;endpoint vive à côté du handler. L\u0026rsquo;équipe préfère le câblage explicite au câblage par convention. Choisir Controllers quand :\nL\u0026rsquo;application a des dizaines d\u0026rsquo;endpoints qui partagent des pipelines de filtres riches et des attributs de métadonnée. L\u0026rsquo;équipe a déjà des conventions MVC profondes (model binding, mapping vers des view-models, ordonnancement complexe des filtres). On a besoin de toute la taxonomie des action filters (autorisation, ressource, action, résultat, exception) avec le court-circuitage entre les étapes. On dépend d\u0026rsquo;un outil ou d\u0026rsquo;une bibliothèque qui attend toujours ControllerBase (certains vieux analyzers, les vieux scaffolders, quelques extensions tierces). Choisir \u0026ldquo;les deux dans le même projet\u0026rdquo; quand :\nUne zone neuve livre en Minimal APIs, une zone legacy garde ses controllers. Ils cohabitent sans souci. AddControllers() et MapControllers() n\u0026rsquo;interfèrent pas avec MapPost(...). La seule règle, c\u0026rsquo;est d\u0026rsquo;être cohérent à l\u0026rsquo;intérieur d\u0026rsquo;une fonctionnalité, pas à l\u0026rsquo;échelle de la solution entière. ❌ Ne jamais faire : Ne copie-colle pas des controllers entiers dans des lambdas Minimal API, ou l\u0026rsquo;inverse, comme \u0026ldquo;migration\u0026rdquo;. Un changement de style n\u0026rsquo;est pas une fonctionnalité, il ajoute du risque sans aucune valeur visible pour l\u0026rsquo;utilisateur. Migre un module quand tu le touches déjà pour une autre raison, et laisse le reste tranquille. La cohérence à l\u0026rsquo;intérieur d\u0026rsquo;un module compte plus que la cohérence sur toute la base de code.\nWrap-up #Tu sais maintenant comment Controllers et Minimal APIs se comparent vraiment dans l\u0026rsquo;ASP.NET Core moderne : les controllers offrent un pipeline de filtres riche, des conventions profondes et une décennie d\u0026rsquo;outillage ; les Minimal APIs offrent moins de cérémonie, un support AOT de premier plan, des typed results, et des endpoint filters plus simples à composer. Tu peux écrire le même endpoint dans les deux styles et livrer la même surface HTTP, et tu peux mixer les deux dans un même projet quand ça a du sens. Choisis celui qui colle à la forme de ta base de code, pas celui qui est le plus récent ou le plus ancien.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nRéférences # Vue d\u0026rsquo;ensemble des Minimal APIs, Microsoft Learn Choisir entre controllers et Minimal APIs, Microsoft Learn Filtres dans les Minimal APIs, Microsoft Learn Gestion des erreurs en Minimal APIs, Microsoft Learn Support OpenAPI dans ASP.NET Core, Microsoft Learn Déploiement Native AOT, Microsoft Learn ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/layer-focused-controllers-vs-minimal-api/","section":"Posts","summary":"","title":"Endpoints en .NET : Controllers vs Minimal API, la comparaison honnête"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/kubernetes/","section":"Tags","summary":"","title":"Kubernetes"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va découvrir Kubernetes vu sous l\u0026rsquo;angle déploiement, et le workflow quotidien qui transforme des manifests en releases fiables.\nL\u0026rsquo;article Kubernetes de la série Hosting a couvert les primitives nécessaires pour faire tourner une application ASP.NET Core sur Kubernetes : Deployments, Services, Ingress, probes, limites de ressources. Cet article couvre l\u0026rsquo;autre moitié : comment travailler au quotidien avec Kubernetes en tant que dev .NET. Quelles commandes kubectl comptent, comment organiser les manifests pour que dev, staging et prod ne dérivent pas, comment Kustomize et Helm s\u0026rsquo;articulent, et le workflow concret qu\u0026rsquo;une équipe utilise pour passer de \u0026ldquo;j\u0026rsquo;ai écrit un changement\u0026rdquo; à \u0026ldquo;c\u0026rsquo;est déployé\u0026rdquo; sans fabriquer du YAML à la main pour chaque environnement.\nL\u0026rsquo;hypothèse ici est que l\u0026rsquo;article Hosting a été lu, que ce qu\u0026rsquo;est un Deployment et un Service est compris, et qu\u0026rsquo;il faut maintenant livrer la chose. Le but de ce primer est de donner le minimum viable d\u0026rsquo;outillage, et le jugement pour savoir quand en ajouter.\nLe contexte : pourquoi le workflow de déploiement Kubernetes compte #Kubernetes lui-même est déclaratif, ce qui est magnifique en théorie et impitoyable en pratique. Un seul fichier manifest avec des valeurs en dur marche très bien pour un environnement et dérive en une semaine sur trois. L\u0026rsquo;écart entre \u0026ldquo;j\u0026rsquo;ai un manifest Deployment\u0026rdquo; et \u0026ldquo;mon équipe livre de façon fiable en dev, staging et prod avec le même pipeline\u0026rdquo; est plus grand que ce que la plupart des tutoriels d\u0026rsquo;introduction admettent.\nLe workflow couvert ici répond à quatre questions concrètes :\nComment on parle réellement au cluster ? kubectl a des centaines de sous-commandes ; peut-être quinze comptent pour le travail quotidien. Comment garder les manifests DRY entre les environnements ? Un déploiement de prod a besoin de limites de ressources différentes, d\u0026rsquo;un nombre de réplicas différent, de secrets différents, et d\u0026rsquo;un hostname d\u0026rsquo;ingress différent d\u0026rsquo;un déploiement de dev. Copier-coller du YAML entre trois dossiers est exactement le problème que Kustomize et Helm résolvent. Comment packager et versionner une release ? Une release n\u0026rsquo;est pas juste un tag d\u0026rsquo;image. C\u0026rsquo;est un Deployment, un Service, un Ingress, un ConfigMap, un Secret, un HorizontalPodAutoscaler, et tout ce dont l\u0026rsquo;application a besoin. Tout cela doit bouger ensemble. Comment déployer sans toucher à kubectl en prod ? Les pipelines, le GitOps, et les changements revus sont l\u0026rsquo;alternative à \u0026ldquo;quelqu\u0026rsquo;un a tapé une commande sur son laptop\u0026rdquo;. Vue d\u0026rsquo;ensemble : le workflow de déploiement # graph LR A[Code source+ manifests] --\u003e B[Build CI] B --\u003e C[Image pousséeau registry] B --\u003e D[Manifests rendusKustomize ou Helm] D --\u003e E[kubectl applyou sync GitOps] E --\u003e F[Cluster réconciliel'état désiré] F --\u003e G[Pods qui tournent] Chaque déploiement Kubernetes suit la même forme de base. La CI build l\u0026rsquo;image, la pousse au registry, rend les manifests pour l\u0026rsquo;environnement cible, et les applique au cluster. Le cluster réconcilie son état courant avec l\u0026rsquo;état déclaré et rapporte le résultat. Les variations entre équipes se jouent surtout dans le comment les manifests sont rendus et comment ils sont appliqués.\nZoom : les commandes kubectl qui comptent #Parmi tout ce que kubectl peut faire, douze commandes couvrent 95% du travail quotidien. À apprendre en premier.\n# Où suis-je ? kubectl config current-context # quel cluster kubectl config use-context prod # changer de cluster kubectl get nodes # nœuds et leur statut # Qu\u0026#39;est-ce qui tourne ? kubectl get pods -n shop # pods dans un namespace kubectl get deployments,svc,ingress -n shop # toutes les ressources courantes d\u0026#39;un coup kubectl describe pod shop-api-abc123 -n shop # état détaillé d\u0026#39;un pod # Logs et debug kubectl logs shop-api-abc123 -n shop --tail=100 --follow kubectl logs -l app=shop-api -n shop --tail=100 # tous les pods qui matchent un label kubectl exec -it shop-api-abc123 -n shop -- /bin/sh # shell dans un pod # Apply et rollback kubectl apply -f deployment.yaml # créer ou mettre à jour kubectl rollout status deployment/shop-api -n shop # attendre que le rollout termine kubectl rollout undo deployment/shop-api -n shop # rollback à la revision précédente # Port-forward pour le debug local kubectl port-forward svc/shop-api 8080:80 -n shop Deux habitudes économisent du vrai temps. D\u0026rsquo;abord, fixer un namespace par défaut pour ne pas taper -n shop sur chaque commande : kubectl config set-context --current --namespace=shop. Ensuite, utiliser kubectl get avec des sélecteurs de labels (-l app=shop-api) pour opérer sur des groupes de ressources, pas sur des ressources individuelles.\n💡 Info : kubectl logs -l app=shop-api --follow est la commande à retenir pour du log tailing en prod. Elle agrège les logs de chaque pod qui matche en temps réel, ce qui est exactement ce qu\u0026rsquo;il faut pour debugger pourquoi un endpoint précis est lent sur plusieurs réplicas.\nZoom : layout des manifests avec Kustomize #Une approche naïve met tous les manifests dans un dossier et les édite à la main pour chaque environnement. Cela marche une semaine et s\u0026rsquo;effondre après. Kustomize résout le problème avec un pattern base + overlays natif à kubectl depuis la 1.14.\nk8s/ ├── base/ │ ├── deployment.yaml │ ├── service.yaml │ ├── ingress.yaml │ ├── configmap.yaml │ └── kustomization.yaml └── overlays/ ├── dev/ │ ├── kustomization.yaml │ └── patch-replicas.yaml ├── staging/ │ └── kustomization.yaml └── prod/ ├── kustomization.yaml ├── patch-replicas.yaml └── patch-resources.yaml La base contient les manifests tels qu\u0026rsquo;ils seraient dans un environnement \u0026ldquo;par défaut\u0026rdquo; : un replica, ressources minimales, aucune valeur spécifique à un environnement. Les overlays ne contiennent que les différences par rapport à la base.\n# k8s/base/kustomization.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - deployment.yaml - service.yaml - ingress.yaml - configmap.yaml commonLabels: app: shop-api images: - name: shop-api newName: myregistry.azurecr.io/shop-api newTag: \u0026#34;1.4.7\u0026#34; # k8s/overlays/prod/kustomization.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: shop-prod resources: - ../../base patches: - path: patch-replicas.yaml - path: patch-resources.yaml configMapGenerator: - name: shop-api-config behavior: merge literals: - Logging__LogLevel__Default=Warning - ASPNETCORE_ENVIRONMENT=Production images: - name: shop-api newTag: \u0026#34;1.4.7\u0026#34; # k8s/overlays/prod/patch-replicas.yaml apiVersion: apps/v1 kind: Deployment metadata: name: shop-api spec: replicas: 5 Trois choses que Kustomize donne gratuitement. Substitution de namespace : l\u0026rsquo;overlay déclare namespace: shop-prod et toutes les ressources de l\u0026rsquo;overlay sont déployées là, sans éditer la base. Overrides par patches : le nombre de réplicas et les limites de ressources vivent dans de petits fichiers de patch qui ne décrivent que le delta par rapport à la base. Génération de ConfigMap avec sémantique de merge : les valeurs spécifiques à un environnement se superposent aux valeurs de base sans dupliquer le ConfigMap complet.\nRendre et appliquer se fait en une seule commande :\n# Prévisualiser ce qui sera appliqué kubectl kustomize k8s/overlays/prod # Appliquer pour de vrai kubectl apply -k k8s/overlays/prod ✅ Bonne pratique : Toujours faire kubectl kustomize avant kubectl apply sur un nouvel environnement. Differ la sortie rendue contre l\u0026rsquo;état courant du cluster avec kubectl diff -k ... montre exactement ce qui va changer, ce qui est le plus proche d\u0026rsquo;un dry run que Kubernetes offre.\nZoom : Helm pour le packaging et la réutilisation #Kustomize est excellent pour les manifests d\u0026rsquo;une équipe. Helm résout un autre problème : packager les manifests en artefact réutilisable qui peut être versionné, partagé, et déployé avec des paramètres. Si Kustomize c\u0026rsquo;est \u0026ldquo;les manifests de mon équipe, par environnement\u0026rdquo;, Helm c\u0026rsquo;est \u0026ldquo;une unité packagée que je peux installer, mettre à jour, et désinstaller comme une bibliothèque\u0026rdquo;.\nLes cas pratiques où Helm gagne :\nInstaller des composants tiers. NGINX Ingress Controller, cert-manager, Prometheus, Grafana, external-secrets : tous sont livrés comme des charts Helm et s\u0026rsquo;installent en une commande. Packager sa propre application pour plusieurs consommateurs. Un service .NET que plusieurs équipes déploient (un service d\u0026rsquo;auth partagé, un agent d\u0026rsquo;observabilité partagé) est plus facile en chart avec des paramètres qu\u0026rsquo;en jeu de manifests que chaque équipe doit copier. Upgrades et rollbacks comme opérations de première classe. helm upgrade et helm rollback suivent l\u0026rsquo;historique des releases dans le cluster lui-même, ce qui est plus propre que suivre manuellement les commits Git. Une chart minimale pour l\u0026rsquo;API .NET :\nchart/ ├── Chart.yaml ├── values.yaml └── templates/ ├── deployment.yaml ├── service.yaml ├── ingress.yaml └── _helpers.tpl # Chart.yaml apiVersion: v2 name: shop-api version: 1.4.7 appVersion: \u0026#34;1.4.7\u0026#34; description: Shop API service # values.yaml image: repository: myregistry.azurecr.io/shop-api tag: \u0026#34;1.4.7\u0026#34; pullPolicy: IfNotPresent replicaCount: 3 resources: requests: cpu: 100m memory: 256Mi limits: cpu: 500m memory: 512Mi ingress: enabled: true className: nginx host: api.shop.example.com tls: enabled: true secretName: shop-api-tls # templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: {{ include \u0026#34;shop-api.fullname\u0026#34; . }} labels: {{- include \u0026#34;shop-api.labels\u0026#34; . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include \u0026#34;shop-api.selectorLabels\u0026#34; . | nindent 6 }} template: metadata: labels: {{- include \u0026#34;shop-api.selectorLabels\u0026#34; . | nindent 8 }} spec: containers: - name: api image: \u0026#34;{{ .Values.image.repository }}:{{ .Values.image.tag }}\u0026#34; imagePullPolicy: {{ .Values.image.pullPolicy }} resources: {{- toYaml .Values.resources | nindent 12 }} Installation :\nhelm install shop-api ./chart --namespace shop-prod --create-namespace \\ --set image.tag=1.4.7 \\ --set replicaCount=5 Upgrade :\nhelm upgrade shop-api ./chart --namespace shop-prod \\ --set image.tag=1.4.8 Rollback :\nhelm rollback shop-api --namespace shop-prod # revient à la révision précédente helm rollback shop-api 3 --namespace shop-prod # revient à la révision 3 précisément ⚠️ Ça marche, mais\u0026hellip; : Les templates Helm sont du Go text/template par-dessus du YAML, une combinaison qui ne se dégrade pas toujours proprement. Une indentation mal placée dans un template peut produire du YAML qui a l\u0026rsquo;air valide mais qui est sémantiquement faux. helm template ./chart -f values-prod.yaml | kubectl apply --dry-run=server -f - est la façon standard d\u0026rsquo;attraper ça avant que cela n\u0026rsquo;atteigne le cluster.\nZoom : Kustomize ou Helm, lequel #Le choix n\u0026rsquo;est pas soit l\u0026rsquo;un soit l\u0026rsquo;autre. La plupart des setups Kubernetes matures utilisent les deux.\nUtiliser Helm pour les composants tiers, les services partagés, et tout ce qu\u0026rsquo;on publie dans un dépôt de charts. Sa force est le packaging et la sémantique d\u0026rsquo;upgrade.\nUtiliser Kustomize pour les services de sa propre équipe, où on contrôle à la fois les manifests de base et les overlays. Sa force est la simplicité : pas de langage de template, pas de helpers, juste des patches YAML.\nCombiner les deux en utilisant Kustomize pour post-traiter la sortie de Helm. Helm rend une chart avec des valeurs de base, Kustomize applique les overrides spécifiques à l\u0026rsquo;équipe par-dessus. C\u0026rsquo;est le pattern sur lequel atterrissent la plupart des clusters de prod après un an ou deux d\u0026rsquo;expérimentation.\n# kustomization.yaml helmCharts: - name: ingress-nginx repo: https://kubernetes.github.io/ingress-nginx version: 4.10.0 releaseName: ingress namespace: ingress-nginx valuesFile: values-ingress.yaml patches: - path: patch-ingress-resources.yaml 💡 Info : kubectl livre Kustomize nativement, mais pas Helm. Installer Helm est un script d\u0026rsquo;une ligne (curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash), et la plupart des environnements Kubernetes l\u0026rsquo;ont déjà.\nZoom : GitOps avec Flux ou ArgoCD #Une fois les manifests organisés et le rendering qui marche, l\u0026rsquo;étape suivante est de retirer les humains du chemin de déploiement entièrement. GitOps est le pattern où le cluster réconcilie en continu avec un repository Git : le repository est la source de vérité, et le cluster le poll pour les changements et les applique automatiquement.\nLes deux outils largement utilisés sont Flux et ArgoCD. Les deux marchent de la même façon : on installe un controller dans le cluster, on le pointe vers un repository Git, et chaque changement mergé dans la branche principale de ce repository est appliqué au cluster en quelques secondes. Le rollback est un git revert.\nUne Application ArgoCD minimale :\napiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: shop-api namespace: argocd spec: project: default source: repoURL: https://github.com/myorg/shop-manifests.git path: overlays/prod targetRevision: main destination: server: https://kubernetes.default.svc namespace: shop-prod syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true Après avoir appliqué cela une fois, ArgoCD surveille le dossier overlays/prod du repository de manifests. Tout merge dans main déclenche une sync automatique. L\u0026rsquo;option selfHeal: true signifie que le cluster auto-corrige la dérive : si quelqu\u0026rsquo;un édite manuellement une ressource avec kubectl, ArgoCD la ramène à l\u0026rsquo;état Git.\nLes bénéfices sont concrets : chaque déploiement est une pull request avec des relecteurs, chaque rollback est un git revert, et chaque environnement est auditable en regardant l\u0026rsquo;historique Git.\n✅ Bonne pratique : Garder le code source de l\u0026rsquo;application et les manifests dans des repositories séparés. shop-api a le code C# ; shop-manifests a les YAML. Cette séparation laisse le pipeline CI pousser des mises à jour de manifests (nouveau tag d\u0026rsquo;image) sans polluer l\u0026rsquo;historique du repository de code, et donne à l\u0026rsquo;équipe d\u0026rsquo;ops une frontière claire.\nQuand c\u0026rsquo;est surdimensionné #Tout ce qui est dans cet article suppose que l\u0026rsquo;équipe tourne réellement sur Kubernetes et a l\u0026rsquo;intention de continuer. Si le setup courant est un seul container sur Azure Web App, passer à Kustomize + Helm + GitOps est surdimensionné. Commencer avec l\u0026rsquo;option d\u0026rsquo;hébergement qui correspond à la taille de l\u0026rsquo;équipe et à la charge, et adopter cette chaîne d\u0026rsquo;outils quand l\u0026rsquo;échelle le justifie.\nSeuils approximatifs :\nUn ou deux services, une équipe : kubectl apply -f avec des manifests bruts suffit. Une poignée de services, un environnement autre que dev : ajouter Kustomize. Beaucoup de services, plusieurs environnements, plusieurs équipes : ajouter Helm (pour les composants partagés) et GitOps (pour le pipeline de déploiement). Wrap-up #Déployer des applications .NET sur Kubernetes au quotidien se résume à un petit jeu d\u0026rsquo;outils et d\u0026rsquo;habitudes : quinze commandes kubectl pour tout ce qui est opérationnel, Kustomize pour la gestion de manifests en base-plus-overlays, Helm pour le packaging et les charts tierces, et GitOps avec Flux ou ArgoCD quand l\u0026rsquo;échelle justifie de retirer les humains du chemin de déploiement. Tu peux les adopter de façon incrémentale, commencer avec juste Kustomize, ajouter Helm quand cela rentabilise, et atteindre GitOps quand \u0026ldquo;qui a déployé quoi quand\u0026rdquo; devient une vraie question. Tu peux éviter le mode de défaillance classique du YAML copié-collé entre environnements, et tu peux donner à l\u0026rsquo;équipe un workflow de déploiement relisable, auditable, et réversible.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core sur Kubernetes : l\u0026rsquo;essentiel pour les devs .NET Docker pour le déploiement .NET : Dockerfile et Compose en pratique Docker : bonnes pratiques de sécurité pour .NET Références # Cheat sheet kubectl, docs Kubernetes Documentation Kustomize Documentation Helm Documentation ArgoCD Documentation Flux Déployer une application .NET sur Kubernetes, Microsoft Learn ","date":"9 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/deployment-kubernetes-primer/","section":"Posts","summary":"","title":"Kubernetes : l'essentiel pour les devs .NET, de kubectl à Helm"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/migrations/","section":"Tags","summary":"","title":"Migrations"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/minimal-api/","section":"Tags","summary":"","title":"Minimal-Api"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/performance/","section":"Tags","summary":"","title":"Performance"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/security/","section":"Tags","summary":"","title":"Security"},{"content":"15 ans et plus à construire et enseigner .NET. #Je suis Mohamed Mustapha, ingénieur fullstack et formateur. Après quinze ans et plus dans le métier, ma stack aujourd\u0026rsquo;hui est claire : .NET (Core et suite) côté back, Angular côté front. SharePoint et .NET Framework Web Forms ont fait partie de mes débuts, mais j\u0026rsquo;ai tourné cette page depuis un bon moment, et je livre du .NET moderne et de l\u0026rsquo;Angular depuis.\nLà où j\u0026rsquo;ai construit #Au fil des années, j\u0026rsquo;ai travaillé chez Microsoft France, et livré pour des clients comme Natixis, Banques Populaires, Rexel et Thales. Banque, distribution d\u0026rsquo;énergie, défense, IT d\u0026rsquo;entreprise : chacun de ces environnements t\u0026rsquo;apprend quelque chose de différent sur ce que \u0026ldquo;la prod\u0026rdquo; veut vraiment dire, sur la façon dont les décisions d\u0026rsquo;architecture survivent au contact du réel, et sur comment garder un codebase en bonne santé pendant que les équipes changent. Aujourd\u0026rsquo;hui, je travaille à la Direction Générale du Trésor (Ministère de l\u0026rsquo;Économie) sur la modernisation d\u0026rsquo;applications administratives, mais la partie la plus importante de mon parcours, ce n\u0026rsquo;est aucun titre en particulier, c\u0026rsquo;est l\u0026rsquo;accumulation de contextes.\nPourquoi j\u0026rsquo;écris #J\u0026rsquo;ai vu .NET évoluer à travers chaque virage majeur : ASP.NET MVC, les middlewares OWIN, .NET Core, le software craftsmanship, le N-Couches, la Clean Architecture, CQRS, le Vertical Slicing, les microservices, les monolithes modulaires, les containers, Kubernetes, l\u0026rsquo;observabilité, .NET Aspire. J\u0026rsquo;ai dû comprendre chaque changement, pas parce que je collectionne les buzzwords, mais parce qu\u0026rsquo;un formateur ne peut pas tricher : quand tu expliques un pattern à une salle de devs mid-level, il faut que tu saches pourquoi il existe, quel problème il règle vraiment, et quel compromis il introduit en échange.\nCe blog, c\u0026rsquo;est cette connaissance mise à l\u0026rsquo;écrit. La même carte mentale que j\u0026rsquo;utilise quand je forme une équipe pendant une semaine d\u0026rsquo;atelier, accessible à quiconque veut passer du mid-level au senior à son rythme.\nJ\u0026rsquo;ai passé des années à faire ce que la plupart des tutoriels évitent : migrer de l\u0026rsquo;authentification legacy vers Keycloak sans réécrire l\u0026rsquo;application, câbler du tracing distribué sur de vraies infrastructures, concevoir des monolithes modulaires que les équipes peuvent réellement maintenir, et expliquer à des directions non-techniques pourquoi les décisions d\u0026rsquo;architecture ont des conséquences concrètes.\nA qui s\u0026rsquo;adresse ce blog #Tu es développeur .NET avec deux à cinq ans d\u0026rsquo;expérience. Tu livres des features. Tu connais C#. Mais tu as l\u0026rsquo;impression qu\u0026rsquo;il manque quelque chose entre où tu en es et comment opèrent les développeurs seniors.\nCet écart est rarement une question de syntaxe. Il s\u0026rsquo;agit de comprendre pourquoi une décision d\u0026rsquo;architecture est prise, pas seulement comment l\u0026rsquo;implémenter. Il s\u0026rsquo;agit de saisir les compromis avant de se retrouver au milieu d\u0026rsquo;un incident de production.\nC\u0026rsquo;est précisément ce que ce blog adresse.\nCe que tu trouveras ici #Chaque série est construite autour d\u0026rsquo;un thème concret qui distingue le niveau mid du niveau senior : structure de code, observabilité, authentification, déploiement, gestion des erreurs, performance et tests. Chaque article suit la même structure : le problème réel d\u0026rsquo;abord, un découpage technique clair, et des notes franches sur ce qui fonctionne, ce qui ne fonctionne pas, et ce qu\u0026rsquo;il ne faut jamais faire.\nTout le contenu est publié en français et en anglais. La version française n\u0026rsquo;est pas une traduction. Elle est rédigée dans la même tonalité que j\u0026rsquo;utilise quand je forme des équipes en interne.\nDisponible pour des formations #Je suis disponible pour des sessions de formation et des ateliers à destination des équipes .NET et Angular. Les thèmes couverts incluent tout ce qui est publié sur ce blog : Clean Architecture, Docker et Kubernetes, intégration Keycloak, observabilité avec OpenTelemetry, stratégies de tests, et bien plus.\nSi ton équipe veut aller vers du .NET moderne et Angular, et a besoin d\u0026rsquo;un accompagnement structuré et concret par quelqu\u0026rsquo;un qui a livré ces patterns pour de vrais clients, contacte-moi.\nContact : LinkedIn | GitHub\n","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/about/","section":"Road to Senior .NET Developer","summary":"","title":"À propos"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/aot/","section":"Tags","summary":"","title":"Aot"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/api/","section":"Tags","summary":"","title":"Api"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/architecture/","section":"Tags","summary":"","title":"Architecture"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/azure/","section":"Tags","summary":"","title":"Azure"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/baseline/","section":"Tags","summary":"","title":"Baseline"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier la Clean Architecture, probablement le pattern le plus cité et le plus mal compris dans le monde .NET.\nClean Architecture a un problème de branding. Beaucoup de codebases .NET qui l\u0026rsquo;affichent dans leur README ressemblent en fait à du N-Couches avec des dossiers renommés, et d\u0026rsquo;autres accumulent assez d\u0026rsquo;interfaces, de mappers et de sauts de DTOs pour qu\u0026rsquo;un simple listing produit devienne un parcours à travers plusieurs fichiers. Ces deux situations s\u0026rsquo;expliquent facilement : le pattern est souvent introduit sans son cadrage d\u0026rsquo;origine, et les équipes comblent le vide avec du cérémonial. L\u0026rsquo;idée d\u0026rsquo;origine, celle que Robert Martin a formalisée en 2012 (en s\u0026rsquo;appuyant sur des travaux plus anciens comme l\u0026rsquo;Hexagonal Architecture d\u0026rsquo;Alistair Cockburn en 2005 et l\u0026rsquo;Onion Architecture de Jeffrey Palermo en 2008), est beaucoup plus petite et beaucoup plus utile : tes règles métier ne doivent dépendre ni de ton framework, ni de la base de données, ni de ta stack HTTP. Tout le reste, c\u0026rsquo;est du détail d\u0026rsquo;implémentation.\nSi tu as lu les articles précédents de cette série, tu connais déjà les deux patterns qui sont venus avant : l\u0026rsquo;architecture N-Couches avec ses projets physiquement séparés, et UI / Repositories / Services avec son découpage pragmatique à l\u0026rsquo;intérieur d\u0026rsquo;un seul projet. Clean Architecture, c\u0026rsquo;est ce que tu sors du placard quand ces patterns commencent à fuir et que tu as besoin du compilateur pour tenir la frontière entre le domaine et le monde extérieur.\nLe contexte : pourquoi ce pattern existe #Imaginons que nous ayons un codebase .NET mature. L\u0026rsquo;équipe livre depuis trois ans. Les requêtes EF Core vivent à l\u0026rsquo;intérieur des classes de service. Les controllers construisent des objets métier à la main depuis le body des requêtes. Un UserService référence HttpContext pour lire l\u0026rsquo;utilisateur courant. Une règle métier sur l\u0026rsquo;éligibilité aux promotions vit moitié dans une procédure stockée, moitié dans un if d\u0026rsquo;un controller, et moitié dans un fichier JavaScript côté front. Quand un nouveau prestataire de paiement arrive, personne ne peut modifier OrderService sans casser deux fonctionnalités, parce que la logique métier est emmêlée avec des appels spécifiques à Stripe.\nVoilà la douleur que Clean Architecture soigne. Son contrat :\nTon modèle de domaine ne connaît rien à EF Core, ASP.NET, MediatR ou Stripe. Ta couche application orchestre les cas d\u0026rsquo;usage en ne manipulant que des abstractions. L\u0026rsquo;infrastructure se branche depuis l\u0026rsquo;extérieur et peut être remplacée sans toucher au domaine. Le gain n\u0026rsquo;est pas théorique. C\u0026rsquo;est la possibilité de monter EF Core de version, de changer de bus de messages, ou de remplacer ton prestataire de paiement sans ouvrir le projet de domaine. C\u0026rsquo;est aussi la possibilité d\u0026rsquo;écrire des tests unitaires rapides sur tes règles métier sans démarrer une base de données.\nVue d\u0026rsquo;ensemble : les couches et la règle #Avant de rentrer dans le code, voici les grandes briques de la Clean Architecture telles qu\u0026rsquo;on va les utiliser en .NET :\ngraph TD A[Api / PresentationControllers, Minimal APIs, SignalR] --\u003e B[ApplicationUse cases, commandes, queries, ports] B --\u003e C[DomainEntités, value objects, services de domaine, invariants] D[InfrastructureEF Core, clients HTTP, fichiers, bus de messages] --\u003e B D --\u003e C A --\u003e D Les flèches, c\u0026rsquo;est la seule chose qui compte. Tout pointe vers Domain. Domain ne dépend de rien. Application ne dépend que de Domain. Infrastructure implémente les interfaces déclarées dans Application (ou dans Domain). Le projet Api fait le câblage au démarrage. Si les flèches sont bonnes, tu as de la Clean Architecture. Sinon, tu as quatre projets qui te coûtent le découpage sans te rendre le bénéfice.\n💡 Info : Le diagramme d\u0026rsquo;origine montre quatre cercles concentriques (Entities, Use Cases, Interface Adapters, Frameworks). En pratique, la plupart des équipes .NET ramènent ça à quatre csproj : Domain, Application, Infrastructure, Api. Ce mapping est suffisant et c\u0026rsquo;est celui que j\u0026rsquo;utilise dans cet article.\nZoom : Domain, le cœur #Le projet Domain contient tes concepts métier et leurs invariants. Il ne référence rien. Ni EF Core, ni MediatR, ni Microsoft.Extensions.*. Juste net10.0 et tes propres types.\n// Domain/Orders/Order.cs namespace Shop.Domain.Orders; public sealed class Order { private readonly List\u0026lt;OrderLine\u0026gt; _lines = new(); public OrderId Id { get; } public CustomerId CustomerId { get; } public OrderStatus Status { get; private set; } public IReadOnlyCollection\u0026lt;OrderLine\u0026gt; Lines =\u0026gt; _lines.AsReadOnly(); public Money Total =\u0026gt; new(_lines.Sum(l =\u0026gt; l.Subtotal.Amount), Currency.Eur); private Order(OrderId id, CustomerId customerId) { Id = id; CustomerId = customerId; Status = OrderStatus.Draft; } public static Order Create(CustomerId customerId) =\u0026gt; new(OrderId.New(), customerId); public void AddLine(ProductId productId, int quantity, Money unitPrice) { if (Status != OrderStatus.Draft) throw new DomainException(\u0026#34;On ne peut pas modifier une commande déjà soumise.\u0026#34;); if (quantity \u0026lt;= 0) throw new DomainException(\u0026#34;La quantité doit être positive.\u0026#34;); _lines.Add(new OrderLine(productId, quantity, unitPrice)); } public void Submit() { if (_lines.Count == 0) throw new DomainException(\u0026#34;Une commande vide ne peut pas être soumise.\u0026#34;); Status = OrderStatus.Submitted; } } Remarque ce qui n\u0026rsquo;est pas là : pas d\u0026rsquo;attribut [Table], pas de DbContext, pas de mot-clé virtual pour le lazy loading, pas de propriété de navigation qui présuppose EF Core. L\u0026rsquo;entité fait respecter ses propres invariants. Casser une règle lève une exception de domaine, pas un HTTP 400.\n✅ Bonne pratique : Mets tes constructeurs en privé ou internal et expose des méthodes factory (Order.Create(...)). Ça force tous les appelants à passer par tes vérifications d\u0026rsquo;invariants. Il n\u0026rsquo;y a aucun moyen d\u0026rsquo;obtenir un Order cassé depuis l\u0026rsquo;extérieur.\n❌ Ne jamais faire : Ne colle pas d\u0026rsquo;attributs [Column] ou [Required] sur tes entités de domaine pour \u0026ldquo;gagner du temps\u0026rdquo;. Dès que tu le fais, le projet Domain gagne une dépendance dure sur un ORM, et l\u0026rsquo;invariant \u0026ldquo;Domain ne référence rien\u0026rdquo; cesse d\u0026rsquo;être vrai. L\u0026rsquo;API fluent d\u0026rsquo;EF Core dans Infrastructure te donne le même mapping sans faire fuiter le framework jusque dans tes entités.\nZoom : Application, les cas d\u0026rsquo;usage #La couche Application décrit ce que le système fait, exprimé sous forme de cas d\u0026rsquo;usage. C\u0026rsquo;est ici que vivent les commandes et les queries, que les transactions sont coordonnées, et que tu déclares les ports (interfaces) que l\u0026rsquo;Infrastructure viendra brancher. Si le découpage commandes / queries est nouveau pour toi, l\u0026rsquo;article dédié Couche Application en .NET : CQS et CQRS sans le hype traite le pattern en profondeur, cette section se contente d\u0026rsquo;en supposer la forme.\n// Application/Orders/SubmitOrder/SubmitOrderCommand.cs public sealed record SubmitOrderCommand(Guid OrderId) : IRequest\u0026lt;Result\u0026gt;; // Application/Orders/SubmitOrder/SubmitOrderHandler.cs public sealed class SubmitOrderHandler : IRequestHandler\u0026lt;SubmitOrderCommand, Result\u0026gt; { private readonly IOrderRepository _orders; private readonly IUnitOfWork _uow; private readonly IPaymentGateway _payments; public SubmitOrderHandler( IOrderRepository orders, IUnitOfWork uow, IPaymentGateway payments) { _orders = orders; _uow = uow; _payments = payments; } public async Task\u0026lt;Result\u0026gt; Handle(SubmitOrderCommand cmd, CancellationToken ct) { var order = await _orders.GetByIdAsync(new OrderId(cmd.OrderId), ct); if (order is null) return Result.NotFound($\u0026#34;Commande {cmd.OrderId} introuvable.\u0026#34;); order.Submit(); var charge = await _payments.ChargeAsync(order.CustomerId, order.Total, ct); if (!charge.Success) return Result.Failure(charge.Error); await _uow.SaveChangesAsync(ct); return Result.Success(); } } Les interfaces IOrderRepository, IUnitOfWork et IPaymentGateway vivent dans le projet Application, juste à côté du use case qui en a besoin. Elles décrivent ce dont l\u0026rsquo;application a besoin, pas comment c\u0026rsquo;est implémenté.\n// Application/Abstractions/IOrderRepository.cs public interface IOrderRepository { Task\u0026lt;Order?\u0026gt; GetByIdAsync(OrderId id, CancellationToken ct); Task AddAsync(Order order, CancellationToken ct); } 💡 Info : C\u0026rsquo;est le Dependency Inversion Principle rendu physique. La politique haute (Application) possède l\u0026rsquo;abstraction. Le détail bas (Infrastructure) l\u0026rsquo;implémente. La flèche va d\u0026rsquo;Infrastructure vers Application, et pas l\u0026rsquo;inverse.\n⚠️ Ça marche, mais\u0026hellip; : Tu verras des équipes mettre toutes leurs interfaces dans un projet séparé Application.Contracts, \u0026ldquo;pour la réutilisation\u0026rdquo;. Dans 90% des cas, ce projet n\u0026rsquo;est importé que par Infrastructure et n\u0026rsquo;apporte rien. Garde les interfaces à côté de leurs cas d\u0026rsquo;usage, tant que tu n\u0026rsquo;as pas un vrai second consommateur.\nZoom : Infrastructure, les prises #Infrastructure, c\u0026rsquo;est là où EF Core, les clients HTTP, les écritures fichiers et le bus de messages apparaissent enfin. Elle référence Application (pour implémenter les ports) et Domain (pour mapper les entités). Rien dans Application ou Domain ne référence Infrastructure.\n// Infrastructure/Persistence/OrderRepository.cs internal sealed class OrderRepository : IOrderRepository { private readonly ShopDbContext _db; public OrderRepository(ShopDbContext db) =\u0026gt; _db = db; public Task\u0026lt;Order?\u0026gt; GetByIdAsync(OrderId id, CancellationToken ct) =\u0026gt; _db.Orders .Include(o =\u0026gt; o.Lines) .FirstOrDefaultAsync(o =\u0026gt; o.Id == id, ct); public async Task AddAsync(Order order, CancellationToken ct) =\u0026gt; await _db.Orders.AddAsync(order, ct); } La configuration EF Core qui connaît les tables et les colonnes vit aussi ici, pas sur l\u0026rsquo;entité :\n// Infrastructure/Persistence/Configurations/OrderConfiguration.cs internal sealed class OrderConfiguration : IEntityTypeConfiguration\u0026lt;Order\u0026gt; { public void Configure(EntityTypeBuilder\u0026lt;Order\u0026gt; b) { b.ToTable(\u0026#34;Orders\u0026#34;); b.HasKey(o =\u0026gt; o.Id); b.Property(o =\u0026gt; o.Id) .HasConversion(id =\u0026gt; id.Value, value =\u0026gt; new OrderId(value)); b.Property(o =\u0026gt; o.Status).HasConversion\u0026lt;string\u0026gt;(); b.OwnsMany(o =\u0026gt; o.Lines, lines =\u0026gt; { lines.ToTable(\u0026#34;OrderLines\u0026#34;); lines.WithOwner().HasForeignKey(\u0026#34;OrderId\u0026#34;); }); } } ✅ Bonne pratique : Marque tes implémentations d\u0026rsquo;Infrastructure en internal. La seule façon pour le monde extérieur d\u0026rsquo;obtenir un IOrderRepository, ça doit être via la DI. Si un controller peut faire new OrderRepository(...), il y a un problème.\nZoom : Api, la composition root #Le projet Api, c\u0026rsquo;est là où tout est câblé. Il référence Application et Infrastructure, enregistre les services, et expose les endpoints. Il reste fin : parser, déléguer, mapper le résultat.\n// Api/Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddApplication(); // MediatR, validators, pipeline behaviors builder.Services.AddInfrastructure( // DbContext, repositories, clients HTTP builder.Configuration); var app = builder.Build(); app.MapOrderEndpoints(); app.Run(); // Api/Endpoints/OrderEndpoints.cs public static class OrderEndpoints { public static void MapOrderEndpoints(this IEndpointRouteBuilder app) { var g = app.MapGroup(\u0026#34;/orders\u0026#34;); g.MapPost(\u0026#34;/{id:guid}/submit\u0026#34;, async ( Guid id, ISender mediator, CancellationToken ct) =\u0026gt; { var result = await mediator.Send(new SubmitOrderCommand(id), ct); return result.IsSuccess ? Results.NoContent() : result.ToProblemDetails(); }); } } Aucune logique métier ici. L\u0026rsquo;endpoint parse la route, dispatche la commande, traduit le résultat. Si tu dois changer de transport demain, un service gRPC ou un worker de fond, tu écris une nouvelle composition root et tu réutilises Application et Domain sans y toucher.\n💡 Info : AddApplication et AddInfrastructure sont des méthodes d\u0026rsquo;extension qui vivent dans leur projet respectif. Ça garde chaque couche en charge de ses propres enregistrements, et le projet Api n\u0026rsquo;a pas besoin de savoir ce qu\u0026rsquo;est un DbContext.\nLa règle que le compilateur doit faire respecter #L\u0026rsquo;intérêt de découper en quatre projets, c\u0026rsquo;est que le compilateur vérifie les flèches pour toi. Ton graphe de csproj doit ressembler à ça :\nApi -\u0026gt; Application, Infrastructure Infrastructure-\u0026gt; Application, Domain Application -\u0026gt; Domain Domain -\u0026gt; (rien) Si Domain gagne une référence vers quoi que ce soit, tu as un bug dans le pattern. Une bonne pratique, c\u0026rsquo;est d\u0026rsquo;ajouter un test d\u0026rsquo;architecture pour que la règle devienne exécutable :\n[Fact] public void Domain_ne_doit_dependre_de_rien() { var result = Types.InAssembly(typeof(Order).Assembly) .ShouldNot() .HaveDependencyOnAny(\u0026#34;Shop.Application\u0026#34;, \u0026#34;Shop.Infrastructure\u0026#34;, \u0026#34;Shop.Api\u0026#34;) .GetResult(); result.IsSuccessful.Should().BeTrue(); } ✅ Bonne pratique : Ajoute un test d\u0026rsquo;architecture par couche. Ça prend cinq minutes avec NetArchTest ou ArchUnitNET et ça attrape le using Shop.Infrastructure; accidentel avant qu\u0026rsquo;il ne devienne, en silence, un point d\u0026rsquo;appui du code.\nQuand Clean Architecture est le mauvais choix #Clean Architecture, c\u0026rsquo;est du surcoût. Quatre projets, une chorégraphie d\u0026rsquo;injection de dépendances, des handlers, des ports, des mappers. Pour une appli CRUD de trente endpoints où les \u0026ldquo;règles métier\u0026rdquo; se résument à \u0026ldquo;enregistre ça et renvoie-le\u0026rdquo;, la structure coûte plus qu\u0026rsquo;elle ne rapporte. Dans ces cas-là, UI / Repositories / Services est honnêtement meilleur et ça te fera livrer plus vite.\nSors la Clean Architecture quand au moins deux de ces conditions sont vraies :\nTu as de vraies règles métier, pas juste du CRUD. Des invariants, des machines à états, des calculs, de la cohérence entre agrégats. L\u0026rsquo;application doit survivre à plusieurs frameworks, moteurs de stockage ou transports pendant sa vie. Plusieurs équipes ou développeurs ont besoin de frontières vraiment respectées. Tu vas écrire une quantité non négligeable de tests unitaires sur le domaine et tu ne veux pas embarquer une base de données dedans. Si aucune de ces conditions n\u0026rsquo;est remplie, tu paies la taxe sans toucher les bénéfices.\nWrap-up #Tu sais maintenant ce qu\u0026rsquo;est vraiment la Clean Architecture : une seule règle sur le sens des dépendances, respectée par les références de projets et parfois par des tests d\u0026rsquo;architecture. Tu peux monter les quatre projets, mettre tes invariants dans le Domain, garder tes cas d\u0026rsquo;usage dans Application, brancher Infrastructure depuis l\u0026rsquo;extérieur, et garder l\u0026rsquo;Api comme une composition root fine. Tu peux aussi reconnaître quand le codebase n\u0026rsquo;a pas besoin de ce niveau de structure et choisir une option plus légère.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # L\u0026rsquo;architecture N-Couches en .NET : les fondations que tu dois maîtriser UI / Repositories / Services : le découpage .NET pragmatique Références # Architectures d\u0026rsquo;applications web courantes, Microsoft Learn Pattern Ports et Adapters, Microsoft Learn Injection de dépendances dans ASP.NET Core, Microsoft Learn Configuration fluent d\u0026rsquo;Entity Framework Core, Microsoft Learn NetArchTest sur GitHub ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/code-structure-clean-architecture/","section":"Posts","summary":"","title":"Clean Architecture en .NET : des dépendances qui pointent dans le bon sens"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/clean-architecture/","section":"Tags","summary":"","title":"Clean-Architecture"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/clean-code/","section":"Tags","summary":"","title":"Clean-Code"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/containers/","section":"Tags","summary":"","title":"Containers"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/e2e/","section":"Tags","summary":"","title":"E2e"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer l\u0026rsquo;hébergement d\u0026rsquo;ASP.NET Core avec Docker, en visant un résultat propre et production-ready plutôt qu\u0026rsquo;un Dockerfile de tutoriel.\nLes containers ont changé l\u0026rsquo;hébergement .NET plus que n\u0026rsquo;importe quelle autre technologie de la dernière décennie. Avant Docker, livrer une application .NET signifiait produire un MSI, un package Web Deploy ou un ZIP, et espérer que l\u0026rsquo;environnement cible avait le bon runtime installé. Après Docker, livrer une application .NET signifie produire une image, et cette image contient tout ce qu\u0026rsquo;il faut pour tourner : le runtime, l\u0026rsquo;application, les dépendances trimmées, rien d\u0026rsquo;autre. L\u0026rsquo;image tourne à l\u0026rsquo;identique sur un poste de dev, un agent de CI, un cluster de pré-prod, et un hôte de prod.\nCet article ne traite pas de \u0026ldquo;Docker en général\u0026rdquo;. Il traite de l\u0026rsquo;hébergement correct d\u0026rsquo;une application ASP.NET Core sur Docker en 2026, avec les images de base Microsoft qui ont réellement du sens, un Dockerfile multi-stage qui produit une image petite et sécurisée, et la poignée de détails de configuration qui séparent un container qui marche d\u0026rsquo;un container prêt pour la prod. Là où un article précédent de la série a couvert IIS comme option Windows-first, celui-ci couvre le défaut cross-platform.\nLe contexte : pourquoi héberger sur Docker #Les avantages de containeriser une application .NET sont bien connus, mais valent la peine d\u0026rsquo;être rappelés clairement parce que \u0026ldquo;on a toujours fait comme ça\u0026rdquo; est une raison étonnamment courante de ne pas être encore sur Docker :\nDéploiement déterministe. L\u0026rsquo;image construite en CI est bit pour bit identique à l\u0026rsquo;image qui tourne en prod. Plus de \u0026ldquo;ça marche sur ma machine\u0026rdquo;, plus de \u0026ldquo;l\u0026rsquo;image de base a été patchée entre deux builds\u0026rdquo;, plus de \u0026ldquo;la version du runtime a bougé\u0026rdquo;. Découplage de l\u0026rsquo;OS hôte. L\u0026rsquo;hôte a besoin d\u0026rsquo;un runtime de container (containerd, Docker Engine, ou une alternative compatible) et de rien d\u0026rsquo;autre. Pas de .NET Hosting Bundle, pas d\u0026rsquo;IIS, pas de dépendance installée sur la machine. Une seule cible de déploiement. La même image tourne sur un poste de dev, sur Kubernetes, Azure Container Apps, AWS ECS, ou un hôte Docker nu. L\u0026rsquo;orchestrateur change ; l\u0026rsquo;image ne change pas. Des opérations rapides et scriptables. Les rolling updates, les rollbacks, et les déploiements blue/green deviennent des primitives simples de l\u0026rsquo;orchestrateur plutôt que des scripts custom. Pour un nouveau projet .NET en 2026, la stratégie d\u0026rsquo;hébergement par défaut est un container. La question n\u0026rsquo;est pas \u0026ldquo;faut-il utiliser Docker\u0026rdquo;, c\u0026rsquo;est \u0026ldquo;comment construire l\u0026rsquo;image proprement\u0026rdquo;.\nVue d\u0026rsquo;ensemble : le pipeline d\u0026rsquo;image # graph LR A[Code source] --\u003e B[Image SDKstage de build] B --\u003e C[Restore + Publish] C --\u003e D[Image runtimestage final] D --\u003e E[Binaire applicatif] D --\u003e F[Métadonnées :ports, user, entrypoint] E --\u003e G[Image finale80-120 Mo] F --\u003e G Toute image Docker .NET qui vaut la peine d\u0026rsquo;être livrée est construite en deux stages. Le stage de build utilise une grosse image SDK (mcr.microsoft.com/dotnet/sdk) qui contient le compilateur, NuGet, et l\u0026rsquo;outillage nécessaire pour produire une sortie de publish. Le stage runtime utilise une image beaucoup plus petite (mcr.microsoft.com/dotnet/aspnet ou sa variante chiseled) qui ne contient que ce qui est nécessaire à l\u0026rsquo;exécution. La sortie publiée du stage de build est copiée dans le stage runtime, et le stage runtime est celui qui part en prod.\nCe pattern à deux stages n\u0026rsquo;est pas optionnel. Une image à un seul stage basée sur le SDK ferait 700 Mo et plus, ce qui est acceptable pour une playground de dev et complètement inadapté à la prod.\nZoom : le Dockerfile multi-stage canonique ## syntax=docker/dockerfile:1.9 ARG DOTNET_VERSION=10.0 # --- Stage de build --- FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build WORKDIR /src # Copie uniquement les csproj d\u0026#39;abord, restore, puis copie le reste. # Permet à Docker de cacher le layer restore quand rien dans csproj n\u0026#39;a changé. COPY [\u0026#34;Shop.Api/Shop.Api.csproj\u0026#34;, \u0026#34;Shop.Api/\u0026#34;] COPY [\u0026#34;Shop.Domain/Shop.Domain.csproj\u0026#34;, \u0026#34;Shop.Domain/\u0026#34;] COPY [\u0026#34;Shop.Application/Shop.Application.csproj\u0026#34;, \u0026#34;Shop.Application/\u0026#34;] COPY [\u0026#34;Shop.Infrastructure/Shop.Infrastructure.csproj\u0026#34;, \u0026#34;Shop.Infrastructure/\u0026#34;] RUN dotnet restore \u0026#34;Shop.Api/Shop.Api.csproj\u0026#34; COPY . . WORKDIR /src/Shop.Api RUN dotnet publish \u0026#34;Shop.Api.csproj\u0026#34; \\ --configuration Release \\ --no-restore \\ --output /app/publish \\ /p:UseAppHost=false # --- Stage runtime --- FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled AS final WORKDIR /app # Copie la sortie publiée depuis le stage de build. COPY --from=build /app/publish . # L\u0026#39;utilisateur non-root est déjà positionné par l\u0026#39;image chiseled. EXPOSE 8080 ENV ASPNETCORE_URLS=http://+:8080 \\ ASPNETCORE_ENVIRONMENT=Production \\ DOTNET_RUNNING_IN_CONTAINER=true ENTRYPOINT [\u0026#34;dotnet\u0026#34;, \u0026#34;Shop.Api.dll\u0026#34;] Trois détails font de ce Dockerfile un Dockerfile prêt pour la prod plutôt qu\u0026rsquo;un Dockerfile de tutoriel.\nLe layer cache sur csproj d\u0026rsquo;abord. Copier uniquement les fichiers .csproj avant le reste du code source permet à Docker de sauter l\u0026rsquo;étape (lente) dotnet restore sur les builds suivants quand seul le code applicatif a changé, pas les dépendances. Sur une grosse solution, cela réduit les temps de build d\u0026rsquo;un ordre de grandeur.\nL\u0026rsquo;image de base chiseled. Le suffixe -noble-chiseled fait référence aux images Ubuntu 24.04 \u0026ldquo;Noble\u0026rdquo; chiseled, que Microsoft publie à côté des images runtime complètes. Les images chiseled sont construites avec l\u0026rsquo;outil chisel de Canonical, qui découpe les packages Ubuntu pour n\u0026rsquo;inclure que les fichiers réellement nécessaires. Une image runtime ASP.NET Core chiseled fait environ 100 Mo contre 220 Mo pour l\u0026rsquo;image complète, sans shell, sans gestionnaire de paquets, et avec une surface d\u0026rsquo;attaque plus petite.\nUtilisateur non-root par défaut. Les images chiseled tournent sous un utilisateur non-root ($APP_UID, UID 64198) nativement, ce qui est une posture de sécurité qui nécessitait autrefois une directive USER explicite. Tourner en root à l\u0026rsquo;intérieur d\u0026rsquo;un container est une erreur courante et un vrai risque, et les images chiseled résolvent ce point pour le développeur.\n💡 Info : La liste complète des tags des images de base .NET de Microsoft vit sur mcr.microsoft.com/dotnet/aspnet. Pinner sur une version spécifique (ex. 10.0.0-noble-chiseled) en prod ; utiliser le tag de version majeure (10.0) uniquement en dev.\nZoom : la décision chiseled vs full image #Microsoft publie trois variantes pertinentes de l\u0026rsquo;image runtime ASP.NET Core :\nImage complète (aspnet:10.0) : basée sur Debian, avec un shell, apt, et l\u0026rsquo;userland Linux courant. Environ 220 Mo. À utiliser quand il faut installer des packages supplémentaires au build ou debugger le container avec un shell.\nImage Alpine (aspnet:10.0-alpine) : base Alpine Linux, environ 100 Mo. Plus petite que Debian, utilise musl libc au lieu de glibc. Certaines bibliothèques natives qui présupposent glibc ne fonctionneront pas ; la plupart du code .NET fonctionne. Taille la plus petite pour une image conventionnelle.\nImage chiseled (aspnet:10.0-noble-chiseled) : Ubuntu chiseled, environ 100 Mo, pas de shell, pas de gestionnaire de paquets, non-root par défaut. L\u0026rsquo;option la plus sécurisée et celle vers laquelle la plupart des systèmes de prod devraient tendre par défaut.\nLe compromis, c\u0026rsquo;est la debuggabilité. Une image chiseled n\u0026rsquo;a pas de shell, ce qui veut dire que docker exec -it container bash ne marchera pas. Pour la prod, c\u0026rsquo;est une fonctionnalité, pas un bug : le debug ne devrait pas se faire depuis l\u0026rsquo;intérieur d\u0026rsquo;un container qui tourne, mais via la collecte de logs, de métriques et de traces. Pour le dev local où un shell est réellement nécessaire, basculer temporairement sur l\u0026rsquo;image complète.\n✅ Bonne pratique : Utiliser l\u0026rsquo;image chiseled par défaut et basculer sur l\u0026rsquo;image complète uniquement quand un scénario précis le demande (dépendance native, debug). Ne pas standardiser sur l\u0026rsquo;image complète \u0026ldquo;au cas où\u0026rdquo;.\nZoom : des health checks qui marchent vraiment #Un orchestrateur (Docker Compose, Kubernetes, Azure Container Apps) utilise les health checks pour décider si un container est prêt à recevoir du trafic et s\u0026rsquo;il doit être redémarré. Un health check absent ou cassé, c\u0026rsquo;est comme ça que les équipes découvrent, en prod, que leur rollout \u0026ldquo;zero downtime\u0026rdquo; n\u0026rsquo;en était pas un.\nASP.NET Core fournit un support natif pour les health checks, qui s\u0026rsquo;appaire proprement avec l\u0026rsquo;orchestration de containers :\n// Program.cs builder.Services.AddHealthChecks() .AddCheck(\u0026#34;self\u0026#34;, () =\u0026gt; HealthCheckResult.Healthy()) .AddDbContextCheck\u0026lt;ShopDbContext\u0026gt;(\u0026#34;database\u0026#34;, tags: [\u0026#34;ready\u0026#34;]) .AddCheck\u0026lt;RedisHealthCheck\u0026gt;(\u0026#34;redis\u0026#34;, tags: [\u0026#34;ready\u0026#34;]); app.MapHealthChecks(\u0026#34;/health/live\u0026#34;, new HealthCheckOptions { Predicate = check =\u0026gt; check.Name == \u0026#34;self\u0026#34;, }); app.MapHealthChecks(\u0026#34;/health/ready\u0026#34;, new HealthCheckOptions { Predicate = check =\u0026gt; check.Tags.Contains(\u0026#34;ready\u0026#34;), }); Deux endpoints, deux rôles différents.\n/health/live est le check de liveness. Il répond à \u0026ldquo;le process est-il assez vivant pour répondre en HTTP\u0026rdquo;. S\u0026rsquo;il échoue, l\u0026rsquo;orchestrateur tue et redémarre le container. Il ne doit pas vérifier la connectivité à la base, parce qu\u0026rsquo;une panne transitoire de la base ne doit pas déclencher une tempête de redémarrages de containers.\n/health/ready est le check de readiness. Il répond à \u0026ldquo;cette instance est-elle prête à prendre du trafic\u0026rdquo;. S\u0026rsquo;il échoue, l\u0026rsquo;orchestrateur retire l\u0026rsquo;instance du load balancer jusqu\u0026rsquo;à ce qu\u0026rsquo;elle récupère. Ce check doit vérifier la base et les dépendances de cache, parce qu\u0026rsquo;une instance qui ne peut pas parler à sa base ne doit pas servir de requêtes.\nDans le Dockerfile, ajouter la directive HEALTHCHECK uniquement quand on tourne sur Docker nu ou Docker Compose. Kubernetes ignore la directive du Dockerfile et utilise ses propres livenessProbe et readinessProbe.\nHEALTHCHECK --interval=10s --timeout=2s --start-period=15s --retries=3 \\ CMD curl --fail http://localhost:8080/health/live || exit 1 ⚠️ Ça marche, mais\u0026hellip; : curl n\u0026rsquo;est pas installé dans l\u0026rsquo;image chiseled. Pour des health checks au niveau Dockerfile sur des images chiseled, soit ajouter la capacité de la bibliothèque ASP.NET Core health checks à se vérifier via son propre process, soit basculer l\u0026rsquo;image de base sur une qui contient un outil de health check.\nZoom : gestion des signaux et arrêt gracieux #Quand Docker (ou n\u0026rsquo;importe quel orchestrateur) veut arrêter un container, il envoie SIGTERM au process, attend jusqu\u0026rsquo;à 30 secondes (la période de grâce par défaut), puis envoie SIGKILL si le process n\u0026rsquo;est pas sorti. ASP.NET Core gère SIGTERM correctement nativement : il cesse d\u0026rsquo;accepter de nouvelles connexions, draine les requêtes en vol, flush les logs, et sort proprement. Pour que cela fonctionne, deux détails comptent.\nLe process doit être PID 1 dans le container. La forme ENTRYPOINT [\u0026quot;dotnet\u0026quot;, \u0026quot;Shop.Api.dll\u0026quot;] lance le process directement comme PID 1, ce qui est l\u0026rsquo;objectif. La forme shell (ENTRYPOINT dotnet Shop.Api.dll sans le tableau JSON) le lance comme enfant de /bin/sh, qui ne forwarde pas les signaux et casse l\u0026rsquo;arrêt gracieux.\nLa période de grâce doit être assez longue pour que les requêtes en vol terminent. Pour une API web, les 30 secondes par défaut suffisent en général. Pour des opérations longues (uploads de fichiers, long-polling, connexions WebSocket), configurer l\u0026rsquo;orchestrateur pour donner plus de temps, ou implémenter un disjoncteur qui cesse d\u0026rsquo;accepter les opérations longues bien avant l\u0026rsquo;arrêt.\n// Program.cs : étendre la fenêtre d\u0026#39;arrêt gracieux à 45 secondes builder.Services.Configure\u0026lt;HostOptions\u0026gt;(options =\u0026gt; { options.ShutdownTimeout = TimeSpan.FromSeconds(45); }); Zoom : docker-compose pour le dev local #Un fichier docker-compose est le chemin le plus rapide vers un environnement local réaliste qui reflète les dépendances de prod. Il s\u0026rsquo;appaire particulièrement bien avec les tests d\u0026rsquo;intégration couverts dans l\u0026rsquo;article TestContainers, où des images identiques à la prod tournent à l\u0026rsquo;intérieur du process de test.\nservices: api: build: context: . dockerfile: Shop.Api/Dockerfile environment: ConnectionStrings__Default: \u0026#34;Host=postgres;Database=shop;Username=shop;Password=shop\u0026#34; Redis__Endpoint: \u0026#34;redis:6379\u0026#34; depends_on: postgres: condition: service_healthy redis: condition: service_started ports: - \u0026#34;8080:8080\u0026#34; postgres: image: postgres:17-alpine environment: POSTGRES_DB: shop POSTGRES_USER: shop POSTGRES_PASSWORD: shop volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U shop\u0026#34;] interval: 5s timeout: 5s retries: 5 redis: image: redis:7-alpine volumes: pgdata: Trois détails à connaître. Le depends_on avec condition: service_healthy veut dire que Compose attendra que Postgres passe son health check avant de démarrer l\u0026rsquo;API, évitant la race condition où l\u0026rsquo;application démarre avant que la base ne soit prête. La déclaration volumes: pour pgdata persiste la base entre docker compose up et docker compose down ; utiliser docker compose down -v pour reset. Le ports: \u0026quot;8080:8080\u0026quot; expose l\u0026rsquo;API sur l\u0026rsquo;hôte, ce qui est l\u0026rsquo;objectif en local mais ne doit jamais se retrouver dans un fichier Compose de prod.\nZoom : ce qu\u0026rsquo;il ne faut pas mettre dans l\u0026rsquo;image #Une image container de prod doit contenir uniquement l\u0026rsquo;application et ses dépendances runtime. Les choses qui ne doivent jamais se retrouver dans l\u0026rsquo;image :\nLes secrets. Chaînes de connexion, clés d\u0026rsquo;API, certificats, clés de signature JWT. Ces éléments ont leur place dans des variables d\u0026rsquo;environnement injectées au runtime, ou dans un store de secrets (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets). Les outils de build. Le compilateur, NuGet, les debuggers. Le pattern multi-stage les garde dans le stage de build. Les projets de test et les données de test. Les tests tournent en CI avant que l\u0026rsquo;image ne soit construite ; ils n\u0026rsquo;ont pas leur place dans l\u0026rsquo;image déployée. Les fichiers de configuration de dev. appsettings.Development.json doit être soit exclu, soit copié uniquement dans les images non-prod. Le code source. Le stage runtime doit copier la sortie de publish, pas le code source. Livrer le code source en prod est une erreur courante et une responsabilité de sécurité. ❌ Ne jamais faire : Ne jamais figer des secrets dans l\u0026rsquo;image au build, même comme variables d\u0026rsquo;environnement dans le Dockerfile. Toute personne qui tire l\u0026rsquo;image (y compris un attaquant avec un accès en lecture au registry) peut les récupérer. Les secrets se gèrent au runtime, jamais au build.\nWrap-up #Héberger correctement une application ASP.NET Core sur Docker en 2026, c\u0026rsquo;est un Dockerfile à deux stages avec un layer cache agressif, une image de base chiseled pour la sécurité et la taille, des endpoints de health check liveness et readiness séparés, une gestion des signaux via PID 1 pour un arrêt gracieux, et un fichier docker-compose qui reflète les dépendances de prod pour le dev local. Tu peux livrer une image d\u0026rsquo;environ 100 Mo, tourner en non-root, exposer les bons endpoints de santé pour l\u0026rsquo;orchestrateur qui viendra ensuite, et garder les secrets complètement hors de l\u0026rsquo;image.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core sur IIS : le classique, démystifié Tests d\u0026rsquo;Intégration avec TestContainers pour .NET La Compilation AOT en .NET : démarrage, taille, et compromis Références # Images Docker .NET sur MCR Images Ubuntu chiseled pour .NET, Microsoft Learn Health checks dans ASP.NET Core, Microsoft Learn Spécification Docker Compose Référence Dockerfile ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/hosting-docker/","section":"Posts","summary":"","title":"Héberger ASP.NET Core avec Docker : un guide pragmatique"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va découvrir Azure Container Apps, la plateforme d\u0026rsquo;hébergement container managée qui comble l\u0026rsquo;écart entre Docker nu et un vrai cluster Kubernetes.\nEntre héberger un seul container sur une VM et faire tourner un vrai cluster Kubernetes, il y a un écart dans lequel les équipes tombaient régulièrement. Elles voulaient les garanties de Kubernetes (rolling updates, autoscaling, config déclarative, isolation des charges) sans le poids opérationnel (upgrades de cluster, maintenance d\u0026rsquo;un Ingress Controller, debug de plugins CNI, rotation de certificats). Les plateformes container serverless ont été la réponse, et la version d\u0026rsquo;Azure, Azure Container Apps (ACA), a atteint la disponibilité générale en mai 2022. C\u0026rsquo;est aujourd\u0026rsquo;hui une cible de première classe pour les charges ASP.NET Core qui vivent dans l\u0026rsquo;écosystème Azure.\nCet article couvre ce qu\u0026rsquo;est réellement ACA sous le capot, comment déployer une image ASP.NET Core dessus, et dans quels cas c\u0026rsquo;est le bon choix comparé à Docker nu, à Kubernetes, ou au prochain article de la série, Azure Web App.\nLe contexte : pourquoi Azure Container Apps #Azure Container Apps est une plateforme d\u0026rsquo;hébergement container managée construite au-dessus de composants open source déjà connus : Kubernetes pour l\u0026rsquo;orchestration, KEDA pour l\u0026rsquo;autoscaling, Envoy pour l\u0026rsquo;ingress, Dapr pour la communication service-à-service. Microsoft opère la couche Kubernetes à la place de l\u0026rsquo;utilisateur, expose une surface d\u0026rsquo;API simplifiée, et facture à la seconde d\u0026rsquo;usage. Le résultat est une plateforme qui apporte 80% des capacités de Kubernetes pour environ 20% du coût opérationnel.\nLes avantages concrets qui comptent pour une équipe .NET :\nScale à zéro. Une application inactive ne consomme aucune ressource et ne coûte rien. Quand la première requête arrive, ACA réveille une nouvelle instance en quelques secondes. Couplé à Native AOT, le démarrage à froid devient réellement rapide. Autoscaling événementiel via KEDA. Scaler par nombre de requêtes HTTP, profondeur de file sur Azure Service Bus ou Storage Queues, lag Kafka, métriques Prometheus custom, n\u0026rsquo;importe lequel des 60+ scalers KEDA. Pas juste le CPU. Aucun cluster à gérer. Pas de kubectl, pas de pools de nœuds, pas d\u0026rsquo;upgrades de version, pas d\u0026rsquo;Ingress Controller à maintenir. Azure s\u0026rsquo;occupe de tout. Revisions et partage de trafic. Chaque déploiement crée une nouvelle revision. Le trafic peut être réparti entre revisions (80/20, canary, blue/green) par un seul appel d\u0026rsquo;API, et le rollback se fait en réaffectant le trafic à la revision précédente. Aucune orchestration de rolling update à écrire. Intégration Dapr, optionnelle. Si on veut abstraire les appels service-à-service, la gestion d\u0026rsquo;état, le pub/sub, ou les stores de secrets de leur provider sous-jacent, Dapr est disponible via un flag dans la définition du container app. Pas obligatoire, mais présent si la forme correspond. Vue d\u0026rsquo;ensemble : la hiérarchie ACA # graph TD A[Souscription Azure] --\u003e B[Container Apps Environmentréseau partagé, Log Analytics] B --\u003e C[Container Appshop-api] B --\u003e D[Container Appshop-worker] B --\u003e E[Container Appshop-web] C --\u003e C1[Revision v1.4.60% trafic] C --\u003e C2[Revision v1.4.7100% trafic] C2 --\u003e C2P[Replica 1] C2 --\u003e C2Q[Replica 2] La hiérarchie a trois niveaux qu\u0026rsquo;il faut comprendre avant de toucher au YAML ou aux commandes az.\nContainer Apps Environment est la frontière d\u0026rsquo;isolation. Correspond grossièrement à un namespace Kubernetes avec son propre réseau virtuel, son propre workspace Log Analytics, et son propre domaine d\u0026rsquo;ingress. Les applications dans le même environnement peuvent se parler via le réseau interne ; celles dans des environnements différents ne le peuvent pas. Un setup typique a un environnement par stage (dev, staging, prod) ou un par domaine métier.\nContainer App est l\u0026rsquo;application elle-même. Elle a un nom, une référence d\u0026rsquo;image, des variables d\u0026rsquo;environnement, des références de secrets, une configuration d\u0026rsquo;ingress, et des règles de scaling. On peut la voir comme l\u0026rsquo;équivalent d\u0026rsquo;un Deployment Kubernetes plus Service plus Ingress combinés en une seule ressource.\nRevision est un snapshot immuable de la configuration du Container App. Chaque changement d\u0026rsquo;image ou de configuration marquée comme \u0026ldquo;revision-scoped\u0026rdquo; crée une nouvelle revision. Le trafic entre revisions peut être réparti explicitement, et c\u0026rsquo;est le mécanisme pour les déploiements canary et blue/green.\nReplica est un container qui tourne. ACA décide combien de réplicas chaque revision active a besoin selon les règles de scaling et la charge courante.\nZoom : déployer une image ASP.NET Core #Le chemin le plus simple vers un déploiement qui marche utilise Azure Bicep ou l\u0026rsquo;Azure CLI. Voici un template Bicep minimal :\nparam location string = resourceGroup().location param envName string = \u0026#39;shop-env\u0026#39; param appName string = \u0026#39;shop-api\u0026#39; param imageName string = \u0026#39;myregistry.azurecr.io/shop-api:1.4.7\u0026#39; resource logs \u0026#39;Microsoft.OperationalInsights/workspaces@2023-09-01\u0026#39; = { name: \u0026#39;${envName}-logs\u0026#39; location: location properties: { sku: { name: \u0026#39;PerGB2018\u0026#39; } retentionInDays: 30 } } resource env \u0026#39;Microsoft.App/managedEnvironments@2025-01-01\u0026#39; = { name: envName location: location properties: { appLogsConfiguration: { destination: \u0026#39;log-analytics\u0026#39; logAnalyticsConfiguration: { customerId: logs.properties.customerId sharedKey: logs.listKeys().primarySharedKey } } } } resource app \u0026#39;Microsoft.App/containerApps@2025-01-01\u0026#39; = { name: appName location: location properties: { managedEnvironmentId: env.id configuration: { ingress: { external: true targetPort: 8080 transport: \u0026#39;http\u0026#39; allowInsecure: false } secrets: [ { name: \u0026#39;db-connection\u0026#39; value: \u0026#39;Host=...\u0026#39; } ] } template: { containers: [ { name: \u0026#39;api\u0026#39; image: imageName resources: { cpu: json(\u0026#39;0.5\u0026#39;) memory: \u0026#39;1Gi\u0026#39; } env: [ { name: \u0026#39;ASPNETCORE_ENVIRONMENT\u0026#39;, value: \u0026#39;Production\u0026#39; } { name: \u0026#39;ConnectionStrings__Default\u0026#39;, secretRef: \u0026#39;db-connection\u0026#39; } ] probes: [ { type: \u0026#39;Liveness\u0026#39; httpGet: { path: \u0026#39;/health/live\u0026#39;, port: 8080 } periodSeconds: 10 failureThreshold: 3 } { type: \u0026#39;Readiness\u0026#39; httpGet: { path: \u0026#39;/health/ready\u0026#39;, port: 8080 } periodSeconds: 5 failureThreshold: 3 } ] } ] scale: { minReplicas: 1 maxReplicas: 10 rules: [ { name: \u0026#39;http-scale\u0026#39; http: { metadata: { concurrentRequests: \u0026#39;50\u0026#39; } } } ] } } } } Six détails qui comptent pour un déploiement de prod.\ningress.external: true expose l\u0026rsquo;application à internet en HTTPS avec un certificat géré par Azure sur un sous-domaine *.azurecontainerapps.io. Pour un domaine custom, le lier séparément et configurer un enregistrement CNAME.\ntargetPort: 8080 correspond au port que l\u0026rsquo;application ASP.NET Core écoute à l\u0026rsquo;intérieur du container. Le port HTTP Kestrel par défaut pour mcr.microsoft.com/dotnet/aspnet est 8080 depuis .NET 8, ce que l\u0026rsquo;article Docker recommande.\nLes références secrets gardent les chaînes de connexion hors du template. La value peut venir d\u0026rsquo;un paramètre, de Key Vault via un keyVaultUrl, ou d\u0026rsquo;une autre source. Ne jamais inliner des secrets de prod dans un fichier Bicep commité.\nprobes reflètent les probes liveness et readiness de Kubernetes, avec la même sémantique : la liveness redémarre le replica, la readiness le retire temporairement de l\u0026rsquo;ingress.\nLes règles scale définissent l\u0026rsquo;autoscaling. Ici, l\u0026rsquo;application scale sur les requêtes HTTP concurrentes par replica : si chaque replica détient plus de 50 requêtes concurrentes, ACA en ajoute un. On peut combiner plusieurs règles (concurrence HTTP + profondeur de file + CPU) et ACA prend le max.\nminReplicas: 1 signifie qu\u0026rsquo;au moins un replica tourne toujours, ce qui évite le démarrage à froid. Le passer à 0 pour économiser sur les charges à faible trafic (scale à zéro), en acceptant un démarrage à froid de 2 à 5 secondes sur la première requête après inactivité.\n💡 Info : minReplicas: 0 est la fonctionnalité qui différencie vraiment ACA de Kubernetes. Scaler à zéro signifie qu\u0026rsquo;un environnement de dev inactif coûte quelques centimes par jour. Les charges de prod avec un trafic stable gardent généralement minReplicas: 1 ou plus pour éviter toute latence de démarrage à froid.\nZoom : revisions et partage de trafic #Chaque fois que le tag d\u0026rsquo;image ou la configuration revision-scoped change, ACA crée une nouvelle revision. Par défaut, la nouvelle revision reçoit 100% du trafic et la précédente est désactivée. Pour des déploiements canary ou blue/green, le partage explicite de trafic est un seul appel CLI :\n# Déploie une nouvelle image. Crée la revision shop-api--v147. az containerapp update \\ --name shop-api \\ --resource-group shop-rg \\ --image myregistry.azurecr.io/shop-api:1.4.7 \\ --revision-suffix v147 # Met 10% du trafic sur la nouvelle revision, 90% sur l\u0026#39;ancienne. az containerapp ingress traffic set \\ --name shop-api \\ --resource-group shop-rg \\ --revision-weight shop-api--v146=90 shop-api--v147=10 # Surveille les métriques pendant 15 minutes. Si tout va bien, bascule à 100%. az containerapp ingress traffic set \\ --name shop-api \\ --resource-group shop-rg \\ --revision-weight shop-api--v147=100 Le rollback est l\u0026rsquo;inverse : basculer le trafic vers la revision précédente en une commande. Pas de terminaison de pods, pas de rolling update à attendre, pas de scripts.\n✅ Bonne pratique : Automatiser les bascules de trafic dans le pipeline de déploiement avec un gate d\u0026rsquo;observabilité : répartir 10% vers la nouvelle revision, attendre 10 minutes, comparer le taux d\u0026rsquo;erreur et la latence au baseline (couvert dans l\u0026rsquo;article sur le baseline), et ne passer à 100% que si les métriques tiennent. Revenir automatiquement en arrière sinon.\nZoom : règles de scaling KEDA #Le scaler de concurrence HTTP vu plus haut est le plus simple. Pour les charges pilotées par des files, des topics Kafka, ou des métriques custom, ACA expose la bibliothèque complète des scalers KEDA.\nscale: { minReplicas: 0 maxReplicas: 30 rules: [ { name: \u0026#39;queue-scale\u0026#39; custom: { type: \u0026#39;azure-servicebus\u0026#39; metadata: { queueName: \u0026#39;orders-inbound\u0026#39; messageCount: \u0026#39;5\u0026#39; } auth: [ { secretRef: \u0026#39;servicebus-connection\u0026#39; triggerParameter: \u0026#39;connection\u0026#39; } ] } } ] } Cela scale l\u0026rsquo;application selon la profondeur d\u0026rsquo;une file Azure Service Bus : s\u0026rsquo;il y a plus de 5 messages par replica, ACA ajoute un replica, jusqu\u0026rsquo;à 30 au total. Quand la file se vide, ACA redescend à zéro, et l\u0026rsquo;application cesse de consommer du compute jusqu\u0026rsquo;au prochain message. Pour des charges événementielles, c\u0026rsquo;est une amélioration de coût spectaculaire par rapport à un hébergement always-on.\n⚠️ Ça marche, mais\u0026hellip; : Scale-à-zéro plus charges HTTP produit des démarrages à froid de 2 à 5 secondes pour la première requête après inactivité. Pour des API exposées à l\u0026rsquo;utilisateur, c\u0026rsquo;est généralement inacceptable, et minReplicas doit rester à 1 ou plus. Pour des workers de fond déclenchés par une file, c\u0026rsquo;est acceptable : la file absorbe la latence, et l\u0026rsquo;économie de coût est réelle.\nZoom : configuration et secrets #ACA expose deux endroits pour la configuration. Les variables d\u0026rsquo;environnement classiques pour les valeurs non sensibles, et une section secrets séparée pour tout ce qui est sensible. Les secrets sont référencés par nom depuis la liste des variables d\u0026rsquo;environnement :\nsecrets: [ { name: \u0026#39;db-connection\u0026#39;, keyVaultUrl: \u0026#39;https://shop-kv.vault.azure.net/secrets/db-connection\u0026#39;, identity: \u0026#39;system\u0026#39; } { name: \u0026#39;jwt-key\u0026#39;, keyVaultUrl: \u0026#39;https://shop-kv.vault.azure.net/secrets/jwt-key\u0026#39;, identity: \u0026#39;system\u0026#39; } ] Utiliser keyVaultUrl avec une identité managée assignée par le système est le pattern canonique : les secrets vivent dans Azure Key Vault, ACA les tire au déploiement via son identité, et aucune valeur en clair ne touche jamais le template Bicep. Si le secret dans Key Vault tourne, ACA a besoin d\u0026rsquo;une nouvelle revision pour prendre en compte le changement.\nPour des valeurs qui changent sans un déploiement (feature flags, limites de débit), appairer ACA avec Azure App Configuration et le package Microsoft.Extensions.Configuration.AzureAppConfiguration. L\u0026rsquo;application recharge les valeurs sans redémarrage.\nQuand ACA est le bon choix #Azure Container Apps est le bon hôte pour :\nLes charges container-natives dans Azure qui iraient autrement sur Kubernetes mais n\u0026rsquo;ont pas besoin du contrôle ou de la complexité complète. Les services événementiels (consommateurs de files, workers de fond, processeurs Kafka) qui bénéficient du scale à zéro. Les microservices où on veut que les appels service-à-service, le pub/sub ou la gestion d\u0026rsquo;état soient abstraits via Dapr. Les équipes qui ont de l\u0026rsquo;expertise container mais pas de budget d\u0026rsquo;opérations Kubernetes. Les stratégies de release lourdes en partage de trafic : canary, blue/green, A/B, où le système natif de revisions supprime le besoin d\u0026rsquo;outillage de rollout custom. Ce n\u0026rsquo;est pas le bon choix quand :\nIl faut un contrôle complet sur Kubernetes : CRDs custom, operators, NetworkPolicies, personnalisation cluster-wide. Passer à AKS (l\u0026rsquo;article Kubernetes). On gère une seule petite application web avec un trafic stable sans containers : Azure Web App est encore plus simple et souvent moins cher. L\u0026rsquo;équipe n\u0026rsquo;est pas sur Azure : porter le modèle d\u0026rsquo;ACA vers AWS ou GCP n\u0026rsquo;est pas trivial. Si le multi-cloud est une exigence, Kubernetes est une meilleure couche de portabilité. Le démarrage à froid compte et on ne peut pas se permettre minReplicas: 1 : le démarrage à froid d\u0026rsquo;ACA est de 2 à 5 secondes, excellent pour un worker de file et trop lent pour une API exposée à l\u0026rsquo;utilisateur sans réplicas always-on. Wrap-up #Azure Container Apps apporte les bénéfices de l\u0026rsquo;hébergement container de classe Kubernetes sans le poids opérationnel : revisions, partage de trafic, autoscaling KEDA, ingress avec certificats managés, secrets basés sur Key Vault, et scale-à-zéro pour les charges qui tolèrent le démarrage à froid. Tu peux déployer une image ASP.NET Core avec un template Bicep en un après-midi, la combiner avec du scaling basé sur file pour des workers événementiels, répartir le trafic entre revisions pour des déploiements canary, et reconnaître quand la charge serait mieux servie par Kubernetes ou Azure Web App.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core avec Docker : un guide pragmatique Héberger ASP.NET Core sur Kubernetes : l\u0026rsquo;essentiel pour les devs .NET La Compilation AOT en .NET : démarrage, taille, et compromis Le Spike Testing en .NET : survivre au burst soudain Références # Documentation Azure Container Apps, Microsoft Learn Scaler une application dans Azure Container Apps, Microsoft Learn Gérer les revisions, Microsoft Learn Documentation des scalers KEDA Dapr sur Azure Container Apps, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/hosting-azure-container-apps/","section":"Posts","summary":"","title":"Héberger ASP.NET Core sur Azure Container Apps"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier Azure Web App, l\u0026rsquo;option d\u0026rsquo;hébergement la plus simple pour une application ASP.NET Core sur Azure, et celle qui reste souvent le meilleur choix malgré toutes les alternatives plus modernes.\nToutes les applications .NET n\u0026rsquo;ont pas besoin de containers, de Kubernetes, ou d\u0026rsquo;un service mesh. Un nombre surprenant de charges de prod sont mieux servies par l\u0026rsquo;option d\u0026rsquo;hébergement Azure la plus simple disponible : une Web App sur Azure App Service. C\u0026rsquo;est le défaut pour les équipes .NET Microsoft-first depuis 2012, elle livre nativement HTTPS, deployment slots et autoscaling, et pour une large classe d\u0026rsquo;applications c\u0026rsquo;est la bonne réponse précisément *parce qu\u0026rsquo;*elle est plus simple que les alternatives container-first vues plus tôt dans cette série.\nCet article clôt la série Hosting avec Azure Web App : ce que c\u0026rsquo;est, comment y déployer une application ASP.NET Core, ce que font réellement les fonctionnalités natives, et dans quels cas elle gagne face aux containers sur ACA ou à Kubernetes.\nLe contexte : pourquoi Azure Web App #Azure App Service a été lancé en 2012 et évolue en continu depuis. Web App est sa variante pour les charges HTTP, qui fait tourner des applications ASP.NET, ASP.NET Core, Node.js, Python, Java et PHP sur une infrastructure gérée par Microsoft. Pour .NET en particulier, le support est natif : pas de Dockerfile à écrire, pas d\u0026rsquo;image à construire, pas de registry container à gérer. L\u0026rsquo;application est publiée directement, la plateforme la fait tourner, et toutes les préoccupations habituelles (TLS, scaling, monitoring, authentification) sont disponibles comme des interrupteurs à basculer.\nLes avantages concrets qui comptent encore en 2026 :\nLa simplicité. Une sortie dotnet publish suffit pour déployer. Pas d\u0026rsquo;image container, pas d\u0026rsquo;orchestration, pas de YAML, pas de Dockerfile. Pour une équipe dont la compétence principale est d\u0026rsquo;écrire du .NET, cela correspond au skill set sans en imposer un nouveau. Les deployment slots natifs. Chaque Web App au tier Standard ou plus haut livre avec des slots de staging. Déployer sur un slot, valider, puis swap les slots atomiquement. Le swap est instantané et réversible, ce qui fait des déploiements blue/green une fonctionnalité native plutôt que quelque chose à orchestrer. Certificats TLS managés. Les App Service Managed Certificates sont gratuits, s\u0026rsquo;auto-renouvellent, et se branchent sur le domaine custom en un clic. Pas de cert-manager, pas de cron Let\u0026rsquo;s Encrypt, pas d\u0026rsquo;alertes d\u0026rsquo;expiration. Autoscaling et Always On. Règles de scale-out basées sur le CPU, la mémoire ou des métriques custom. Le réglage Always On empêche le worker de passer en inactif pendant les périodes calmes, ce qui supprime le démarrage à froid qui pénalise les alternatives serverless pour les charges exposées à l\u0026rsquo;utilisateur. Intégration avec l\u0026rsquo;écosystème Azure. Identité managée, références Key Vault, Application Insights, App Configuration, private endpoints, intégration VNet. Tout cela est disponible sous forme d\u0026rsquo;options de configuration, pas de packages à installer. Rien de tout cela n\u0026rsquo;est unique à Web App. Tout est disponible ailleurs. La valeur, c\u0026rsquo;est que tout est au même endroit et accessible sans outillage supplémentaire.\nVue d\u0026rsquo;ensemble : le modèle App Service # graph TD A[App Service Plancompute + tier de prix] --\u003e B[Web Appshop-api] A --\u003e C[Web Appshop-admin] B --\u003e B1[Slot Production] B --\u003e B2[Slot Staging] B2 --\u003e B2D[Déploiement] B1 --\u003e B1T[100% trafic] La hiérarchie est directe et stable depuis une décennie.\nApp Service Plan est la ressource compute sous-jacente : cœurs CPU, mémoire, tier de prix (Basic, Standard, Premium v3, Isolated). Plusieurs Web Apps peuvent partager un seul plan, ce qui est la façon standard d\u0026rsquo;héberger des applications liées sur le même compute sans avoir besoin de lignes de facturation séparées.\nWeb App est l\u0026rsquo;application. Elle a un nom (utilisé dans l\u0026rsquo;URL par défaut \u0026lt;nom\u0026gt;.azurewebsites.net), une stack de runtime (.NET 10 (LTS)), une source de déploiement, des paramètres de configuration, et des fonctionnalités optionnelles (domaines custom, identité, règles de scaling).\nDeployment slot est un clone séparé de la Web App avec sa propre URL, sa propre configuration, et son propre code déployé. Les slots non-production partagent le compute de l\u0026rsquo;App Service Plan mais tournent indépendamment. La valeur vient de la capacité à swapper le contenu des slots atomiquement : déployer sur staging, le chauffer avec quelques requêtes, lancer des smoke tests, puis le faire basculer en production en quelques secondes.\nZoom : déployer une application ASP.NET Core #Les trois chemins de déploiement les plus courants, par ordre de maturité :\n1. Publish profile depuis Visual Studio ou la CLI. Le plus simple pour un seul développeur. dotnet publish produit la sortie, az webapp deploy (ou l\u0026rsquo;assistant de publish de Visual Studio) la pousse. Bon pour des prototypes, pas pour des équipes.\n2. Pipeline GitHub Actions ou Azure DevOps avec azure/webapps-deploy. Le chemin CI standard. Le pipeline build, teste, publie, et déploie, avec un seul workflow YAML.\n# .github/workflows/deploy.yml name: Deploy Shop API on: push: branches: [main] jobs: build-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: { dotnet-version: \u0026#39;10.0.x\u0026#39; } - name: Publish run: dotnet publish Shop.Api/Shop.Api.csproj -c Release -o ./publish - name: Deploy to staging slot uses: azure/webapps-deploy@v3 with: app-name: shop-api slot-name: staging package: ./publish publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_STAGING }} - name: Health check staging run: | curl --fail https://shop-api-staging.azurewebsites.net/health/ready - name: Swap slots uses: azure/CLI@v2 with: inlineScript: | az webapp deployment slot swap \\ --resource-group shop-rg \\ --name shop-api \\ --slot staging \\ --target-slot production Quatre étapes : publier la sortie, déployer sur le slot de staging, vérifier la santé sur le slot de staging, swapper les slots. Le trafic de prod bascule sur la nouvelle version au moment du swap, sans démarrage à froid, parce qu\u0026rsquo;Azure chauffe le slot de staging avant que le swap ne se termine.\n3. Déploiement container. Si l\u0026rsquo;équipe construit déjà des images Docker pour d\u0026rsquo;autres hôtes, Web App peut faire tourner un container custom depuis n\u0026rsquo;importe quel registry. Configurer la Web App pour pointer sur l\u0026rsquo;image, et elle devient un hôte container managé. Cela perd le bénéfice \u0026ldquo;pas de Dockerfile à écrire\u0026rdquo; mais garde les fonctionnalités de slots et de scaling.\n✅ Bonne pratique : Toujours déployer sur un slot de staging d\u0026rsquo;abord et swapper. Le déploiement direct en prod est une habitude des années 2000. Avec les slots, on ne paie essentiellement rien de plus pour une étape de validation pré-prod et la capacité à revenir instantanément en arrière.\nZoom : configuration sans rebuilder #Web App expose sa configuration par trois couches, par ordre de précédence :\nParamètres d\u0026rsquo;application spécifiques au slot. Des variables d\u0026rsquo;environnement définies sur la Web App elle-même, qui deviennent des entrées IConfiguration dans ASP.NET Core. La convention du double underscore mappe sur les clés imbriquées : ConnectionStrings__Default devient ConnectionStrings:Default.\nRéférences Key Vault. Un paramètre d\u0026rsquo;application peut contenir une référence à un secret dans Azure Key Vault, et App Service la résout au démarrage via l\u0026rsquo;identité managée de la Web App. Le vrai secret n\u0026rsquo;apparaît jamais dans un fichier de configuration ou un artefact de déploiement.\nConnectionStrings__Default = @Microsoft.KeyVault(SecretUri=https://shop-kv.vault.azure.net/secrets/db-connection/) Intégration App Configuration via le package Microsoft.Extensions.Configuration.AzureAppConfiguration, pour les valeurs qui doivent se recharger sans redémarrage (feature flags, limites de débit, toggles). Cela s\u0026rsquo;appaire particulièrement bien avec Key Vault pour les valeurs sensibles et App Configuration pour les dynamiques.\n// Program.cs builder.Configuration.AddAzureAppConfiguration(options =\u0026gt; { options.Connect(new Uri(builder.Configuration[\u0026#34;AppConfig:Endpoint\u0026#34;]!), new DefaultAzureCredential()) .ConfigureKeyVault(kv =\u0026gt; kv.SetCredential(new DefaultAzureCredential())) .Select(KeyFilter.Any, LabelFilter.Null) .Select(KeyFilter.Any, builder.Environment.EnvironmentName) .ConfigureRefresh(refresh =\u0026gt; { refresh.Register(\u0026#34;Sentinel\u0026#34;, refreshAll: true) .SetRefreshInterval(TimeSpan.FromSeconds(30)); }); }); 💡 Info : Un paramètre d\u0026rsquo;application \u0026ldquo;spécifique au slot\u0026rdquo; reste avec le slot pendant un swap, tandis qu\u0026rsquo;un paramètre classique swap avec le code. Cette distinction permet de garder ASPNETCORE_ENVIRONMENT=Staging en permanence sur le slot de staging, pour que le même déploiement puisse être testé en mode staging et basculé en mode production simplement en swappant.\nZoom : scaling #Web App offre deux dimensions de scaling :\nScale up change la taille de l\u0026rsquo;App Service Plan (plus de CPU, plus de mémoire). C\u0026rsquo;est une opération qui affecte toutes les Web Apps du plan et prend une à deux minutes. À utiliser quand le tier actuel est trop petit pour la charge de pointe.\nScale out ajoute plus d\u0026rsquo;instances du plan, faisant tourner des copies des mêmes Web Apps en parallèle. Azure load balance le trafic sur les instances automatiquement. Les règles de scale-out peuvent être configurées selon le CPU, la mémoire, la longueur de file, ou des métriques custom, avec des fenêtres de cooldown pour éviter le thrashing.\nresource plan \u0026#39;Microsoft.Web/serverfarms@2024-04-01\u0026#39; = { name: \u0026#39;shop-plan\u0026#39; location: location sku: { name: \u0026#39;P1v3\u0026#39; tier: \u0026#39;PremiumV3\u0026#39; capacity: 2 } kind: \u0026#39;linux\u0026#39; properties: { reserved: true // obligatoire pour Linux } } resource autoscale \u0026#39;Microsoft.Insights/autoscalesettings@2022-10-01\u0026#39; = { name: \u0026#39;shop-plan-autoscale\u0026#39; location: location properties: { targetResourceUri: plan.id enabled: true profiles: [ { name: \u0026#39;default\u0026#39; capacity: { minimum: \u0026#39;2\u0026#39;, maximum: \u0026#39;10\u0026#39;, default: \u0026#39;2\u0026#39; } rules: [ { metricTrigger: { metricName: \u0026#39;CpuPercentage\u0026#39; metricResourceUri: plan.id timeGrain: \u0026#39;PT1M\u0026#39; statistic: \u0026#39;Average\u0026#39; timeWindow: \u0026#39;PT5M\u0026#39; timeAggregation: \u0026#39;Average\u0026#39; operator: \u0026#39;GreaterThan\u0026#39; threshold: 70 } scaleAction: { direction: \u0026#39;Increase\u0026#39; type: \u0026#39;ChangeCount\u0026#39; value: \u0026#39;1\u0026#39; cooldown: \u0026#39;PT5M\u0026#39; } } ] } ] } } Un plan avec 2 instances minimum, 10 maximum, qui ajoute une instance chaque fois que le CPU moyen sur 5 minutes dépasse 70%, avec un cooldown de 5 minutes. C\u0026rsquo;est la forme standard pour autoscaler une Web App à trafic stable.\n⚠️ Ça marche, mais\u0026hellip; : Les règles d\u0026rsquo;autoscale sur Web App sont réactives, pas prédictives. Un burst qui dépasse la capacité en 30 secondes (voir l\u0026rsquo;article sur les spike tests) est plus rapide que la fenêtre de réaction de l\u0026rsquo;autoscaler. Pour des charges à forte composante spike, soit faire tourner un nombre minimum d\u0026rsquo;instances plus élevé, soit accepter la latence mise en file d\u0026rsquo;attente au début de chaque spike.\nZoom : le réglage Always On #Web App endort un worker inactif après 20 minutes d\u0026rsquo;inactivité, exactement comme IIS. C\u0026rsquo;est acceptable pour des sites hobby et pour des environnements de dev, mais pour des charges de prod exposées à l\u0026rsquo;utilisateur cela introduit un démarrage à froid sur chaque première requête après inactivité, ce qui casse les cibles de latence p99.\nLe correctif est un seul toggle :\nGeneral settings → Always On → On Cela garde le worker au chaud en permanence. C\u0026rsquo;est disponible au tier Basic et au-dessus (pas sur Free ou Shared). Pour du trafic de prod, ce réglage doit toujours être activé.\nAppairé à Always On, l\u0026rsquo;endpoint /health/live décrit dans l\u0026rsquo;article Docker permet de configurer le ping de santé d\u0026rsquo;App Service pour taper périodiquement l\u0026rsquo;endpoint, s\u0026rsquo;assurant que l\u0026rsquo;application reste réactive.\nsiteConfig: { alwaysOn: true healthCheckPath: \u0026#39;/health/live\u0026#39; // ... } Zoom : quand Web App est le bon choix #Web App est le bon hôte pour :\nUne seule application web .NET avec un trafic stable. La simplicité rentabilise : une ressource, un chemin de déploiement, un jeu de réglages. Les équipes dont l\u0026rsquo;expertise est .NET, pas les containers ni Kubernetes. Pas de Dockerfile, pas de kubectl, pas de connaissance d\u0026rsquo;orchestration nécessaire. Les applications qui bénéficient des deployment slots : blue/green sans outillage supplémentaire, tests A/B, rollout progressif avec des pourcentages de routage de trafic. Les charges intégrées à l\u0026rsquo;écosystème Microsoft : authentification Entra ID, identité managée, Key Vault, Application Insights. Tout se branche comme options de configuration. Les charges qui ont besoin des fonctionnalités hybrides Azure : intégration VNet, private endpoints, Hybrid Connections pour l\u0026rsquo;intégration on-prem. Ce n\u0026rsquo;est pas le bon choix quand :\nLe déploiement container-natif est une exigence. Si l\u0026rsquo;application est déjà livrée comme image Docker et que l\u0026rsquo;équipe est sur le chemin container-first, Azure Container Apps ou Kubernetes est mieux adapté. La portabilité multi-cloud ou on-prem compte. Web App est une offre Azure-only. La porter ailleurs signifie réécrire la couche d\u0026rsquo;hébergement. La charge est événementielle avec un trafic de base faible. Le scale à zéro n\u0026rsquo;est pas une fonctionnalité de Web App (au-delà du tier gratuit). Azure Functions ou Azure Container Apps avec minReplicas: 0 servent mieux ce pattern. L\u0026rsquo;isolation de charges sur beaucoup de petits services est nécessaire. Faire tourner une Web App par microservice devient vite coûteux et opérationnellement lourd comparé à partager un cluster ou un Container Apps Environment. Wrap-up #Azure Web App est la façon la plus simple de faire tourner une application ASP.NET Core sur Azure en 2026, et pour une part significative des charges c\u0026rsquo;est aussi la meilleure. Tu peux déployer avec un pipeline GitHub Actions en une demi-heure, utiliser les deployment slots pour des swaps zero-downtime, câbler des références Key Vault directement dans la configuration, activer Always On pour une latence prévisible, et configurer un autoscaling basé sur le CPU avec un template Bicep. Tu peux reconnaître quand la charge bénéficierait plus de Kubernetes ou d\u0026rsquo;Azure Container Apps et choisir le bon outil selon la forme du problème plutôt que d\u0026rsquo;appliquer le même pattern d\u0026rsquo;hébergement à tout.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core sur IIS : le classique, démystifié Héberger ASP.NET Core avec Docker : un guide pragmatique Héberger ASP.NET Core sur Kubernetes : l\u0026rsquo;essentiel pour les devs .NET Héberger ASP.NET Core sur Azure Container Apps Références # Vue d\u0026rsquo;ensemble App Service, Microsoft Learn Déployer sur App Service, Microsoft Learn Deployment slots, Microsoft Learn App Service Managed Certificates, Microsoft Learn Autoscale dans Azure Monitor, Microsoft Learn Références Key Vault pour App Service, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/hosting-azure-web-app/","section":"Posts","summary":"","title":"Héberger ASP.NET Core sur Azure Web App"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier l\u0026rsquo;hébergement d\u0026rsquo;ASP.NET Core sur IIS, le grand classique du monde Windows Server qui est encore très présent en 2026, et qu\u0026rsquo;il vaut mieux savoir configurer correctement.\nPendant vingt ans, IIS a été la réponse par défaut à \u0026ldquo;où tourne cette application .NET\u0026rdquo;. System.Web, ASP.NET WebForms, MVC jusqu\u0026rsquo;à la version 5, WCF, WebAPI 2 : tous étaient fortement couplés au pipeline IIS et à HttpRuntime. Quand ASP.NET Core est sorti en 2016, il a été explicitement découplé d\u0026rsquo;IIS : il tournait sur son propre serveur web cross-platform, Kestrel, et IIS est devenu optionnel. Pourtant, dix ans plus tard, IIS reste la cible de prod pour une part significative des boutiques .NET, en général parce qu\u0026rsquo;un Windows Server on-prem existant, une flotte d\u0026rsquo;applications legacy, ou une contrainte de conformité le maintient dans le paysage. Cet article traite de l\u0026rsquo;hébergement d\u0026rsquo;ASP.NET Core sur IIS en 2026 : ce qu\u0026rsquo;IIS fait réellement, ce qu\u0026rsquo;il ne fait pas, et dans quels cas c\u0026rsquo;est encore le bon choix.\nLe contexte : pourquoi IIS est encore dans le paysage #L\u0026rsquo;histoire classique est \u0026ldquo;IIS est legacy, passez aux containers\u0026rdquo;. Cette histoire est à moitié vraie. IIS n\u0026rsquo;est clairement pas l\u0026rsquo;avenir, et les nouveaux projets greenfield y démarrent rarement. Mais trois situations en font le choix pragmatique correct :\nUne infrastructure Windows Server existante avec une équipe d\u0026rsquo;ops qui la maîtrise. Une boîte qui fait tourner cinquante applications .NET sur IIS, avec du monitoring, des pipelines de déploiement et des runbooks construits autour d\u0026rsquo;IIS, ne gagne rien à déplacer une seule application sur une stack complètement différente. Le coût d\u0026rsquo;intégration dépasse le gain marginal de l\u0026rsquo;hébergement. Applications legacy mélangées avec des modernes. Une application ASP.NET Core qui doit cohabiter avec une application ASP.NET WebForms, partager l\u0026rsquo;authentification, partager les certificats SSL, ou répondre sous le même domaine est bien plus facile à héberger sur le même IIS qu\u0026rsquo;à répartir sur deux stratégies d\u0026rsquo;hébergement. Conformité et contraintes de politique. Certains environnements exigent des configurations TLS spécifiques, des fonctionnalités HTTP.sys, de l\u0026rsquo;authentification Windows par Kerberos, ou une intégration à Active Directory qui est significativement plus simple sur IIS que sur Kestrel seul. Aucune de ces raisons ne rend IIS \u0026ldquo;bon\u0026rdquo;. Elles rendent IIS adapté dans son contexte. Le travail consiste à héberger de l\u0026rsquo;ASP.NET Core moderne correctement dessus, pas à faire semblant que la contrainte n\u0026rsquo;existe pas.\nVue d\u0026rsquo;ensemble : ce qu\u0026rsquo;IIS fait réellement # graph LR A[Requête HTTP] --\u003e B[Driver kernel HTTP.sys] B --\u003e C[Process worker IISw3wp.exe] C --\u003e D[ASP.NET Core Moduleaspnetcore v2] D --\u003e E[Application Kestreldotnet.exe] E --\u003e F[Réponse] F --\u003e C C --\u003e B B --\u003e A La pièce clé à comprendre : IIS n\u0026rsquo;est plus le serveur web de l\u0026rsquo;application ASP.NET Core. C\u0026rsquo;est un reverse proxy devant un process Kestrel qui fait tourner l\u0026rsquo;application. Le composant qui fait ce lien est l\u0026rsquo;ASP.NET Core Module (ANCM), un module IIS natif installé avec le .NET Hosting Bundle. ANCM a deux modes, et le choix entre les deux est la décision d\u0026rsquo;hébergement la plus importante sur IIS.\nLe mode in-process (défaut depuis .NET Core 2.2) : ANCM charge le CLR directement à l\u0026rsquo;intérieur du process worker IIS w3wp.exe. Kestrel tourne in-process, et les requêtes atteignent l\u0026rsquo;application via un canal rapide en mémoire. C\u0026rsquo;est à peu près 2 à 3 fois plus rapide que le mode out-of-process, et c\u0026rsquo;est le bon défaut pour la plupart des charges.\nLe mode out-of-process : ANCM lance un process enfant dotnet.exe séparé qui héberge Kestrel sur un port localhost, puis forwarde les requêtes IIS vers lui en HTTP. Plus lent, mais nécessaire quand l\u0026rsquo;application doit s\u0026rsquo;isoler du process worker ou utiliser des fonctionnalités qui ne marchent pas in-process (par exemple certaines formes d\u0026rsquo;interop avec des modules).\n💡 Info : L\u0026rsquo;ASP.NET Core Module s\u0026rsquo;appelait à l\u0026rsquo;origine aspnetcore (v1), puis a été réécrit en aspnetcorev2 dans .NET Core 2.2 pour ajouter l\u0026rsquo;hébergement in-process. Les deux sont installés par le .NET Hosting Bundle ; l\u0026rsquo;application choisit celui qui est actif via son web.config. Les nouveaux projets doivent toujours démarrer en in-process.\nZoom : le web.config minimal #ASP.NET Core sur IIS utilise toujours un fichier web.config, mais il est généré au publish et son seul rôle est de dire à IIS quel module charger et quel exécutable lancer. Pour la plupart des applications, le fichier n\u0026rsquo;a pas besoin d\u0026rsquo;édition manuelle :\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;utf-8\u0026#34;?\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;location path=\u0026#34;.\u0026#34; inheritInChildApplications=\u0026#34;false\u0026#34;\u0026gt; \u0026lt;system.webServer\u0026gt; \u0026lt;handlers\u0026gt; \u0026lt;add name=\u0026#34;aspNetCore\u0026#34; path=\u0026#34;*\u0026#34; verb=\u0026#34;*\u0026#34; modules=\u0026#34;AspNetCoreModuleV2\u0026#34; resourceType=\u0026#34;Unspecified\u0026#34; /\u0026gt; \u0026lt;/handlers\u0026gt; \u0026lt;aspNetCore processPath=\u0026#34;.\\Shop.Api.exe\u0026#34; stdoutLogEnabled=\u0026#34;false\u0026#34; stdoutLogFile=\u0026#34;.\\logs\\stdout\u0026#34; hostingModel=\u0026#34;inprocess\u0026#34; /\u0026gt; \u0026lt;/system.webServer\u0026gt; \u0026lt;/location\u0026gt; \u0026lt;/configuration\u0026gt; L\u0026rsquo;attribut hostingModel=\u0026quot;inprocess\u0026quot; est ce qui active le mode in-process. Le processPath pointe vers l\u0026rsquo;exécutable publié, pas vers dotnet.exe, parce qu\u0026rsquo;un publish self-contained ou framework-dependent produit un petit launcher natif pour Windows.\n✅ Bonne pratique : Ne jamais éditer le web.config généré à la main pour ajouter des variables d\u0026rsquo;environnement ou des flags de démarrage. Utiliser le groupe d\u0026rsquo;items \u0026lt;EnvironmentVariables\u0026gt; dans le csproj au moment du publish, ou les définir sur le pool d\u0026rsquo;applications IIS via le gestionnaire IIS. Un web.config édité à la main sera écrasé au prochain publish.\nZoom : configurer le pool d\u0026rsquo;applications #Un pool d\u0026rsquo;applications IIS est le process worker qui héberge l\u0026rsquo;application. Pour ASP.NET Core, quelques réglages comptent plus que les défauts.\nVersion du CLR .NET : à mettre sur Aucun code managé. C\u0026rsquo;est contre-intuitif mais correct. Le réglage fait référence à l\u0026rsquo;ancien CLR .NET (System.Web), que ASP.NET Core n\u0026rsquo;utilise pas. Le laisser sur \u0026ldquo;v4.0\u0026rdquo; charge du code runtime legacy dans le process worker sans aucune raison.\nMode de pipeline managé : Integrated (le défaut). Le mode Classic est pour les applications legacy qui dépendent du vieux pipeline ISAPI.\nMode et identité du pipeline : l\u0026rsquo;ApplicationPoolIdentity par défaut est en général correct. Pour les applications qui ont besoin d\u0026rsquo;accéder à un partage réseau ou à un SQL Server avec authentification intégrée, passer sur un compte de service de domaine dédié au pool.\nRecyclage : le défaut recycle le pool toutes les 1740 minutes (29 heures). Pour une application ASP.NET Core longue durée qui détient des caches en mémoire, cela force un démarrage à froid chaque jour, ce qui est perturbant. Soit désactiver le recyclage par temps, soit le planifier pendant une fenêtre de trafic faible. Une application ASP.NET Core moderne n\u0026rsquo;a pas le comportement runtime legacy qui rendait les recyclages quotidiens nécessaires sur System.Web.\nIdle timeout : le défaut de 20 minutes arrête le process worker quand aucun trafic n\u0026rsquo;arrive. C\u0026rsquo;est bien pour des applications intranet mais cela produira des premières requêtes lentes après chaque période d\u0026rsquo;inactivité. Pour des applications exposées à internet avec un trafic continu, soit le mettre à 0, soit configurer le module Application Initialization pour garder le process au chaud.\n⚠️ Ça marche, mais\u0026hellip; : Les réglages de recyclage et d\u0026rsquo;idle timeout par défaut ont été conçus pour les charges System.Web des années 2000. Ce sont encore les défauts dans IIS moderne. Une équipe qui publie une application ASP.NET Core sans revoir ces réglages paiera chaque matin la taxe de démarrage à froid décrite dans l\u0026rsquo;article sur les spike tests, et ne comprendra pas pourquoi.\nZoom : déploiement avec Web Deploy #Le mécanisme de déploiement traditionnel pour IIS est Web Deploy (MSDeploy), qui sait comment arrêter le pool, copier les fichiers, et redémarrer proprement. Une release typique depuis un pipeline CI utilise dotnet publish pour produire la sortie, puis msdeploy.exe pour la pousser vers le serveur cible :\ndotnet publish -c Release -r win-x64 --self-contained false -o .\\publish msdeploy.exe ` -verb:sync ` -source:contentPath=.\\publish ` -dest:contentPath=\u0026#34;Default Web Site/shop-api\u0026#34;,computerName=https://iis.internal:8172/msdeploy.axd,userName=deployer,password=...,authType=basic ` -enableRule:AppOffline La règle AppOffline dépose un fichier App_Offline.htm dans la racine pendant le déploiement, ce qui fait qu\u0026rsquo;IIS cesse de forwarder vers l\u0026rsquo;application et affiche une page de maintenance. Sans elle, les requêtes en vol peuvent échouer bruyamment pendant le remplacement des fichiers.\nPour les équipes qui n\u0026rsquo;aiment pas MSDeploy, un simple xcopy ou robocopy vers un dossier partagé suivi de appcmd recycle apppool /apppool.name:ShopApi est aussi une stratégie de déploiement parfaitement valable. Moins sophistiquée mais plus scriptable.\n💡 Info : Le mécanisme App_Offline.htm est un héritage de System.Web, mais ASP.NET Core le respecte toujours. Déposer un fichier nommé exactement App_Offline.htm dans la racine de l\u0026rsquo;application provoque le déchargement du CLR par le module d\u0026rsquo;hébergement et sert le contenu HTML comme une 503.\nZoom : observabilité sur IIS #Les applications ASP.NET Core hébergées sur IIS gardent un accès complet à l\u0026rsquo;observabilité .NET standard : ILogger, OpenTelemetry, Application Insights, et toutes les métriques ou traces que l\u0026rsquo;application émet. Les seules surfaces spécifiques à IIS à connaître sont :\nLes logs IIS sous C:\\inetpub\\logs\\LogFiles\\W3SVC*. Ces fichiers enregistrent chaque requête HTTP au niveau IIS, y compris le status code et le temps de réponse, et sont utiles pour corréler avec les logs applicatifs quand quelque chose tourne mal en amont du code applicatif.\nObservateur d\u0026rsquo;événements → Journaux Windows → Application : l\u0026rsquo;ASP.NET Core Module écrit les erreurs de démarrage et les crashs ici. C\u0026rsquo;est le premier endroit à vérifier quand l\u0026rsquo;application ne démarre pas après un déploiement.\nstdoutLogEnabled dans le web.config : passer temporairement ce réglage à true redirige la sortie console de Kestrel vers un fichier. Ne l\u0026rsquo;activer que pendant un diagnostic et le désactiver après, parce que le fichier n\u0026rsquo;est pas tourné et grandit sans borne.\n// Program.cs : router ILogger vers Event Log pour corréler avec IIS builder.Logging.AddEventLog(new EventLogSettings { SourceName = \u0026#34;Shop.Api\u0026#34;, LogName = \u0026#34;Application\u0026#34; }); Cela écrit des entrées de log visibles dans l\u0026rsquo;Observateur d\u0026rsquo;événements, qu\u0026rsquo;une équipe d\u0026rsquo;ops familière avec IIS peut lire sans avoir besoin d\u0026rsquo;un outil d\u0026rsquo;agrégation de logs séparé. Ce n\u0026rsquo;est pas un remplacement du logging structuré, mais c\u0026rsquo;est un repli pratique.\nZoom : quand IIS n\u0026rsquo;est pas la bonne réponse #IIS n\u0026rsquo;est pas le bon hôte quand :\nLe déploiement container-natif est la cible. Si le pipeline de déploiement livre des images Docker et que l\u0026rsquo;orchestrateur est Kubernetes, Azure Container Apps ou similaire, passer par IIS ajoute un serveur Windows qui n\u0026rsquo;a pas sa place dans le chemin. Le cross-platform est une exigence. Les cibles Linux excluent IIS entièrement. L\u0026rsquo;autoscaling est nécessaire. IIS scale en ajoutant des process workers ou des hôtes workers ; il ne scale pas élastiquement par nombre d\u0026rsquo;instances comme une plateforme container. Pour une charge variable, une plateforme container est mieux adaptée. L\u0026rsquo;équipe est déjà Linux-first. Faire tourner un Windows Server pour une seule application .NET dans un environnement par ailleurs Linux est le type de \u0026ldquo;choix d\u0026rsquo;hébergement\u0026rdquo; le plus cher parce que le coût opérationnel est élevé et la base de compétences est différente. Pour ces cas, les prochains articles de la série couvrent Docker, Kubernetes, Azure Container Apps, et Azure Web App.\nWrap-up #IIS en 2026 est un reverse proxy qui place un process Kestrel derrière lui via l\u0026rsquo;ASP.NET Core Module en mode in-process, et c\u0026rsquo;est un hôte parfaitement raisonnable pour une application ASP.NET Core dans le bon contexte : infrastructure Windows Server existante, mélange d\u0026rsquo;applications legacy, ou contraintes de conformité qui rendent un autre chemin plus coûteux. Tu peux mettre le pool d\u0026rsquo;applications sur \u0026ldquo;Aucun code managé\u0026rdquo;, désactiver le recyclage quotidien legacy, garder l\u0026rsquo;idle timeout raisonnable, déployer avec Web Deploy et la règle App_Offline.htm, et câbler la sortie event log de l\u0026rsquo;ASP.NET Core Module dans le workflow d\u0026rsquo;ops existant.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nRéférences # Héberger ASP.NET Core sur Windows avec IIS, Microsoft Learn Référence ASP.NET Core Module (ANCM), Microsoft Learn Documentation Web Deploy Réglages de recyclage des pools d\u0026rsquo;applications IIS, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/hosting-iis/","section":"Posts","summary":"","title":"Héberger ASP.NET Core sur IIS : le classique, démystifié"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va comprendre l\u0026rsquo;hébergement d\u0026rsquo;ASP.NET Core sur Kubernetes, en se concentrant sur le sous-ensemble de la plateforme qui compte vraiment pour un dev .NET.\nKubernetes est l\u0026rsquo;orchestrateur par défaut des charges container en 2026, et toute boutique .NET sérieuse finit par y héberger au moins une application. La courbe d\u0026rsquo;apprentissage a la réputation d\u0026rsquo;être raide, et c\u0026rsquo;est vrai, mais le sous-ensemble qu\u0026rsquo;un dev .NET doit réellement comprendre pour livrer une application ASP.NET Core est bien plus petit que la surface complète de la plateforme. Cet article couvre exactement ce sous-ensemble : la poignée de primitives (Deployment, Service, Ingress, probes, limites de ressources) qui transforment une image Docker en une charge prête pour la prod sur Kubernetes.\nLe contexte : pourquoi Kubernetes #Kubernetes apporte cinq choses difficiles à construire sur du Docker nu :\nUn état désiré déclaratif. On décrit ce qui doit tourner, et le plan de contrôle garde la réalité en phase avec la description. Pas de scripts, pas de récupération manuelle. Si un pod meurt, un nouveau démarre automatiquement. Le scaling horizontal. On spécifie un nombre de réplicas ou une règle d\u0026rsquo;autoscaler, et le cluster maintient le bon nombre d\u0026rsquo;instances, en les distribuant sur les nœuds. Les rolling updates et les rollbacks. Déployer une nouvelle version remplace les pods un à un sans downtime, et revenir en arrière est une seule commande. La découverte de services et le load balancing. Les pods n\u0026rsquo;ont pas besoin de connaître les IPs des autres. Ils parlent à des services nommés, et le cluster route le trafic vers les instances saines. L\u0026rsquo;isolation des ressources. Chaque pod a des limites CPU et mémoire, imposées par le noyau, pour qu\u0026rsquo;une instance mal élevée ne puisse pas affamer ses voisins. Ce sont les garanties qui justifient le passage d\u0026rsquo;un hôte Docker unique à un orchestrateur. Le coût, c\u0026rsquo;est un nouveau vocabulaire et un nouveau modèle opérationnel, et c\u0026rsquo;est ce que cet article essaie de rendre concret.\nVue d\u0026rsquo;ensemble : les primitives minimales # graph TD A[Deployment] --\u003e B[ReplicaSetgère N pods] B --\u003e C[Pod 1ton container] B --\u003e D[Pod 2] B --\u003e E[Pod 3] F[Service] --\u003e C F --\u003e D F --\u003e E G[Ingress] --\u003e F H[Internet] --\u003e G Pour une API web ASP.NET Core typique, le jeu minimal de ressources Kubernetes est :\nDeployment : déclare ce qu\u0026rsquo;est l\u0026rsquo;application (image container, variables d\u0026rsquo;environnement, probes, limites de ressources) et combien de réplicas doivent tourner. Le Deployment possède un ReplicaSet, qui possède les pods réels.\nService : donne aux pods une IP virtuelle et un nom DNS stables à l\u0026rsquo;intérieur du cluster, et load balance le trafic entre les réplicas sains. Les autres services parlent à l\u0026rsquo;application via le Service, pas aux pods individuels.\nIngress : route le trafic HTTP externe depuis l\u0026rsquo;extérieur du cluster vers le Service. Gère la terminaison TLS, le routing par hôte, et le routing par chemin via un Ingress Controller (NGINX, Traefik, Azure Application Gateway, etc.).\nConfigMap et Secret : externalisent la configuration et les secrets hors de l\u0026rsquo;image. ConfigMaps pour les valeurs non sensibles (niveau de log, feature flags), Secrets pour tout ce qui est sensible (chaînes de connexion, clés d\u0026rsquo;API).\nCes quatre ressources couvrent 80% de ce dont une application .NET sur Kubernetes a besoin. Le reste (HorizontalPodAutoscaler, NetworkPolicy, ServiceAccount, ResourceQuota) est construit par-dessus.\nZoom : le Deployment #apiVersion: apps/v1 kind: Deployment metadata: name: shop-api labels: app: shop-api spec: replicas: 3 selector: matchLabels: app: shop-api strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: shop-api spec: terminationGracePeriodSeconds: 45 containers: - name: api image: myregistry.azurecr.io/shop-api:1.4.7 ports: - containerPort: 8080 name: http env: - name: ASPNETCORE_ENVIRONMENT value: Production - name: ConnectionStrings__Default valueFrom: secretKeyRef: name: shop-api-secrets key: db-connection resources: requests: cpu: 100m memory: 256Mi limits: cpu: 500m memory: 512Mi livenessProbe: httpGet: path: /health/live port: http initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 readinessProbe: httpGet: path: /health/ready port: http initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 3 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true Six détails font de ce Deployment un Deployment prêt pour la prod plutôt qu\u0026rsquo;un Deployment de tutoriel.\nstrategy.rollingUpdate avec maxUnavailable: 0 garantit qu\u0026rsquo;à aucun moment pendant un déploiement le cluster n\u0026rsquo;a moins que le nombre cible de réplicas disponibles. Un nouveau pod est créé en premier (maxSurge: 1), il passe sa readiness probe, puis un ancien pod est terminé. Vrai rollout zero-downtime.\nresources.requests et resources.limits sont tous les deux déclarés. Les requests disent au scheduler combien d\u0026rsquo;espace trouver sur un nœud. Les limits sont le plafond dur imposé par le noyau. Un pod sans limites de ressources peut manger tout le CPU de son nœud, affamer les autres pods, et produire des défaillances en cascade. Un pod sans requests de ressources est planifié n\u0026rsquo;importe où et finit par se battre pour les ressources de façon imprévisible.\nlivenessProbe et readinessProbe s\u0026rsquo;appairent proprement avec les endpoints de health check vus dans l\u0026rsquo;article Docker. La liveness redémarre le pod en cas d\u0026rsquo;échec ; la readiness le retire des endpoints du Service jusqu\u0026rsquo;à récupération. Ne jamais fusionner les deux dans une seule probe, parce que les conséquences d\u0026rsquo;un échec sont différentes.\nterminationGracePeriodSeconds: 45 étend la fenêtre par défaut de 30 secondes pour laisser aux requêtes en vol plus de temps pour terminer. Doit correspondre au HostOptions.ShutdownTimeout configuré dans l\u0026rsquo;application.\nsecurityContext fait tourner le container en non-root avec un système de fichiers racine en lecture seule et sans escalade de privilèges. Les images .NET chiseled tournent déjà en non-root par défaut, mais le déclarer au niveau du pod est une mesure de défense en profondeur qui marche aussi avec les images complètes.\nenv tire les secrets depuis un Secret Kubernetes au lieu de coder en dur les chaînes de connexion. Le Secret est défini séparément et injecté au runtime, pour que le YAML du Deployment puisse être commité en gestion de version sans fuiter les credentials.\n💡 Info : Les requests de ressources Kubernetes pour le CPU sont en \u0026ldquo;millicores\u0026rdquo; (m). 100m veut dire 0,1 de cœur CPU. 500m veut dire un demi cœur. Une API ASP.NET Core typique a besoin de 50 à 200m au repos et de 300 à 500m sous charge, mais seul un test de charge (couvert dans la série load testing) donne les vrais chiffres pour l\u0026rsquo;application.\nZoom : le Service et l\u0026rsquo;Ingress #apiVersion: v1 kind: Service metadata: name: shop-api spec: type: ClusterIP selector: app: shop-api ports: - name: http port: 80 targetPort: http --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: shop-api annotations: nginx.ingress.kubernetes.io/proxy-body-size: \u0026#34;10m\u0026#34; spec: ingressClassName: nginx tls: - hosts: [api.shop.example.com] secretName: shop-api-tls rules: - host: api.shop.example.com http: paths: - path: / pathType: Prefix backend: service: name: shop-api port: name: http Le type de Service est ClusterIP, ce qui veut dire qu\u0026rsquo;il n\u0026rsquo;est joignable que depuis l\u0026rsquo;intérieur du cluster. Le trafic externe passe par l\u0026rsquo;Ingress, qui gère la terminaison TLS avec un certificat stocké dans le Secret shop-api-tls (typiquement géré par cert-manager avec Let\u0026rsquo;s Encrypt).\nL\u0026rsquo;annotation d\u0026rsquo;Ingress proxy-body-size est spécifique à NGINX et augmente la taille maximale d\u0026rsquo;upload du défaut de 1 Mo à 10 Mo. Les annotations comme celle-ci sont la façon principale de configurer le comportement d\u0026rsquo;un ingress controller ; chaque controller a son propre jeu.\n✅ Bonne pratique : Utiliser une seule ressource Ingress par domaine et un seul Service par Deployment. Ne pas essayer d\u0026rsquo;être malin avec des services partagés ou des règles de routing complexes dès le départ. Commencer simple, et n\u0026rsquo;ajouter de la complexité que quand un besoin concret l\u0026rsquo;exige.\nZoom : rolling updates et cycle de vie des pods #Quand une nouvelle version de l\u0026rsquo;application est livrée, un rolling update typique ressemble à ça :\nLe tag de l\u0026rsquo;image dans le Deployment est mis à jour (via kubectl set image, un upgrade Helm, une synchro ArgoCD, ou similaire). Kubernetes crée un nouveau ReplicaSet pour la nouvelle version. Un nouveau pod est créé et démarre. Le container tourne. La readiness probe commence à poller. Une fois qu\u0026rsquo;elle renvoie 200, le pod est ajouté aux endpoints du Service et commence à recevoir du trafic. Un ancien pod est marqué pour terminaison. Il reçoit SIGTERM. ASP.NET Core cesse d\u0026rsquo;accepter de nouvelles connexions, draine les requêtes en vol (dans la limite de la période de grâce), flush les logs, et sort proprement. Kubernetes le retire des endpoints du Service immédiatement et attend que le process sorte. Les étapes 3 et 4 se répètent jusqu\u0026rsquo;à ce que tous les anciens pods soient remplacés. Trois choses peuvent mal tourner, et de l\u0026rsquo;extérieur elles se ressemblent mais ont des causes différentes.\nLe nouveau pod ne passe jamais la readiness. Les anciens pods restent en place, le rollout stagne. En général, cela veut dire que l\u0026rsquo;application ne démarre pas : mauvaise configuration, secret manquant, migration de base qui a échoué. kubectl describe pod et kubectl logs sont les premiers endroits à regarder.\nLe nouveau pod passe la readiness, puis crashe sous trafic. Les liveness probes commencent à échouer, le pod redémarre, et l\u0026rsquo;état CrashLoopBackOff se déclenche. En général, cela veut dire que l\u0026rsquo;application dépend de quelque chose dont elle n\u0026rsquo;avait pas besoin pendant la readiness (par exemple, une API en aval qui n\u0026rsquo;est appelée que sous vrai trafic).\nLes requêtes en vol échouent pendant le rollout. En général, cela veut dire que la période de grâce est trop courte, ou que l\u0026rsquo;application ne gère pas SIGTERM correctement (voir l\u0026rsquo;article Docker sur la gestion des signaux). Les requêtes sont perdues quand l\u0026rsquo;ancien pod sort avant qu\u0026rsquo;elles ne terminent.\n⚠️ Ça marche, mais\u0026hellip; : Le comportement par défaut d\u0026rsquo;ASP.NET Core est de cesser d\u0026rsquo;accepter les connexions sur SIGTERM et de finir les requêtes en attente. Cela marche dans la plupart des cas, mais si l\u0026rsquo;application gère des opérations longues (gros uploads, long-polling, WebSockets), augmenter terminationGracePeriodSeconds et configurer KeepAliveTimeout de Kestrel en conséquence.\nZoom : ConfigMap et Secret #La configuration de prod doit vivre hors de l\u0026rsquo;image container. Kubernetes fournit deux primitives pour ça.\napiVersion: v1 kind: ConfigMap metadata: name: shop-api-config data: Logging__LogLevel__Default: Information FeatureFlags__NewCheckout: \u0026#34;true\u0026#34; AllowedHosts: \u0026#34;api.shop.example.com\u0026#34; --- apiVersion: v1 kind: Secret metadata: name: shop-api-secrets type: Opaque stringData: db-connection: \u0026#34;Host=postgres;Database=shop;Username=shop;Password=secret\u0026#34; jwt-signing-key: \u0026#34;...\u0026#34; ConfigMap pour les valeurs non sensibles, Secret pour les sensibles. La convention du double underscore (__) dans les noms de clés mappe sur la configuration imbriquée d\u0026rsquo;ASP.NET Core : Logging__LogLevel__Default devient Logging:LogLevel:Default dans IConfiguration.\nLes Secrets en YAML clair sont seulement encodés en base64, pas chiffrés. Pour une vraie sécurité, utiliser l\u0026rsquo;un de :\nSealed Secrets (Bitnami) pour commiter des secrets chiffrés dans Git. External Secrets Operator pour tirer les secrets depuis Azure Key Vault, AWS Secrets Manager, HashiCorp Vault au runtime. Kubernetes Secrets avec chiffrement au repos activé sur le cluster (la plupart des offres managées le font par défaut). ❌ Ne jamais faire : Ne pas commiter de YAML Secret en clair dans Git, même dans un repo privé. Le traiter comme un fichier de mots de passe. Utiliser l\u0026rsquo;un des patterns externes de gestion de secrets à la place.\nZoom : autoscaling horizontal #Une fois l\u0026rsquo;application qui tourne avec des comptes de réplicas manuels, ajouter un HorizontalPodAutoscaler laisse Kubernetes ajuster le compte automatiquement selon le CPU ou des métriques custom.\napiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: shop-api spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: shop-api minReplicas: 3 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 behavior: scaleDown: stabilizationWindowSeconds: 300 Le HPA scale le Deployment entre 3 et 20 pods, en visant une utilisation CPU moyenne autour de 70%. Le stabilizationWindowSeconds: 300 sur le scale-down évite le thrashing : le HPA attend 5 minutes de CPU bas avant de retirer un réplica, ce qui évite les oscillations quand la charge est erratique.\nL\u0026rsquo;article sur les spike tests couvre le mode de défaillance où la réaction du HPA est trop lente pour les bursts soudains. Si les spikes sont un vrai sujet pour la charge, soit faire tourner un minReplicas plus élevé, soit passer à de l\u0026rsquo;autoscaling prédictif via des outils comme KEDA.\n💡 Info : KEDA (Kubernetes Event-Driven Autoscaling) est la façon standard communautaire de scaler des charges Kubernetes selon des signaux externes : profondeur de file (RabbitMQ, Azure Service Bus, Kafka), métriques Prometheus, taux de requêtes HTTP, et bien d\u0026rsquo;autres. Pour les charges dont la charge ne corrèle pas avec le CPU, KEDA est en général la bonne réponse.\nQuand Kubernetes est le mauvais outil #Kubernetes est puissant, mais il est aussi lourd opérationnellement. Faire tourner un cluster de qualité prod signifie patcher les nœuds, gérer un Ingress Controller, maintenir l\u0026rsquo;observabilité, gérer la rotation des certificats, et debugger des problèmes qui n\u0026rsquo;existent pas sur des plateformes plus simples. Pour une petite application (un service, peu de trafic, un ou deux devs), ce coût est disproportionné.\nSi la charge correspond à l\u0026rsquo;une de ces formes, une alternative plus légère est souvent meilleure :\nUn seul petit service : Azure Web App ou un simple hôte Docker. Container-natif mais faible tolérance opérationnelle : Azure Container Apps, qui apporte la plupart des bénéfices de Kubernetes sans gérer le cluster. Serverless / événementiel : Azure Functions ou AWS Lambda, surtout couplé à Native AOT vu dans la série performance pour un démarrage à froid rapide. Kubernetes rentabilise quand on a plusieurs services, plusieurs équipes, une charge variable qui bénéficie de l\u0026rsquo;autoscaling, et assez de capacité opérationnelle pour faire tourner le cluster. Pour une seule petite API avec un trafic stable, c\u0026rsquo;est surdimensionné.\nWrap-up #Héberger ASP.NET Core sur Kubernetes se résume à un petit jeu de primitives : un Deployment avec probes, limites de ressources et security context ; un Service pour un routing interne stable ; un Ingress pour le trafic externe avec TLS ; des ConfigMaps et Secrets pour la configuration externalisée ; et optionnellement un HorizontalPodAutoscaler quand la charge varie. Tu peux transformer une image Docker en une charge Kubernetes prête pour la prod en les combinant, tu peux obtenir de vrais rolling updates zero-downtime avec la bonne config de probes et de période de grâce, et tu peux reconnaître quand le coût opérationnel de Kubernetes ne rentabilise pas et qu\u0026rsquo;une plateforme plus simple servirait mieux.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Héberger ASP.NET Core sur IIS : le classique, démystifié Héberger ASP.NET Core avec Docker : un guide pragmatique Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Le Spike Testing en .NET : survivre au burst soudain Références # Concepts Kubernetes, docs officielles Configurer les probes Liveness, Readiness et Startup, docs Kubernetes Horizontal Pod Autoscaler, docs Kubernetes Déployer des applications ASP.NET Core sur Kubernetes, Microsoft Learn Documentation KEDA ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/hosting-kubernetes/","section":"Posts","summary":"","title":"Héberger ASP.NET Core sur Kubernetes : l'essentiel pour les devs .NET"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/hosting/","section":"Categories","summary":"","title":"Hosting"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/hosting/","section":"Tags","summary":"","title":"Hosting"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/iis/","section":"Tags","summary":"","title":"Iis"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/integration/","section":"Tags","summary":"","title":"Integration"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier l\u0026rsquo;architecture N-Couches en .NET.\nC\u0026rsquo;est probablement le premier pattern que tu as croisé dans ta carrière. Il est partout : dans les codebases legacy, les tutos YouTube, les projets d\u0026rsquo;entreprise. Avant de passer à Clean Architecture ou au Vertical Slicing, faut vraiment comprendre celui-là. Pas juste le suivre bêtement, mais savoir pourquoi il existe, où il tient la route, et quand il commence à faire mal.\nUn peu d\u0026rsquo;histoire #Avant de plonger dans la théorie, un petit détour historique s\u0026rsquo;impose. Au début des années 2000, les tutoriels officiels de Microsoft, les templates Visual Studio par défaut (WebForms, puis les premiers scaffoldings MVC) et la documentation de l\u0026rsquo;époque encourageaient activement le mélange des responsabilités. On posait un SqlDataSource directement dans le markup .aspx, on collait la logique métier dans le code-behind d\u0026rsquo;un bouton, et on saupoudrait des appels ADO.NET bruts dans ce qui allait devenir nos contrôleurs. Ça livrait vite, ça faisait de jolies démos, et ça pourrissait tout aussi vite dès que l\u0026rsquo;application dépassait la poignée d\u0026rsquo;écrans. Le N-Couches n\u0026rsquo;est pas tombé d\u0026rsquo;une tour d\u0026rsquo;ivoire théorique : c\u0026rsquo;est la réponse pragmatique de la communauté à ce bazar, une façon de tracer des frontières nettes entre ce que voit l\u0026rsquo;utilisateur, ce que décide le métier, et ce que stocke la base. Garder cette origine en tête rend la suite de l\u0026rsquo;article beaucoup plus limpide.\nLe contexte : pourquoi ça existe #Imaginons que nous ayons un projet à rejoindre. Le codebase, c\u0026rsquo;est un seul projet Web. Les controllers font des requêtes directement en base. La logique métier vit dans des if imbriqués dans les action methods. Un bug dans le calcul de facturation t\u0026rsquo;oblige à toucher le même fichier qui génère le HTML de la facture. Un nouveau dev casse la logique de paiement en corrigeant un label dans l\u0026rsquo;UI.\nC\u0026rsquo;est le spaghetti classique. L\u0026rsquo;architecture N-Couches existe précisément pour éviter ça.\nLe principe : découper les responsabilités en couches horizontales qui ne peuvent parler qu\u0026rsquo;à la couche directement en dessous. Chaque couche a un seul job.\nCe que ça t\u0026rsquo;apporte :\nSéparation des responsabilités, l\u0026rsquo;UI ne touche jamais la base directement Testabilité, la logique métier est isolée, testable sans lancer un serveur HTTP Remplaçabilité, tu peux swapper Entity Framework pour Dapper sans toucher ta couche service Vue d\u0026rsquo;ensemble : les briques #Avant de rentrer dans le code, voici les grandes briques de l\u0026rsquo;architecture N-Couches :\ngraph TD A[Couche PrésentationControllers / Minimal API] --\u003e B[Couche ServiceLogique Métier] B --\u003e C[Couche RepositoryAccès aux Données] C --\u003e D[Base de DonnéesSQL Server / PostgreSQL] E[Domain / ModelsEntités + DTOs] -.-\u003e A E -.-\u003e B E -.-\u003e C Couche Responsabilité Contenu typique Présentation HTTP, mapping DTOs, réponses Controllers, Minimal API Service Règles métier OrderService, InvoiceService Repository Abstraction de l\u0026rsquo;accès aux données IOrderRepository, impl EF Core / Dapper Domain/Models Contrats partagés Entités, DTOs, Enums, Interfaces Zoom technique : brique par brique #Domain / Models : le contrat partagé #C\u0026rsquo;est pas vraiment une \u0026ldquo;couche\u0026rdquo; au sens strict : c\u0026rsquo;est un projet partagé que tout le monde référence. Garde-le léger : entités, DTOs, enums, et les interfaces de repositories/services.\n// Domain/Entities/Order.cs public class Order { public Guid Id { get; init; } public string CustomerId { get; init; } = default!; public List\u0026lt;OrderLine\u0026gt; Lines { get; init; } = new(); public OrderStatus Status { get; init; } public decimal Total =\u0026gt; Lines.Sum(l =\u0026gt; l.Quantity * l.UnitPrice); } // Domain/DTOs/CreateOrderRequest.cs public record CreateOrderRequest( string CustomerId, List\u0026lt;OrderLineDto\u0026gt; Lines ); DTO vs Entity : tracer la frontière au bon endroit #Un DTO (Data Transfer Object) et une Entity se ressemblent sur un diagramme UML, mais ils vivent deux vies complètement différentes. Une Entity, c\u0026rsquo;est la forme qui intéresse la couche de persistance : elle reflète le schéma de la base, elle est suivie par le change tracker d\u0026rsquo;EF Core, elle porte des propriétés de navigation, et son cycle de vie est attaché à un DbContext. Un DTO, c\u0026rsquo;est la forme qui intéresse le contrat de ton API : un payload plat, sérialisable, spécifique à un cas d\u0026rsquo;usage, qui traverse la frontière HTTP et rien d\u0026rsquo;autre. Parfois les mêmes champs, jamais le même rôle.\nRenvoyer directement des entités EF Core depuis tes contrôleurs ressemble à un raccourci. En réalité, c\u0026rsquo;est quatre bugs qui t\u0026rsquo;attendent au tournant :\nOver-posting : un client POSTe {\u0026quot;id\u0026quot;: 42, \u0026quot;isAdmin\u0026quot;: true, \u0026quot;total\u0026quot;: 0} et le model binder remplit gentiment des champs auxquels l\u0026rsquo;appelant ne devrait jamais avoir accès. Sérialisation et lazy-loading : le sérialiseur JSON parcourt une propriété de navigation, déclenche une requête en dehors du scope d\u0026rsquo;origine, et tu récoltes soit une ObjectDisposedException, soit un joli festival de N+1 en production. Fuite de schéma : chaque colonne ajoutée en base devient instantanément une partie de ton API publique. Tu renommes un champ côté DB, tu casses tous les clients. Enfer du versioning : tu ne peux plus faire évoluer l\u0026rsquo;entité et le contrat indépendamment. Un simple refactor côté données devient un breaking change d\u0026rsquo;API. La solution est ennuyeuse et terriblement efficace : accepter un DTO de requête, le mapper vers une entité dans le service, persister, puis remapper le résultat vers un DTO de réponse.\nflowchart LR Client([Client HTTP]) --\u003e|POST /orders| Req[CreateOrderRequestDTO] Req --\u003e Ctrl[Controller] Ctrl --\u003e Svc[OrderService] Svc --\u003e|map| Ent[OrderEntity] Ent --\u003e Repo[OrderRepository] Repo --\u003e DB[(Base de données)] DB --\u003e Ent2[OrderEntity] Ent2 --\u003e|map| Res[OrderResponseDTO] Res --\u003e Ctrl Ctrl --\u003e|200 OK| Client subgraph Frontiere_API[Frontière API: DTO uniquement] Req Res end subgraph Domaine[Domaine et persistance: entités uniquement] Ent Repo DB Ent2 end style Req fill:#d4f1d4,stroke:#2a7a2a style Res fill:#d4f1d4,stroke:#2a7a2a style Ent fill:#f1d4d4,stroke:#7a2a2a style Ent2 fill:#f1d4d4,stroke:#7a2a2a Mapping explicite, sans AutoMapper, sans magie de réflexion :\npublic sealed record OrderResponse( Guid Id, string CustomerEmail, decimal Total, string Status, DateTime CreatedAt, IReadOnlyList\u0026lt;OrderLineResponse\u0026gt; Lines); public sealed record OrderLineResponse( string Sku, int Quantity, decimal UnitPrice); internal static class OrderMappings { public static OrderResponse ToResponse(this Order order) =\u0026gt; new( Id: order.Id, CustomerEmail: order.Customer.Email, Total: order.Lines.Sum(l =\u0026gt; l.Quantity * l.UnitPrice), Status: order.Status.ToString(), CreatedAt: order.CreatedAt, Lines: order.Lines .Select(l =\u0026gt; new OrderLineResponse(l.Sku, l.Quantity, l.UnitPrice)) .ToList()); } ✅ Bonne pratique : garde un DTO par cas d\u0026rsquo;usage, pas un DTO par entité. CreateOrderRequest, UpdateOrderStatusRequest, OrderSummaryResponse et OrderDetailsResponse sont quatre petits types précis, qui révèlent l\u0026rsquo;intention. Un gros OrderDto générique réutilisé dans six endpoints finit toujours avec la moitié de ses champs nullables, l\u0026rsquo;autre moitié ignorée, et un commentaire du genre \u0026ldquo;ne pas remplir ce champ quand on appelle X\u0026rdquo;. Ça, ce n\u0026rsquo;est plus un DTO, c\u0026rsquo;est un piège.\nCouche Repository : l\u0026rsquo;accès aux données, rien d\u0026rsquo;autre #Le pattern repository encapsule ta technologie d\u0026rsquo;accès aux données. L\u0026rsquo;interface vit dans Domain, l\u0026rsquo;implémentation vit dans Infrastructure.\n// Domain/Interfaces/IOrderRepository.cs public interface IOrderRepository { Task\u0026lt;Order?\u0026gt; GetByIdAsync(Guid id, CancellationToken ct = default); Task\u0026lt;IReadOnlyList\u0026lt;Order\u0026gt;\u0026gt; GetByCustomerAsync(string customerId, CancellationToken ct = default); Task AddAsync(Order order, CancellationToken ct = default); Task SaveChangesAsync(CancellationToken ct = default); } // Infrastructure/Repositories/OrderRepository.cs public class OrderRepository : IOrderRepository { private readonly AppDbContext _db; public OrderRepository(AppDbContext db) =\u0026gt; _db = db; public async Task\u0026lt;Order?\u0026gt; GetByIdAsync(Guid id, CancellationToken ct = default) =\u0026gt; await _db.Orders .AsNoTracking() .Include(o =\u0026gt; o.Lines) .FirstOrDefaultAsync(o =\u0026gt; o.Id == id, ct); public async Task AddAsync(Order order, CancellationToken ct = default) =\u0026gt; await _db.Orders.AddAsync(order, ct); public Task SaveChangesAsync(CancellationToken ct = default) =\u0026gt; _db.SaveChangesAsync(ct); } ✅ Bonne pratique : Utilise toujours AsNoTracking() pour les requêtes en lecture seule. EF Core ne suivra pas l\u0026rsquo;entité dans le change tracker, ce qui réduit la consommation mémoire et accélère les lectures.\n❌ Ne jamais faire : N\u0026rsquo;expose pas IQueryable\u0026lt;T\u0026gt; depuis ton interface de repository. Ça fait remonter l\u0026rsquo;abstraction EF Core dans ta couche service et crée un couplage fort que tu regretteras lors de ta prochaine migration de framework.\nCouche Service : c\u0026rsquo;est ici que vit la logique métier #C\u0026rsquo;est ici que tes règles métier habitent. Pas dans les controllers, pas dans les repositories. Le service reçoit une demande, valide, applique les règles, appelle le repository, retourne un résultat.\n// Application/Services/OrderService.cs public class OrderService { private readonly IOrderRepository _orders; private readonly ILogger\u0026lt;OrderService\u0026gt; _logger; public OrderService(IOrderRepository orders, ILogger\u0026lt;OrderService\u0026gt; logger) { _orders = orders; _logger = logger; } public async Task\u0026lt;Guid\u0026gt; CreateOrderAsync( CreateOrderRequest request, CancellationToken ct = default) { if (!request.Lines.Any()) throw new ValidationException(\u0026#34;Une commande doit avoir au moins une ligne.\u0026#34;); var order = new Order { Id = Guid.NewGuid(), CustomerId = request.CustomerId, Status = OrderStatus.Pending, Lines = request.Lines.Select(l =\u0026gt; new OrderLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = l.UnitPrice }).ToList() }; await _orders.AddAsync(order, ct); await _orders.SaveChangesAsync(ct); _logger.LogInformation(\u0026#34;Commande {OrderId} créée pour le client {CustomerId}\u0026#34;, order.Id, order.CustomerId); return order.Id; } } ⚠️ Ça marche, mais\u0026hellip; : Lancer une ValidationException directement dans le service, c\u0026rsquo;est acceptable pour les cas simples. Dans une codebase plus large, envisage le Result pattern pour éviter d\u0026rsquo;utiliser les exceptions comme mécanisme de contrôle de flux. Voir la série Error Handling pour aller plus loin.\nCouche Présentation : des controllers fins, c\u0026rsquo;est tout #Les controllers doivent être fins. Leur seul job : recevoir la requête HTTP, appeler le service, mapper le résultat en réponse HTTP. Zéro logique métier ici.\n// Api/Controllers/OrdersController.cs [ApiController] [Route(\u0026#34;api/[controller]\u0026#34;)] public class OrdersController : ControllerBase { private readonly OrderService _orderService; public OrdersController(OrderService orderService) =\u0026gt; _orderService = orderService; [HttpPost] [ProducesResponseType(typeof(CreateOrderResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task\u0026lt;IActionResult\u0026gt; CreateOrder( [FromBody] CreateOrderRequest request, CancellationToken ct) { var orderId = await _orderService.CreateOrderAsync(request, ct); return CreatedAtAction(nameof(GetOrder), new { id = orderId }, new CreateOrderResponse(orderId)); } [HttpGet(\u0026#34;{id:guid}\u0026#34;)] public async Task\u0026lt;IActionResult\u0026gt; GetOrder(Guid id, CancellationToken ct) { var order = await _orderService.GetOrderAsync(id, ct); return order is null ? NotFound() : Ok(order); } } ✅ Bonne pratique : Utilise CancellationToken dans chaque action async et passe-le jusqu\u0026rsquo;à la couche base de données. Quand un utilisateur annule sa requête ou qu\u0026rsquo;un load balancer timeout, EF Core annulera la requête SQL plutôt que de la laisser tourner pour rien.\nStructure de la solution #MyApp.sln ├── src/ │ ├── MyApp.Api/ ← Présentation (Controllers, Program.cs, DI) │ ├── MyApp.Application/ ← Services (logique métier) │ ├── MyApp.Infrastructure/ ← Repositories, EF Core, intégrations externes │ └── MyApp.Domain/ ← Entités, DTOs, Interfaces (aucune dépendance) └── tests/ ├── MyApp.Application.Tests/ └── MyApp.Infrastructure.Tests/ 💡 Info : MyApp.Domain doit avoir zéro dépendance NuGet externe. Si nous nous retrouvons à ajouter EF Core ou n\u0026rsquo;importe quel framework dans Domain, c\u0026rsquo;est que le sens de tes dépendances est mauvais.\nOù l\u0026rsquo;architecture N-Couches commence à te freiner #Ce pattern fonctionne très bien pour les applications petites à moyennes. Il commence à montrer ses limites quand le codebase grossit :\nServices anémiques : nous nous retrouvons avec un ProductService qui a 25 méthodes, une par cas d\u0026rsquo;usage. Impossible à naviguer. Repositories obèses : ils accumulent des méthodes de requêtes custom jusqu\u0026rsquo;à devenir ingérables. Couplage inter-fonctionnalités : ajouter une nouvelle feature oblige à toucher chaque couche à chaque fois. C\u0026rsquo;est pas une raison de fuir le N-Couches, c\u0026rsquo;est un signal d\u0026rsquo;évolution. La suite naturelle, c\u0026rsquo;est la Clean Architecture (qui enforce la direction des dépendances) ou le Vertical Slicing (qui organise par feature plutôt que par couche).\nWrap-up #Tu sais maintenant ce qu\u0026rsquo;est l\u0026rsquo;architecture N-Couches, comment chaque couche s\u0026rsquo;articule avec les autres, et comment l\u0026rsquo;implémenter correctement dans une vraie solution .NET. Tu peux structurer un nouveau projet depuis zéro, garder tes controllers fins, isoler la logique métier dans les services, et abstraire l\u0026rsquo;accès aux données derrière des interfaces de repository.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nRéférences # Architectures d\u0026rsquo;applications web courantes, Microsoft Learn Style d\u0026rsquo;architecture N-Tier, Azure Architecture Center Fondamentaux ASP.NET Core, Microsoft Learn Entity Framework Core, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/code-structure-n-layered/","section":"Posts","summary":"","title":"L'architecture N-Couches en .NET : les fondations que tu dois maîtriser"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va comprendre la compilation AOT en .NET, ce qu\u0026rsquo;elle apporte, ce qu\u0026rsquo;elle coûte, et dans quels cas elle est le bon outil.\nPendant vingt ans, .NET a tourné via un compilateur JIT. Le code managé était chargé, vérifié, compilé en instructions machine au premier appel, et exécuté. Le JIT produisait d\u0026rsquo;excellentes performances en régime stable parce qu\u0026rsquo;il pouvait observer le comportement réel à l\u0026rsquo;exécution et optimiser en conséquence. Le prix était payé au démarrage : un process froid passait ses premières secondes à compiler les chemins chauds avant d\u0026rsquo;atteindre sa vitesse de croisière, et le runtime lui-même devait être livré dans chaque déploiement. Pour un serveur web qui tourne longtemps, c\u0026rsquo;était une taxe négligeable. Pour une fonction serverless invoquée une fois puis détruite, un outil CLI, un container qui scale depuis zéro, ou n\u0026rsquo;importe quelle charge où le démarrage est le coût dominant, c\u0026rsquo;était un problème.\nNative AOT dans .NET 7 (2022) a résolu ce problème en produisant un binaire natif, entièrement compilé et autonome, au moment du build, sans JIT à l\u0026rsquo;exécution. Le résultat : une application .NET qui démarre en quelques dizaines de millisecondes, prend moins de mémoire, et se livre sans runtime. Les contraintes, elles, sont significatives, et une équipe qui adopte Native AOT sans les comprendre découvrira à ses dépens que les bibliothèques lourdes en réflexion, la génération de code dynamique, et certaines parties de la BCL ne fonctionnent pas de la même façon.\nCet article couvre les deux côtés : ce que l\u0026rsquo;AOT apporte, ce qu\u0026rsquo;il coûte, quand l\u0026rsquo;utiliser, et quand les techniques zero-allocation ou le JIT classique sont le meilleur choix.\nLe contexte : pourquoi l\u0026rsquo;AOT existe #Le problème que l\u0026rsquo;AOT résout n\u0026rsquo;est pas le débit brut. Sur les charges longues, un process JIT chaud est souvent plus rapide qu\u0026rsquo;un équivalent compilé AOT, parce que le JIT a de l\u0026rsquo;optimisation guidée par profil, de la compilation à plusieurs étages, et de l\u0026rsquo;inlining de méthodes informé par les vrais patterns d\u0026rsquo;appel. Ce que l\u0026rsquo;AOT résout, c\u0026rsquo;est la forme de la courbe de latence au démarrage du process, et la taille de ce qui est livré dans le container.\nTrois problèmes concrets motivent l\u0026rsquo;adoption de l\u0026rsquo;AOT :\nLa latence de démarrage à froid. Une application web .NET 10 standard met plusieurs centaines de millisecondes à démarrer, même avec ReadyToRun. Une version Native AOT de la même application démarre en 30 à 80 millisecondes. Pour une Lambda, une Azure Function, un pod Kubernetes qui scale depuis zéro, ou un outil CLI qui tourne et se termine, cette différence fait tout. La taille du binaire. Une application .NET self-contained est livrée avec un runtime qui pèse 70 à 100 Mo après trimming. Un binaire Native AOT pour la même application peut peser 10 à 20 Mo. Dans un registry qui héberge 500 images, ou un pipeline de CI qui tire des images des centaines de fois par jour, la différence s\u0026rsquo;accumule vite. L\u0026rsquo;empreinte mémoire. Un process JIT a besoin de mémoire pour le runtime, le compilateur lui-même, et le code compilé. Un process AOT n\u0026rsquo;en a besoin pour rien de tout ça. Le working set de pointe baisse de 30 à 50% sur des charges typiques. Il y a aussi une quatrième raison, moins discutée : la simplicité de déploiement. Un binaire natif est un seul fichier qui tourne sur l\u0026rsquo;OS cible sans aucun runtime installé. Plus de \u0026ldquo;c\u0026rsquo;est la bonne version de .NET ?\u0026rdquo;, plus de \u0026ldquo;l\u0026rsquo;image de base a été patchée ?\u0026rdquo;, plus de \u0026ldquo;pourquoi ça marche sur ma machine et pas sur le serveur ?\u0026rdquo;. Ça tourne ou ça ne tourne pas, et si ça tourne une fois, ça tourne partout où l\u0026rsquo;OS et l\u0026rsquo;architecture sont partagés.\nVue d\u0026rsquo;ensemble : le paysage AOT # graph TD A[Options de compilation .NET] --\u003e B[JITdéfaut] A --\u003e C[ReadyToRundepuis .NET Core 3.0] A --\u003e D[Native AOTdepuis .NET 7] B --\u003e B1[Meilleure perf en régime stableDémarrage le plus lent] C --\u003e C1[Méthodes pré-JITRuntime encore nécessaire~30% plus rapide au démarrage] D --\u003e D1[Pas de runtimeTaille minimaleDémarrage le plus rapideLimites de réflexion] Trois modèles de compilation sont disponibles en .NET 10, chacun avec un compromis différent.\nJIT est le défaut et le bon choix pour la grande majorité des charges : serveurs web qui restent up des heures ou des jours, workers de fond, tout ce qui fait que la performance en régime stable compte plus que le démarrage. Le JIT a l\u0026rsquo;optimisation guidée par profil (PGO) depuis .NET 6, la compilation à plusieurs étages depuis .NET Core 2.1, et produit du code qui, dans beaucoup de benchmarks, bat l\u0026rsquo;équivalent AOT après warmup.\nReadyToRun (R2R) est le compromis intermédiaire. Il pré-compile les méthodes en code natif au build, puis utilise quand même le JIT à l\u0026rsquo;exécution pour la ré-optimisation et pour les méthodes qui n\u0026rsquo;ont pas été pré-compilées. R2R est disponible depuis .NET Core 3.0 et s\u0026rsquo;active en ajoutant \u0026lt;PublishReadyToRun\u0026gt;true\u0026lt;/PublishReadyToRun\u0026gt; au csproj. Il réduit la latence de démarrage d\u0026rsquo;environ 30% avec un minimum d\u0026rsquo;autres changements. C\u0026rsquo;est le premier pas à faible risque pour les équipes dont le démarrage est pénible mais qui ne peuvent pas se permettre les contraintes AOT.\nNative AOT est l\u0026rsquo;engagement complet. Il produit un seul binaire natif sans runtime, sans JIT, et sans génération de code dynamique. Le coût est un jeu de contraintes (couvertes plus bas) qui exclut des catégories entières de bibliothèques. Le bénéfice est le démarrage le plus rapide, la taille minimale, et l\u0026rsquo;empreinte mémoire la plus basse que .NET puisse produire.\n💡 Info : Native AOT a été publié dans .NET 7 (2022) comme fonctionnalité supportée pour les minimal APIs ASP.NET Core, les workers, et les applications console. Le support s\u0026rsquo;est étendu dans .NET 8, 9, et 10 à plus de bibliothèques et de scénarios. Dans .NET 10, la plupart des middlewares courants d\u0026rsquo;ASP.NET Core et System.Text.Json fonctionnent correctement sous AOT avec les source generators.\nZoom : activer Native AOT #Passer une minimal API en Native AOT, c\u0026rsquo;est un changement de deux lignes dans le csproj et quelques ajustements de code :\n\u0026lt;Project Sdk=\u0026#34;Microsoft.NET.Sdk.Web\u0026#34;\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;TargetFramework\u0026gt;net10.0\u0026lt;/TargetFramework\u0026gt; \u0026lt;PublishAot\u0026gt;true\u0026lt;/PublishAot\u0026gt; \u0026lt;InvariantGlobalization\u0026gt;true\u0026lt;/InvariantGlobalization\u0026gt; \u0026lt;/PropertyGroup\u0026gt; \u0026lt;/Project\u0026gt; // Program.cs using System.Text.Json.Serialization; var builder = WebApplication.CreateSlimBuilder(args); builder.Services.ConfigureHttpJsonOptions(options =\u0026gt; { options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default); }); var app = builder.Build(); app.MapGet(\u0026#34;/products/{id:int}\u0026#34;, (int id) =\u0026gt; new Product(id, \u0026#34;SKU-42\u0026#34;, 19.99m)); app.Run(); public record Product(int Id, string Sku, decimal Price); [JsonSerializable(typeof(Product))] internal partial class AppJsonContext : JsonSerializerContext { } Deux choses à noter. CreateSlimBuilder remplace CreateBuilder et produit un hôte trimmer-friendly sans toute la stack MVC. JsonSerializerContext remplace la sérialisation JSON basée sur la réflexion runtime par un source generator au moment de la compilation, ce qui est l\u0026rsquo;approche standard compatible AOT.\ndotnet publish -c Release -r linux-x64 La sortie est un seul binaire dans bin/Release/net10.0/linux-x64/publish/, pesant typiquement 12 à 18 Mo, sans runtime nécessaire pour le lancer.\n✅ Bonne pratique : Utiliser CreateSlimBuilder dès le jour 1 si l\u0026rsquo;AOT est dans la roadmap. Cela rend la migration progressive au lieu d\u0026rsquo;un seul flip douloureux, et pousse l\u0026rsquo;équipe vers des patterns qui fonctionnent à la fois en JIT et en AOT.\nZoom : les contraintes #C\u0026rsquo;est la section qu\u0026rsquo;on saute souvent dans le marketing AOT. Les compromis sont réels et ils disqualifient l\u0026rsquo;AOT pour une portion significative des codebases du monde réel.\nPas de réflexion runtime sur des types arbitraires. Le linker (IL trimmer) supprime le code dont il ne peut pas prouver statiquement qu\u0026rsquo;il est utilisé. Toute bibliothèque qui utilise Type.GetType(string), Activator.CreateInstance(type), ou Assembly.Load au runtime sur des types que le linker n\u0026rsquo;a pas vus au build échouera. Cela touche beaucoup de bibliothèques populaires : les anciennes versions d\u0026rsquo;AutoMapper, certains conteneurs IoC avec découverte de types runtime, certaines bibliothèques de sérialisation. Les versions plus récentes de la plupart ont adopté les source generators, mais la migration n\u0026rsquo;est pas complète dans tout l\u0026rsquo;écosystème.\nPas de génération de code dynamique. System.Reflection.Emit, Expression.Compile(), et toute bibliothèque construite par-dessus (proxies dynamiques, certains ORM, certains frameworks de mocking) ne fonctionnent pas sous AOT. Entity Framework Core a le support AOT depuis .NET 8+, avec des limitations sur certaines formes de requêtes. Moq et NSubstitute ne fonctionnent pas, parce qu\u0026rsquo;ils reposent sur la génération de proxies au runtime.\nSource generators pour JSON et autres. La sérialisation System.Text.Json par réflexion ne fonctionne pas sous AOT. La sérialisation par source generator fonctionne. Pareil pour Regex (utiliser le regex généré via l\u0026rsquo;attribut [GeneratedRegex]) et pour le logging (utiliser les méthodes de logger générées).\nGlobalisation invariante. Les binaires Native AOT passent par défaut en mode invariant sauf si les données ICU sont livrées explicitement. Pour beaucoup de charges, c\u0026rsquo;est acceptable. Pour toute application qui formate des dates, des nombres, ou une devise selon la locale de l\u0026rsquo;utilisateur, c\u0026rsquo;est une contrainte à adresser.\nTemps de build plus longs. Un build Native AOT peut prendre 30 secondes à plusieurs minutes par publish, contre moins de 10 secondes pour un build JIT. Pour la boucle de dev, le JIT reste la cible ; l\u0026rsquo;AOT est pour le pipeline de release.\n❌ Ne jamais faire : Ne pas activer PublishAot sur un codebase mature et lancer le publish comme premier test. Le build échouera de façons difficiles à rattacher à la bibliothèque fautive. À la place, ajouter les warnings AOT au build (\u0026lt;IsAotCompatible\u0026gt;true\u0026lt;/IsAotCompatible\u0026gt;), les corriger de façon itérative, et n\u0026rsquo;activer PublishAot qu\u0026rsquo;une fois les warnings nettoyés.\nZoom : ReadyToRun comme alternative à faible risque #Pour les équipes qui veulent une amélioration de démarrage sans les contraintes AOT, ReadyToRun est souvent la bonne réponse. Il demande une propriété dans le csproj et aucun changement de code :\n\u0026lt;PropertyGroup\u0026gt; \u0026lt;PublishReadyToRun\u0026gt;true\u0026lt;/PublishReadyToRun\u0026gt; \u0026lt;PublishReadyToRunComposite\u0026gt;true\u0026lt;/PublishReadyToRunComposite\u0026gt; \u0026lt;/PropertyGroup\u0026gt; L\u0026rsquo;option composite bundle le framework et l\u0026rsquo;application dans une seule image R2R, améliorant encore le warmup. Le démarrage s\u0026rsquo;améliore d\u0026rsquo;environ 30%, la taille augmente de 10 à 20% (parce que le binaire contient maintenant à la fois l\u0026rsquo;IL et le code natif), et rien d\u0026rsquo;autre ne change. Le JIT tourne toujours au runtime pour la recompilation tier-1, ce qui veut dire que la performance en régime stable est préservée.\nReadyToRun est le premier pas sensé pour toute équipe dont le démarrage est un problème remonté mais dont le codebase dépend de la réflexion, des proxies dynamiques, ou de tout ce que l\u0026rsquo;AOT rejetterait.\n⚠️ Ça marche, mais\u0026hellip; : R2R améliore le démarrage à froid sur les méthodes qu\u0026rsquo;il a pré-compilées, typiquement le framework et les chemins chauds déclarés dans l\u0026rsquo;application. Il n\u0026rsquo;aide pas pour les méthodes jamais marquées comme cold-compilable, catégorie qui grandit avec la taille du codebase. Pour des cibles de démarrage extrêmes (sous 100 ms), Native AOT reste la réponse.\nZoom : quand l\u0026rsquo;AOT est le bon choix #Native AOT est le bon choix pour un ensemble précis de charges. L\u0026rsquo;utiliser quand au moins deux des conditions suivantes sont vraies :\nLe process est invoqué fréquemment et de courte durée. Lambdas, Azure Functions, APIs serverless, outils CLI, jobs planifiés. Chacun paie la taxe de démarrage à froid à chaque invocation. La taille de l\u0026rsquo;image container compte. Systèmes multi-tenants qui font tourner des milliers d\u0026rsquo;images, pipelines de CI qui tirent des images souvent, déploiements edge avec des contraintes de bande passante. L\u0026rsquo;empreinte mémoire par process est un driver de coût. Faire tourner des centaines de petites instances par hôte, où chaque 20 Mo économisés par process vaut l\u0026rsquo;effort de migration. La cible de déploiement n\u0026rsquo;a aucun runtime installé. Containers Linux nus, images distroless minimales, systèmes embarqués. Le codebase est greenfield ou assez petit pour être migré en un sprint. La migration AOT est beaucoup plus facile pour une minimal API de 50 fichiers que pour un monolithe de 500 fichiers avec vingt ans de patterns basés sur la réflexion. Ne pas utiliser l\u0026rsquo;AOT quand :\nLa charge est un serveur web longue durée qui reste up pendant des jours. Le JIT avec PGO battra l\u0026rsquo;AOT en régime stable, et le temps de démarrage est amorti sur la durée de vie du process. Le codebase dépend fortement de la réflexion, des proxies dynamiques, ou du code généré à l\u0026rsquo;exécution. Soit migrer ces dépendances d\u0026rsquo;abord, soit accepter que l\u0026rsquo;AOT n\u0026rsquo;est pas le bon outil pour ce codebase. L\u0026rsquo;équipe n\u0026rsquo;a pas d\u0026rsquo;appétit pour des changements cassants pendant la migration. L\u0026rsquo;AOT expose des warnings et des erreurs qu\u0026rsquo;un build JIT tolère en silence, et chaque warning est du vrai travail. Zoom : mesurer le gain #Toute adoption AOT doit s\u0026rsquo;appuyer sur des mesures avant/après, pas sur l\u0026rsquo;espoir. Trois chiffres comptent :\nLe temps de démarrage à froid, mesuré du lancement du process à la première requête réussie. Un simple harness qui spawn le process, poll un endpoint de santé, et enregistre le temps écoulé. Répéter dix fois et rapporter la médiane.\nLa mémoire résidente de pointe, mesurée pendant un run stable à 100 RPS. Utiliser dotnet-counters ou ps -o rss et capturer le max.\nLa taille du binaire, mesurée sur le répertoire de sortie publié (du -sh sous Linux, Get-ChildItem | Measure-Object -Property Length -Sum sous Windows).\n# Avant, JIT $ time curl -s http://localhost:5000/health real 0m0.480s # ~480 ms de démarrage à froid $ du -sh ./publish 82M # Après, Native AOT $ time curl -s http://localhost:5000/health real 0m0.052s # ~52 ms de démarrage à froid $ du -sh ./publish 14M Des chiffres comme ça justifient la migration. Des chiffres qui ne montrent que 10% d\u0026rsquo;amélioration ne la justifient pas, parce que les contraintes que l\u0026rsquo;AOT impose ont une longue traîne de coûts en aval. La décision doit être guidée par la donnée.\n✅ Bonne pratique : Mettre le harness de mesure dans le repo comme un script dédié. Quand le runtime livre une nouvelle version, rejouer les mesures. L\u0026rsquo;AOT s\u0026rsquo;améliore à chaque release .NET, et un codebase qui n\u0026rsquo;était \u0026ldquo;pas prêt pour l\u0026rsquo;AOT\u0026rdquo; en .NET 8 peut très bien l\u0026rsquo;être en .NET 10 ou 11.\nWrap-up #Native AOT est la bonne réponse pour une classe précise de charges .NET où la latence de démarrage à froid, la taille du binaire, et l\u0026rsquo;empreinte mémoire dominent, et où le codebase peut accepter les contraintes sur la réflexion et la génération de code dynamique. Tu peux l\u0026rsquo;activer avec \u0026lt;PublishAot\u0026gt;true\u0026lt;/PublishAot\u0026gt; et CreateSlimBuilder, adopter les source generators pour JSON, regex et logging, corriger les warnings AOT de façon itérative avant de basculer le flag de publish, et mesurer le démarrage à froid, la mémoire de pointe et la taille binaire avant et après. Tu peux te rabattre sur ReadyToRun comme intermédiaire à faible risque, ou rester sur le JIT standard quand le débit en régime stable est la vraie métrique qui compte.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Zero Allocation en .NET : quand le GC devient le goulet d\u0026rsquo;étranglement Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Le Spike Testing en .NET : survivre au burst soudain Références # Déploiement Native AOT, Microsoft Learn Support de Native AOT dans ASP.NET Core, Microsoft Learn Compilation ReadyToRun, Microsoft Learn Source generation System.Text.Json, Microsoft Learn Options de trimming, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/performance-aot-compilation/","section":"Posts","summary":"","title":"La Compilation AOT en .NET : démarrage, taille, et compromis"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/layered/","section":"Tags","summary":"","title":"Layered"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer le baseline testing, le premier type de test de charge à mettre en place dans un projet .NET.\nLe premier test de charge qu\u0026rsquo;une équipe devrait lancer n\u0026rsquo;est presque jamais le plus impressionnant. C\u0026rsquo;est le plus ennuyeux : le système sous le trafic qu\u0026rsquo;il est censé gérer au quotidien, assez longtemps pour produire des chiffres stables, et rien de plus. C\u0026rsquo;est ça, le baseline. Sans lui, tous les autres tests de charge n\u0026rsquo;ont aucun sens, parce qu\u0026rsquo;il n\u0026rsquo;y a aucune référence à laquelle comparer. Un p95 à 300 ms ne veut rien dire tant qu\u0026rsquo;on ne sait pas s\u0026rsquo;il est meilleur ou pire que la semaine dernière.\nL\u0026rsquo;article d\u0026rsquo;ensemble de cette série a couvert pourquoi les tests de charge existent et quelles métriques comptent. Cet article zoome sur le premier des quatre types et explique comment réellement monter, lancer et exploiter un baseline dans un projet .NET.\nLe contexte : pourquoi le baseline existe #Une équipe livre une fonctionnalité. La fonctionnalité implique une requête EF Core qui a l\u0026rsquo;air innocente. Le déploiement suivant passe en prod. Deux semaines plus tard, un client signale que le dashboard est lent. L\u0026rsquo;équipe regarde Grafana, voit que la latence est effectivement plus haute que d\u0026rsquo;habitude, et pose la seule question qui compte : \u0026ldquo;plus haute que quoi, exactement ?\u0026rdquo;. Sans baseline, la réponse est \u0026ldquo;plus haute que mon souvenir de la sensation du mois dernier\u0026rdquo;, ce qui n\u0026rsquo;est pas une réponse.\nLe baseline répond à quatre problèmes concrets :\nIl établit une référence. Un chiffre stable enregistré sous un profil de trafic connu, sauvegardé avec le hash du commit et la date de déploiement. Chaque run suivant peut être comparé à lui. Il attrape les régressions avant que la prod ne les voie. Une pull request qui double les allers-retours en base pour le tunnel de checkout échouera à la comparaison baseline en CI, pas à 2 heures du matin un lundi. Il valide les hypothèses de sizing. Si le p95 baseline est déjà proche du SLO au trafic attendu, la prod n\u0026rsquo;a plus de marge, et l\u0026rsquo;équipe le sait avant l\u0026rsquo;incident. Il ancre tous les autres tests de charge. Les tests soak, stress et spike sont toujours relatifs au baseline. Sans lui, \u0026ldquo;le système s\u0026rsquo;est dégradé sous stress\u0026rdquo; est une phrase sans dénominateur. Vue d\u0026rsquo;ensemble : la forme d\u0026rsquo;un run baseline # graph LR A[Pré-prodfraîche] --\u003e B[Warmup1-2 min] B --\u003e C[Régime stable5-10 min auRPS attendu] C --\u003e D[Capture des métriques] D --\u003e E[Stockage référenceavec hash commit] E --\u003e F[Comparaison avecbaseline précédent] Un run baseline a quatre phases. Le warmup existe parce que la compilation JIT, le remplissage du cache, et l\u0026rsquo;amorçage du pool de connexions faussent tous la première minute de n\u0026rsquo;importe quel test .NET. Le régime stable est la phase où les chiffres sont réellement capturés, assez longtemps pour lisser le bruit du GC et des tâches de fond. La capture produit un artefact structuré, pas juste un dump de log. Le stockage et la comparaison est la partie que la plupart des équipes sautent et regrettent.\nLe profil de trafic pendant le régime stable doit refléter la prod le plus fidèlement possible. Si la prod fait 70% de lectures, 20% d\u0026rsquo;écritures et 10% de recherches, le baseline fait la même chose. Un baseline qui ne tape que sur GET /orders n\u0026rsquo;est pas un baseline, c\u0026rsquo;est un microbenchmark avec des prétentions.\nZoom : un baseline réaliste avec k6 #import http from \u0026#39;k6/http\u0026#39;; import { check, sleep, group } from \u0026#39;k6\u0026#39;; import { Trend, Counter } from \u0026#39;k6/metrics\u0026#39;; const checkoutLatency = new Trend(\u0026#39;checkout_flow_duration\u0026#39;); const ordersCreated = new Counter(\u0026#39;orders_created\u0026#39;); export const options = { stages: [ { duration: \u0026#39;1m\u0026#39;, target: 50 }, // rampe de warmup { duration: \u0026#39;10m\u0026#39;, target: 50 }, // régime stable { duration: \u0026#39;30s\u0026#39;, target: 0 }, // cooldown ], thresholds: { \u0026#39;http_req_duration{group:::catalog}\u0026#39;: [\u0026#39;p(95)\u0026lt;200\u0026#39;], \u0026#39;http_req_duration{group:::checkout}\u0026#39;: [\u0026#39;p(95)\u0026lt;500\u0026#39;], \u0026#39;http_req_failed\u0026#39;: [\u0026#39;rate\u0026lt;0.005\u0026#39;], // \u0026lt;0,5% d\u0026#39;erreurs \u0026#39;checkout_flow_duration\u0026#39;: [\u0026#39;p(95)\u0026lt;1200\u0026#39;], }, }; const BASE = __ENV.BASE_URL || \u0026#39;https://shop.preprod.internal\u0026#39;; export default function () { // 70% chemin de lecture group(\u0026#39;catalog\u0026#39;, () =\u0026gt; { const r = http.get(`${BASE}/api/products?page=1\u0026amp;size=20`); check(r, { \u0026#39;catalog ok\u0026#39;: (res) =\u0026gt; res.status === 200 }); }); // 20% chemin d\u0026#39;écriture : tunnel de checkout complet if (Math.random() \u0026lt; 0.2) { group(\u0026#39;checkout\u0026#39;, () =\u0026gt; { const start = Date.now(); const cart = http.post(`${BASE}/api/cart`, JSON.stringify({ productId: \u0026#39;SKU-42\u0026#39;, quantity: 1, }), { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; } }); const submit = http.post(`${BASE}/api/orders/${cart.json(\u0026#39;id\u0026#39;)}/submit`); checkoutLatency.add(Date.now() - start); if (submit.status === 204) ordersCreated.add(1); }); } // 10% chemin de recherche if (Math.random() \u0026lt; 0.1) { group(\u0026#39;search\u0026#39;, () =\u0026gt; { http.get(`${BASE}/api/search?q=jean`); }); } sleep(1); } Trois chemins de trafic, pondérés pour correspondre à la prod. Une rampe de warmup, un régime stable de 10 minutes, un cooldown. Des seuils qui font échouer le run en CI si l\u0026rsquo;un d\u0026rsquo;eux casse. Des métriques custom qui suivent la transaction métier (le tunnel de checkout complet), pas seulement les endpoints individuels. Voilà à quoi ressemble un baseline sérieux.\n✅ Bonne pratique : Tagger les requêtes avec group() pour que k6 remonte les métriques par chemin. Un p95 global qui mélange lectures et écritures n\u0026rsquo;a presque jamais d\u0026rsquo;utilité. Un p95 par groupe dit où la latence vit réellement.\nZoom : le même baseline avec NBomber #Pour les équipes qui préfèrent garder tout en C# :\nusing NBomber.CSharp; using NBomber.Http; using NBomber.Http.CSharp; using var httpClient = new HttpClient { BaseAddress = new Uri(\u0026#34;https://shop.preprod.internal\u0026#34;) }; var catalogScenario = Scenario.Create(\u0026#34;catalog\u0026#34;, async context =\u0026gt; { var request = Http.CreateRequest(\u0026#34;GET\u0026#34;, \u0026#34;/api/products?page=1\u0026amp;size=20\u0026#34;) .WithHeader(\u0026#34;Accept\u0026#34;, \u0026#34;application/json\u0026#34;); return await Http.Send(httpClient, request); }) .WithWeight(70) .WithLoadSimulations( Simulation.RampingConstant(copies: 50, during: TimeSpan.FromMinutes(1)), Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(10))); var checkoutScenario = Scenario.Create(\u0026#34;checkout\u0026#34;, async context =\u0026gt; { var addToCart = Http.CreateRequest(\u0026#34;POST\u0026#34;, \u0026#34;/api/cart\u0026#34;) .WithJsonBody(new { productId = \u0026#34;SKU-42\u0026#34;, quantity = 1 }); var cartResponse = await Http.Send(httpClient, addToCart); if (!cartResponse.IsError) { var cartId = cartResponse.Payload.Value.RootElement.GetProperty(\u0026#34;id\u0026#34;).GetString(); var submit = Http.CreateRequest(\u0026#34;POST\u0026#34;, $\u0026#34;/api/orders/{cartId}/submit\u0026#34;); return await Http.Send(httpClient, submit); } return cartResponse; }) .WithWeight(20) .WithLoadSimulations( Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(10))); NBomberRunner .RegisterScenarios(catalogScenario, checkoutScenario) .WithReportFormats(ReportFormat.Html, ReportFormat.Csv, ReportFormat.Md) .WithReportFolder(\u0026#34;./reports/baseline\u0026#34;) .Run(); Même idée, exprimée en C#. L\u0026rsquo;option WithWeight permet à NBomber de répartir les utilisateurs virtuels entre les scénarios dans le ratio attendu. Les rapports atterrissent dans ./reports/baseline/ et peuvent être commités, archivés ou poussés vers un bucket de stockage pour la comparaison historique.\nZoom : quoi capturer, et où le stocker #Un baseline n\u0026rsquo;est pas utile comme un tas de fichiers CSV. Il est utile comme un enregistrement structuré qu\u0026rsquo;on peut differ. Au minimum, chaque run baseline devrait stocker :\nLe hash du commit et la branche de l\u0026rsquo;application sous test Le timestamp de déploiement et l\u0026rsquo;identifiant de l\u0026rsquo;environnement La version de k6 ou NBomber et le hash du fichier source du scénario Les métriques par groupe : latence p50, p95, p99, p99.9 ; RPS ; taux d\u0026rsquo;erreur par code de statut Les signaux runtime : CPU, mémoire, temps de pause GC, longueur de la file du thread pool, usage du pool de base Le statut pass / fail par rapport aux seuils configurés Une convention simple qui marche bien : écrire un résumé JSON vers un bucket S3 / blob après chaque run, indexé par \u0026lt;env\u0026gt;/\u0026lt;yyyy-mm-dd\u0026gt;/\u0026lt;hash-commit\u0026gt;.json. Un job ultérieur diffe le run le plus récent avec le précédent et poste le delta en commentaire de la pull request. Cela transforme le baseline en un signal vivant de régression, au lieu d\u0026rsquo;un exercice ponctuel.\n💡 Info : k6 supporte la remontée directe des résultats vers Prometheus (k6 run --out experimental-prometheus-rw) et vers Grafana Cloud. NBomber écrit nativement des rapports HTML, CSV et Markdown, et peut aussi alimenter InfluxDB. L\u0026rsquo;un ou l\u0026rsquo;autre chemin suffit pour construire la comparaison historique.\nZoom : baseline par rapport à quoi, exactement #Une question à poser explicitement : quel niveau de trafic \u0026ldquo;baseline\u0026rdquo; veut-il dire pour le système ? Trois définitions courantes, chacune valable dans son contexte :\nLe pic quotidien moyen. L\u0026rsquo;heure la plus chargée d\u0026rsquo;une journée de semaine typique. C\u0026rsquo;est le point de départ le plus sûr pour la plupart des équipes, parce qu\u0026rsquo;il correspond à ce que le système gère réellement sur une journée normale. Le pic hebdomadaire. Le trafic à l\u0026rsquo;heure la plus chargée du jour le plus chargé de la semaine. Utile pour les systèmes avec des patterns hebdomadaires prévisibles (dashboards du lundi matin, e-commerce du vendredi soir). La charge SLO cible. Le niveau de trafic que le système est contractuellement censé soutenir, que la prod actuelle l\u0026rsquo;atteigne ou non. Utilisé quand le SLO est au-dessus du trafic réel et que l\u0026rsquo;équipe doit prouver que la marge existe. En choisir un, l\u0026rsquo;écrire, et s\u0026rsquo;y tenir. Déplacer silencieusement la cible du baseline entre les runs est la meilleure façon de livrer \u0026ldquo;des améliorations\u0026rdquo; qui n\u0026rsquo;ont l\u0026rsquo;air d\u0026rsquo;être des améliorations que parce que la comparaison a bougé sous les pieds.\n❌ Ne jamais faire : Ne pas enregistrer le baseline depuis un système froid un dimanche matin calme et le comparer à un test tournant sur un système chaud sous charge normale. Les deux ne sont pas comparables. Le warmup compte, le régime stable compte, la cohérence de l\u0026rsquo;environnement de référence compte. Un baseline qui bouge à chaque run n\u0026rsquo;est pas un baseline.\nZoom : quand le lancer #Trois cadences couvrent la plupart des équipes :\nToutes les nuits, en CI. Un job planifié lance le baseline contre la pré-prod chaque nuit, stocke le résultat, et notifie sur régression. C\u0026rsquo;est l\u0026rsquo;automatisation à plus forte valeur que la plupart des équipes peuvent ajouter.\nAvant chaque release importante. Même avec les runs nocturnes, un run dédié avant release attrape les problèmes qui n\u0026rsquo;apparaissent que sur le chemin de code spécifique de la version qui sort.\nÀ la demande, avant de merger une PR sensible à la performance. Les équipes qui pratiquent ça ont une cible dotnet run --project LoadTests.Baseline ou un k6 run baseline.js qu\u0026rsquo;un dev peut déclencher localement contre une pré-prod partagée, avant de demander la review.\n✅ Bonne pratique : Stocker l\u0026rsquo;artefact de référence du baseline à côté des notes de release. Quand un client signale \u0026ldquo;c\u0026rsquo;était plus rapide avant\u0026rdquo;, l\u0026rsquo;équipe peut sortir le baseline de la dernière version connue comme bonne et prouver, ou réfuter, la réclamation avec des données.\nWrap-up #Le baseline est le test de charge le moins cher et celui qui rentabilise le plus vite. Le lancer donne à l\u0026rsquo;équipe un point de référence auquel chaque changement, déploiement et test soak / stress / spike suivant peut être comparé. On peut le monter en k6 ou NBomber en un après-midi, tagger le trafic par chemin métier pour que les métriques par groupe reflètent de vrais flux utilisateurs, stocker des artefacts structurés avec les hashes de commit, et planifier des runs nocturnes contre la pré-prod pour attraper la régression avant la prod.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Tests d\u0026rsquo;Intégration avec TestContainers pour .NET Tests API avec WebApplicationFactory en ASP.NET Core Références # Documentation k6 Seuils et métriques k6 Documentation NBomber Métriques ASP.NET Core, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/load-testing-baseline/","section":"Posts","summary":"","title":"Le Baseline Testing en .NET : savoir à quoi ressemble la normale"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va comprendre le soak testing, le type de test qui révèle les bugs invisibles à court terme.\nUn système peut passer chaque test unitaire, chaque test d\u0026rsquo;intégration, chaque test d\u0026rsquo;API, chaque test E2E Playwright, et le baseline, et quand même tomber à 4 heures du matin le troisième jour après le déploiement. Les bugs qui causent ça partagent une signature commune : ils n\u0026rsquo;apparaissent qu\u0026rsquo;après des heures d\u0026rsquo;exécution soutenue. De la mémoire qui fuit d\u0026rsquo;un kilo-octet par requête. Un pool de connexions qui grimpe de 40 à 99 au long d\u0026rsquo;un week-end. Un fichier de log qui atteint le quota de disque au sixième jour. Un cache qui dérive parce qu\u0026rsquo;un événement d\u0026rsquo;invalidation est occasionnellement perdu sous charge.\nAucun de ces bugs ne se montre dans un baseline de 10 minutes. Tous se montrent dans un soak test. C\u0026rsquo;est toute la proposition de valeur du soak : faire tourner le système sous une charge modérée et soutenue assez longtemps pour que les bugs dépendants du temps aient une chance d\u0026rsquo;apparaître.\nLe contexte : pourquoi le soak existe #L\u0026rsquo;histoire classique sur les incidents de prod soudains est fausse. La plupart des incidents de prod ne sont pas soudains. Ce sont des défaillances lentes qui paraissent soudaines parce que personne ne surveillait la pente. Une croissance mémoire de 2% par jour est invisible sur un graphe qui couvre une heure, et flagrante sur un graphe qui couvre sept jours. Un job de fond qui fuit un thread par run se porte bien à un run par heure et devient catastrophique à dix runs par minute. Ce sont exactement les modes de défaillance qu\u0026rsquo;un soak est fait pour attraper.\nConcrètement, les soak tests répondent à quatre questions qu\u0026rsquo;aucun autre type de test ne traite :\nEst-ce que la mémoire reste stable sous charge soutenue ? Une vraie fuite mémoire produit un working set qui monte de façon monotone. Un GC qui suit les allocations produit un motif en dents de scie qui reste borné. La différence n\u0026rsquo;est visible que dans la durée. Est-ce que les pools de connexions restent sains ? Pools base de données, pools de clients HTTP, channels gRPC, connexions au broker de messages, tous ont une taille max. Une fuite occasionnelle d\u0026rsquo;une connexion par heure est invisible à la dixième minute et fatale à la dix-huitième heure. Est-ce que l\u0026rsquo;usage disque reste borné ? Logs, fichiers temporaires, dead-letter queues, tables de jobs en échec. N\u0026rsquo;importe lequel peut grandir sans borne si la rotation, le pruning ou le nettoyage est cassé. Est-ce que les caches, les files et l\u0026rsquo;état des tâches de fond restent cohérents ? L\u0026rsquo;invalidation de cache sous écritures concurrentes, la profondeur de file selon la vitesse du consommateur, les jobs planifiés qui ne nettoient pas derrière eux, tout cela dérive avec le temps et ne se révèle qu\u0026rsquo;après des heures. Vue d\u0026rsquo;ensemble : la forme d\u0026rsquo;un run soak # graph TD A[Charge modérée50-70% du baseline] --\u003e B[Durée4 à 24 heures] B --\u003e C[Métriques en continu] C --\u003e D1[Croissanceworking set] C --\u003e D2[Tailles heap GCgen0/1/2] C --\u003e D3[Temps d'attente poolsDB, HTTP, threads] C --\u003e D4[Usage disquelogs, tmp] C --\u003e D5[Latencedérive dans le temps] Un soak n\u0026rsquo;est pas un test de débit de pointe. La charge est délibérément modérée, en général 50 à 70 pour cent de ce que le baseline établit comme normal, pour que le système ait de la marge pour le vrai travail et que le test stresse la durée, pas l\u0026rsquo;intensité. La durée est la variable : quatre heures pour un premier run, une nuit entière pour une validation pré-release, plusieurs jours pour un changement de plateforme (upgrade EF Core, upgrade runtime, migration d\u0026rsquo;infrastructure).\nLa sortie d\u0026rsquo;un soak n\u0026rsquo;est pas un seul chiffre. C\u0026rsquo;est un jeu de graphes time-series qui montrent comment les métriques évoluent pendant le run. Un run qui rapporte \u0026ldquo;p95 moyen à 120 ms\u0026rdquo; et rien d\u0026rsquo;autre est un soak raté, parce que la moyenne ne dit rien sur le fait que la latence est peut-être passée de 90 ms à 160 ms sur la fenêtre, ce qui est la vraie question.\nZoom : configuration soak avec k6 #import http from \u0026#39;k6/http\u0026#39;; import { check, sleep, group } from \u0026#39;k6\u0026#39;; export const options = { stages: [ { duration: \u0026#39;2m\u0026#39;, target: 30 }, // warmup { duration: \u0026#39;8h\u0026#39;, target: 30 }, // soak à 30 VUs (~60% du baseline) { duration: \u0026#39;1m\u0026#39;, target: 0 }, // cooldown ], thresholds: { // La fenêtre glissante : le soak échoue si *n\u0026#39;importe quelle* heure dégrade. \u0026#39;http_req_duration\u0026#39;: [\u0026#39;p(95)\u0026lt;400\u0026#39;], \u0026#39;http_req_failed\u0026#39;: [\u0026#39;rate\u0026lt;0.01\u0026#39;], }, ext: { loadimpact: { projectID: 0 }, }, }; const BASE = __ENV.BASE_URL || \u0026#39;https://shop.preprod.internal\u0026#39;; export default function () { group(\u0026#39;catalog\u0026#39;, () =\u0026gt; { http.get(`${BASE}/api/products?page=1\u0026amp;size=20`); }); if (Math.random() \u0026lt; 0.2) { group(\u0026#39;write\u0026#39;, () =\u0026gt; { http.post(`${BASE}/api/cart`, JSON.stringify({ productId: `SKU-${Math.floor(Math.random() * 1000)}`, quantity: 1, }), { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; } }); }); } sleep(2); } Huit heures, trente utilisateurs virtuels, charge modérée. Le sleep(2) entre requêtes est délibéré : un soak n\u0026rsquo;est pas fait pour maximiser le débit, il est fait pour maintenir le système sous une pression continue et réaliste sur une longue période.\n✅ Bonne pratique : Lancer le soak avec les résultats streamés en direct vers Grafana (ou n\u0026rsquo;importe quel dashboard). Le moment le plus utile d\u0026rsquo;un soak n\u0026rsquo;est pas la fin, c\u0026rsquo;est le moment où on remarque que la pente change. Attendre le rapport final supprime l\u0026rsquo;intérêt du test.\nZoom : ce qu\u0026rsquo;il faut regarder pendant le run #Le générateur de charge capture les métriques au niveau requête. Le vrai signal vit côté application. Pour un système .NET, le dashboard minimum pendant un run soak affiche :\nLe working set et le heap GC dans le temps. La métrique process.runtime.dotnet.gc.heap.size, ventilée par génération, tracée en fonction de l\u0026rsquo;horloge. Un système sain montre un motif stable ou en dents de scie. Une fuite montre une tendance qui monte et ne redescend jamais, même après une collecte gen2.\nLes métriques du pool de connexions base de données. Les compteurs pool_wait_time et pool_in_use de Npgsql, SqlClient, ou le provider utilisé. Un pool qui commence à 10 en usage et grimpe à 90 en six heures a une fuite de connexion quelque part, et le soak est le test qui l\u0026rsquo;attrape.\nLa longueur de file du thread pool. Les compteurs System.Runtime exposent threadpool-queue-length et threadpool-thread-count. Une file qui grandit sans borne signifie que le travail arrive plus vite que les threads ne peuvent le traiter, en général à cause d\u0026rsquo;un pattern sync-over-async qui n\u0026rsquo;est visible que sous charge soutenue.\nLa distribution de latence, dans le temps, pas en moyenne. Un heatmap Grafana de http_server_request_duration par endpoint dit si le p95 est stable ou dérive vers le haut. La dérive, si elle existe, est le bug.\nL\u0026rsquo;usage disque sur l\u0026rsquo;hôte. Un simple df remonté chaque minute attrape les échecs de rotation de logs, les fuites de fichiers temporaires, et le gonflement des dead-letter queues avant qu\u0026rsquo;ils ne fassent tomber le process.\n// Program.cs : exposer les métriques dont un soak a besoin builder.Services.AddOpenTelemetry() .WithMetrics(metrics =\u0026gt; { metrics .AddMeter(\u0026#34;Microsoft.AspNetCore.Hosting\u0026#34;) .AddMeter(\u0026#34;Microsoft.AspNetCore.Http.Connections\u0026#34;) .AddMeter(\u0026#34;System.Net.Http\u0026#34;) .AddMeter(\u0026#34;Microsoft.EntityFrameworkCore\u0026#34;) .AddRuntimeInstrumentation() // GC, thread pool, contention .AddProcessInstrumentation() // CPU, mémoire, handles .AddPrometheusExporter(); }); 💡 Info : AddRuntimeInstrumentation vient du package NuGet OpenTelemetry.Instrumentation.Runtime et c\u0026rsquo;est la ligne unique la plus utile qu\u0026rsquo;une équipe .NET puisse ajouter à un système testable en soak. Elle expose les tailles de heap GC, la longueur de file du thread pool, et la contention de verrous sans une seule ligne de code custom.\nZoom : lire les résultats #Un soak produit trois résultats typiques.\nPlat et stable. Toutes les métriques restent dans leur bande de départ pendant toute la durée. La latence fait des dents de scie, le GC récupère, les pools restent stables, l\u0026rsquo;usage disque reste plat. Le soak passe, et l\u0026rsquo;équipe a la preuve que le système peut tourner aussi longtemps que la durée du test.\nDérive graduelle. La latence monte lentement, la mémoire tend vers le haut, ou un pool grandit. C\u0026rsquo;est le cas diagnostique pour lequel le soak existe. L\u0026rsquo;équipe regarde la pente et se demande : \u0026ldquo;à ce rythme, quand est-ce qu\u0026rsquo;on atteint la limite ?\u0026rdquo;. Une fuite linéaire de 50 Mo par heure, sur une machine 16 Go, donne à peu près deux semaines. Une dérive sous-linéaire peut encore être acceptable. Une dérive super-linéaire est une alerte rouge, parce qu\u0026rsquo;elle ne se contente pas de doubler le temps avant l\u0026rsquo;échec quand la charge double, elle échoue beaucoup plus vite.\nFalaise. Tout a l\u0026rsquo;air normal pendant six heures, puis un pool s\u0026rsquo;épuise, un disjoncteur se déclenche, ou le process tombe en OOM. Le moment de la falaise est une information utile : il dit où se cache la limite cachée et donne à l\u0026rsquo;équipe une cible concrète à corriger.\n⚠️ Ça marche, mais\u0026hellip; : Un soak qui ne montre aucune dérive sur 8 heures n\u0026rsquo;est pas une preuve que le système peut tourner 8 jours. La couverture de durée croît de façon non linéaire : les crons hebdomadaires, les batchs mensuels, et les motifs de charge saisonniers ne seront exercés que par des runs plus longs. Le soak est un signal de confiance, pas une garantie.\n❌ Ne jamais faire : Ne pas lancer un soak et ne rapporter que le chiffre final. Un p95 de latence moyenné sur 8 heures cache toute l\u0026rsquo;histoire. L\u0026rsquo;histoire est dans le graphe time-series. Si le rapport n\u0026rsquo;inclut pas de graphe, le rapport est incomplet.\nZoom : quand lancer un soak #Les soak coûtent cher en temps écoulé, même s\u0026rsquo;ils coûtent peu en compute. Trois cadences couvrent la plupart des équipes :\nAvant chaque upgrade de plateforme. Un upgrade du runtime .NET, un changement de version majeure d\u0026rsquo;EF Core, une migration de cluster Kubernetes, un changement de version du moteur de base : chacun justifie un soak d\u0026rsquo;une nuit entière avant le rollout en prod. C\u0026rsquo;est là que se cachent les bugs à plus forte valeur.\nHebdomadaire, planifié. Un soak de 8 heures une fois par semaine, qui tourne du samedi soir au dimanche matin, attrape les régressions accumulées pendant la semaine et établit un baseline roulant pour le comportement de longue durée.\nSur suspicion. Quand un incident de prod contient \u0026ldquo;dégradation lente sur plusieurs heures\u0026rdquo; dans son post-mortem, la suite est presque toujours un soak conçu pour reproduire la dégradation en pré-prod, avec le composant fautif instrumenté plus finement que d\u0026rsquo;habitude.\nQuand le soak est le mauvais outil #Les soak tests sont la bonne réponse pour les modes de défaillance dépendants du temps. Ils sont la mauvaise réponse pour :\nLes questions de débit de pointe : c\u0026rsquo;est un test de stress. La gestion des bursts : c\u0026rsquo;est un test de spike. La correction logique sous concurrence : c\u0026rsquo;est un test d\u0026rsquo;intégration avec des workers parallèles, ou une chasse aux race conditions, pas un soak. Trouver le point de rupture : les tests de stress le trouvent, les soak ne poussent pas assez fort pour l\u0026rsquo;atteindre. Lancer un soak pour répondre à une question de stress, c\u0026rsquo;est attendre huit heures pour une conclusion qu\u0026rsquo;un test de stress d\u0026rsquo;une heure aurait donnée.\nWrap-up #Un soak révèle les bugs qui vivent dans l\u0026rsquo;écart entre un run sain de dix minutes et un déploiement de prod de plusieurs jours. On peut en monter un en k6 ou NBomber en un après-midi, garder la charge modérée (50 à 70 pour cent du baseline), le faire tourner pendant quatre à vingt-quatre heures contre un environnement de pré-prod réaliste, et regarder les métriques time-series en direct plutôt que d\u0026rsquo;attendre le rapport final. On peut attraper des pools de connexions qui fuient, des caches qui dérivent, des fichiers de log qui grossissent, et des fuites mémoire linéaires avant qu\u0026rsquo;ils ne deviennent un incident de prod, et distinguer la dérive graduelle, la falaise et le comportement plat stable à la forme du graphe.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Le Baseline Testing en .NET : savoir à quoi ressemble la normale Tests API avec WebApplicationFactory en ASP.NET Core Références # Stages et scénarios k6 OpenTelemetry Runtime Instrumentation pour .NET Métriques ASP.NET Core, Microsoft Learn Fondamentaux du garbage collection .NET, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/load-testing-soak/","section":"Posts","summary":"","title":"Le Soak Testing en .NET : les bugs qui n'apparaissent qu'après des heures"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier le spike testing, le dernier des quatre types de tests de charge, et celui qui révèle comment le système réagit aux moments les plus visibles de sa vie.\nUn système peut passer un baseline, tenir un soak, récupérer proprement d\u0026rsquo;un stress, et quand même échouer sur son moment public le plus visible : la seconde précise où le trafic passe du calme à l\u0026rsquo;écrasement, sans préavis. Le Black Friday à minuit, un tweet viral qui pointe vers une landing page, un email marketing envoyé à cinq cent mille boîtes en même temps, une intégration partenaire dont le cron se déclenche à l\u0026rsquo;heure pile. Ce sont les moments dont l\u0026rsquo;équipe se souvient, et ce n\u0026rsquo;est pas à ça que prépare une rampe de stress progressive.\nLe spike testing est le dernier des quatre types présentés dans l\u0026rsquo;article d\u0026rsquo;ensemble. Il répond à une seule question précise : quand le trafic passe de presque zéro à très haut en moins de dix secondes, est-ce que le système tient, se dégrade gracieusement, ou s\u0026rsquo;effondre.\nLe contexte : pourquoi le spike existe #Un stress test avec une rampe douce donne au système toutes les chances de s\u0026rsquo;adapter : les caches CPU chauffent, le JIT compile les chemins chauds, le pool de connexions base grandit pour suivre la demande, l\u0026rsquo;autoscaler réagit et provisionne de nouvelles instances. Un spike ne donne rien de tout ça. Ça part du calme, et quinze secondes plus tard c\u0026rsquo;est submergé. Les systèmes qui meurent sur un spike sont ceux qui avaient besoin de la rampe.\nConcrètement, les spikes exposent quatre faiblesses distinctes qu\u0026rsquo;aucun autre type de test ne stresse aussi fort :\nLa pénalité de cache froid. Les caches distribués vont bien jusqu\u0026rsquo;à ce que tous les nœuds ratent en même temps. La base reçoit alors tout le trafic, amplifié par une tempête de requêtes identiques concurrentes, et s\u0026rsquo;effondre avant que le cache n\u0026rsquo;ait le temps de se réhydrater. La latence de l\u0026rsquo;autoscaling. Les horizontal pod autoscalers Kubernetes, Azure Container Apps, AWS ECS, et chaque autre autoscaler ont un temps de réaction. Ce temps est en général de l\u0026rsquo;ordre de la minute. Un spike qui dure quatre-vingt-dix secondes est fini avant qu\u0026rsquo;une seule nouvelle instance ne soit prête. Le coût de démarrage des pools de connexions. Les drivers de base de données, les clients HTTP, et les connexions aux brokers de messages mettent du temps à s\u0026rsquo;établir. Une application qui démarre avec un pool de 10 connexions et en a besoin de 200 passera les trente premières secondes du spike en timeout pendant que le pool grossit. La compilation JIT et le warmup. .NET JIT les méthodes au premier appel. Les méthodes tier-0 sont re-JIT en tier-1 une fois qu\u0026rsquo;elles se révèlent chaudes. Un spike touche le système avant que les chemins chauds ne soient compilés en tier-1, ce qui peut doubler la latence des premiers milliers de requêtes. Aucun de ces problèmes n\u0026rsquo;est visible dans un test en régime stable. Tous sont visibles dans un spike, et tous sont corrigeables, en général avec des changements de configuration et des stratégies de warmup qui coûtent très peu.\nVue d\u0026rsquo;ensemble : la forme d\u0026rsquo;un run spike # graph LR A[Idle ou très bas5-10 VUs] --\u003e B[Saut soudain10 -\u003e 500 VUsen moins de 30s] B --\u003e C[Maintien au pic2-5 min] C --\u003e D[Redescentevers idle] D --\u003e E[Observationsecond spikesi besoin] Un spike test a quatre phases, chacune avec un rôle précis.\nLa phase idle établit que le système est au calme. Trafic bas ou nul, pendant une ou deux minutes. C\u0026rsquo;est l\u0026rsquo;état que le spike va interrompre.\nLe saut est la caractéristique définissante du test. La montée se fait en secondes, pas en minutes. Un spike est fait pour prendre le système au dépourvu. Si la montée est progressive, le test est un stress, pas un spike.\nLe maintien au pic garde la charge haute pendant deux à cinq minutes. Assez longtemps pour que l\u0026rsquo;autoscaler (s\u0026rsquo;il y en a un) réagisse, que le JIT chauffe, que le cache se réhydrate, et que les pools de connexions grandissent. Cette phase répond à la question \u0026ldquo;est-ce que le système récupère pendant qu\u0026rsquo;il est encore sous charge\u0026rdquo;.\nLa redescente revient à l\u0026rsquo;idle. Optionnellement, un second spike suit une minute plus tard, pour tester si le système est réellement prêt pour le prochain burst ou s\u0026rsquo;il est encore en train de récupérer du premier.\nZoom : un spike avec k6 #import http from \u0026#39;k6/http\u0026#39;; import { check, sleep } from \u0026#39;k6\u0026#39;; export const options = { stages: [ { duration: \u0026#39;1m\u0026#39;, target: 10 }, // idle { duration: \u0026#39;10s\u0026#39;, target: 500 }, // le spike : 10 -\u0026gt; 500 en 10s { duration: \u0026#39;3m\u0026#39;, target: 500 }, // maintien au pic { duration: \u0026#39;10s\u0026#39;, target: 10 }, // redescente { duration: \u0026#39;30s\u0026#39;, target: 10 }, // observation de la récupération { duration: \u0026#39;10s\u0026#39;, target: 500 }, // second spike (optionnel) { duration: \u0026#39;1m\u0026#39;, target: 500 }, { duration: \u0026#39;10s\u0026#39;, target: 0 }, ], thresholds: { // Un spike a des seuils volontairement plus lâches : l\u0026#39;objectif est \u0026#34;encore debout\u0026#34;, pas \u0026#34;latence baseline\u0026#34;. \u0026#39;http_req_duration\u0026#39;: [\u0026#39;p(95)\u0026lt;2000\u0026#39;], \u0026#39;http_req_failed\u0026#39;: [\u0026#39;rate\u0026lt;0.10\u0026#39;], }, }; const BASE = __ENV.BASE_URL || \u0026#39;https://shop.preprod.internal\u0026#39;; export default function () { http.get(`${BASE}/api/products/featured`); sleep(0.1); // boucle serrée : un spike maximise la pression } De dix à cinq cents utilisateurs virtuels en dix secondes, maintenu trois minutes, redescendu, maintenu bas, puis re-spiké. Les seuils sont délibérément plus lâches qu\u0026rsquo;un baseline ou un stress, parce que la question n\u0026rsquo;est pas \u0026ldquo;est-ce que la performance est restée au niveau baseline\u0026rdquo; mais \u0026ldquo;est-ce que le système est resté disponible pendant le spike et le second spike\u0026rdquo;.\n✅ Bonne pratique : Lancer le spike contre un système qui est au repos depuis au moins dix minutes avant le début du test. Un spike contre un système chaud n\u0026rsquo;est pas un spike, c\u0026rsquo;est un stress. La froideur fait tout l\u0026rsquo;intérêt du test.\nZoom : le même test avec NBomber #using NBomber.CSharp; using NBomber.Http; using NBomber.Http.CSharp; using var httpClient = new HttpClient { BaseAddress = new Uri(\u0026#34;https://shop.preprod.internal\u0026#34;) }; var scenario = Scenario.Create(\u0026#34;spike_hot_path\u0026#34;, async context =\u0026gt; { var request = Http.CreateRequest(\u0026#34;GET\u0026#34;, \u0026#34;/api/products/featured\u0026#34;); return await Http.Send(httpClient, request); }) .WithLoadSimulations( // Idle Simulation.KeepConstant(copies: 10, during: TimeSpan.FromMinutes(1)), // Le spike : rampe 10 -\u0026gt; 500 en 10 secondes Simulation.RampingConstant(copies: 500, during: TimeSpan.FromSeconds(10)), // Maintien Simulation.KeepConstant(copies: 500, during: TimeSpan.FromMinutes(3)), // Redescente Simulation.RampingConstant(copies: 10, during: TimeSpan.FromSeconds(10)), // Récupération Simulation.KeepConstant(copies: 10, during: TimeSpan.FromSeconds(30)), // Second spike Simulation.RampingConstant(copies: 500, during: TimeSpan.FromSeconds(10)), Simulation.KeepConstant(copies: 500, during: TimeSpan.FromMinutes(1)), Simulation.RampingConstant(copies: 0, during: TimeSpan.FromSeconds(10)) ); NBomberRunner.RegisterScenarios(scenario) .WithReportFormats(ReportFormat.Html, ReportFormat.Csv) .WithReportFolder(\u0026#34;./reports/spike\u0026#34;) .Run(); RampingConstant avec une durée de 10 secondes de 10 à 500 utilisateurs virtuels est l\u0026rsquo;équivalent NBomber du stage spike de k6. Tout le reste est une question de séquencement de phases, que NBomber exprime comme une liste ordonnée d\u0026rsquo;entrées LoadSimulation.\nZoom : ce qu\u0026rsquo;il faut regarder pendant un spike #Cinq signaux comptent pendant un spike, et tous ont besoin d\u0026rsquo;une résolution inférieure à la seconde dans le dashboard pour être lisibles.\nLe temps-jusqu\u0026rsquo;à-la-première-réponse après le début du spike. Combien de secondes s\u0026rsquo;écoulent entre le saut de charge et la première 200 OK servie sous la nouvelle charge. C\u0026rsquo;est souvent le chiffre le plus utile à lui seul : il capture le warmup JIT, la croissance du pool de connexions, et la réhydratation du cache dans une seule métrique.\nLa courbe de croissance du pool de connexions. Pour Npgsql ou SqlClient, tracer pool_in_use dans le temps. Pendant un spike, le pool devrait grandir rapidement pour suivre la demande. S\u0026rsquo;il plafonne tôt, le pool a atteint son maximum configuré et l\u0026rsquo;équipe a trouvé le premier goulet d\u0026rsquo;étranglement.\nLa distribution de latence des requêtes base de données. Pendant un spike avec un cache froid, la base est le premier endroit à encaisser. Tracer le p95 par seconde de la durée des requêtes. Repérer le moment où il atteint son pic, puis revient au baseline. Le delta est le coût du cache froid.\nLes événements de l\u0026rsquo;autoscaler. Si le système tourne sur Kubernetes ou un orchestrateur de containers avec de l\u0026rsquo;autoscaling, logger le nombre de pods dans le temps. Comparer le moment du scale-up au début du spike. L\u0026rsquo;écart est la latence de l\u0026rsquo;autoscaling, et c\u0026rsquo;est presque toujours plus long que ce que les équipes attendent.\nLe taux d\u0026rsquo;erreur par endpoint. Pendant un spike, certains endpoints cassent avant les autres. Tracer le taux d\u0026rsquo;erreur par endpoint pour identifier lequel est tombé en premier. C\u0026rsquo;est la prochaine cible à corriger.\n// Program.cs : exposer les métriques minimales nécessaires à un spike builder.Services.AddOpenTelemetry() .WithMetrics(metrics =\u0026gt; metrics .AddMeter(\u0026#34;Microsoft.AspNetCore.Hosting\u0026#34;) .AddMeter(\u0026#34;Microsoft.EntityFrameworkCore\u0026#34;) // durée des requêtes .AddMeter(\u0026#34;Npgsql\u0026#34;) // pool_in_use .AddRuntimeInstrumentation() // GC, thread pool .AddPrometheusExporter()); 💡 Info : La résolution temporelle par défaut de Grafana est de 15 ou 30 secondes, ce qui est trop grossier pour un spike de 90 secondes. Il faut passer l\u0026rsquo;intervalle de scrape à 1 seconde et le rafraîchissement du dashboard à 1 seconde pendant les spike tests. Sinon le graphe n\u0026rsquo;affichera que deux points sur tout le spike et rien ne sera diagnosticable.\nZoom : les quatre échecs courants sur un spike #Tempête de cache froid. Chaque requête tape le cache, chaque lookup rate, chaque miss tape la base, et la base voit 500 requêtes identiques concurrentes. Le correctif est la coalescence de requêtes ou un verrou autour de la réhydratation du cache, pour que seul le premier miss déclenche une requête base pendant que les autres attendent.\nÉpuisement du pool de connexions. Le pool Npgsql par défaut est plafonné à 100 connexions. Une instance qui gère 400 requêtes concurrentes pendant un spike en bloquera 300 en attente d\u0026rsquo;une connexion. Le correctif est soit un pool plus grand (si la base peut l\u0026rsquo;encaisser) soit un limiteur de concurrence devant l\u0026rsquo;endpoint (pour délester plutôt qu\u0026rsquo;empiler).\nLatence de l\u0026rsquo;autoscaling. L\u0026rsquo;autoscaler est configuré pour ajouter des pods quand le CPU dépasse 70%. Le spike envoie le CPU à 100% en 10 secondes, l\u0026rsquo;autoscaler réagit en 60 secondes, et le premier nouveau pod est prêt 45 secondes plus tard. Les 90 premières secondes du spike tournent avec la moitié de la capacité nécessaire. Le correctif est le pré-chauffage : faire tourner plus de capacité idle, ou utiliser un autoscaling prédictif, ou pré-scaler avant un événement attendu (solde de minuit).\nCoût de warmup JIT. Les premiers milliers de requêtes après un démarrage à froid sont servis par du code JIT tier-0, plus lent que le tier-1. Dans un spike, ces premiers milliers de requêtes arrivent en quelques secondes, et leur latence est deux à trois fois celle du baseline. Le correctif est la compilation ReadyToRun (R2R), l\u0026rsquo;AOT, ou un endpoint de warmup que l\u0026rsquo;orchestrateur appelle avant de déclarer le pod healthy.\n⚠️ Ça marche, mais\u0026hellip; : Un spike qui ne déclenche aucune de ces défaillances au premier run est généralement le signe que le système cible n\u0026rsquo;est pas configuré comme la prod le sera. Vérifier que le cache est réellement vide, que le pool de la base est à son défaut de prod, et que le compte de répliques correspond au minimum de prod. Sinon le test confirme la mauvaise chose.\n❌ Ne jamais faire : Ne pas lancer un spike test juste après un autre test de charge. Le système est chaud, les pools sont pleins, les caches sont peuplés. Un spike contre un système chaud ne dit rien. Attendre dix minutes d\u0026rsquo;idle, ou redémarrer la cible.\nZoom : quand lancer un spike #Les spike tests sont moins routiniers que les baselines mais plus ciblés. Trois déclencheurs :\nAvant un événement de trafic attendu. Un lancement de produit, une campagne marketing, une intégration externe connue qui va être mise en service. Si l\u0026rsquo;équipe sait qu\u0026rsquo;un spike arrive en prod, autant le répéter en pré-prod d\u0026rsquo;abord.\nAprès un changement de topologie de déploiement. De nouvelles règles d\u0026rsquo;autoscaling, un type d\u0026rsquo;instance différent, un nouveau backend de cache, une migration de base. Chacun peut changer le comportement en spike sans apparaître dans un baseline ou un stress.\nQuand un incident de prod dit \u0026ldquo;le trafic a sauté et on est tombés\u0026rdquo;. La suite est toujours un spike en pré-prod, avec la forme de trafic exacte de l\u0026rsquo;incident, et la configuration d\u0026rsquo;infrastructure exacte de l\u0026rsquo;incident. L\u0026rsquo;objectif est de reproduire la défaillance, la corriger, et prouver que le correctif marche.\nWrap-up #Un spike test est le seul test qui mesure comment un système survit à un saut soudain du calme à l\u0026rsquo;écrasement. On peut en monter un en k6 ou NBomber en un après-midi, partir d\u0026rsquo;un état idle (pas d\u0026rsquo;un état chaud), sauter de bas à haut en moins de trente secondes, maintenir au pic quelques minutes, déclencher optionnellement un second spike pour tester la capacité à encaisser tout de suite après, et surveiller le temps-jusqu\u0026rsquo;à-la-première-réponse, la croissance des pools, la latence d\u0026rsquo;autoscaling et le coût du cache froid avec un dashboard à résolution inférieure à la seconde. On peut en sortir en sachant lequel des quatre échecs spike courants le système rencontrerait, et planifier les correctifs (coalescence de requêtes, pools plus grands, pré-chauffage, compilation ReadyToRun) avant que la prochaine campagne marketing ne les rende urgents.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Le Baseline Testing en .NET : savoir à quoi ressemble la normale Le Soak Testing en .NET : les bugs qui n\u0026rsquo;apparaissent qu\u0026rsquo;après des heures Le Stress Testing en .NET : trouver le point de rupture et sa forme Références # Guide spike testing k6 Load simulations NBomber Compilation ReadyToRun, Microsoft Learn Horizontal Pod Autoscaler dans Kubernetes Google SRE book : Handling Overload ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/load-testing-spike/","section":"Posts","summary":"","title":"Le Spike Testing en .NET : survivre au burst soudain"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va découvrir le stress testing, le type de test qui pousse le système au-delà de ses limites pour en apprendre quelque chose d\u0026rsquo;utile.\nUn baseline dit à quoi ressemble la normale. Un soak dit si le système tient dans la durée. Aucun des deux ne répond à la question que la prod finit toujours par imposer : quand est-ce que ça casse, et comment. C\u0026rsquo;est le rôle du stress test. Pas de prouver que le système peut gérer une charge arbitraire (aucun système ne le peut), mais de caractériser la forme exacte de sa défaillance pour que l\u0026rsquo;équipe puisse concevoir autour.\nL\u0026rsquo;article d\u0026rsquo;ensemble a présenté les quatre types. L\u0026rsquo;article baseline a couvert le run de référence. Cet article couvre celui qui casse délibérément le système, qui apprend quelque chose de cette cassure, et qui en sort avec un plan de capacité concret.\nLe contexte : pourquoi le stress existe #Chaque système a un point au-delà duquel plus de trafic dégrade les choses au lieu de les améliorer. Ajouter une requête par seconde de plus commence à empiler du travail plus vite que les workers ne peuvent le traiter. La latence monte, puis grimpe fortement, puis le taux d\u0026rsquo;erreur commence à croître. Finalement, quelque chose cède : un pool de connexions sature, un thread pool meurt de faim, un disjoncteur s\u0026rsquo;ouvre, ou le process tombe en OOM et redémarre. L\u0026rsquo;équipe qui apprend ça en prod paie la leçon par un incident. L\u0026rsquo;équipe qui l\u0026rsquo;apprend en stress test paie la même leçon par un tableur.\nLes stress tests répondent à quatre questions qu\u0026rsquo;aucun autre type de test ne traite :\nOù est le point de rupture ? La charge (en RPS ou en utilisateurs concurrents) à laquelle la latence explose, le taux d\u0026rsquo;erreur grimpe, ou le process lâche. Le chiffre lui-même est utile pour le capacity planning. Quelle est la forme de la défaillance ? Dégradation linéaire, exponentielle, effondrement en falaise, et défaillance en cascade demandent tous des réponses différentes. La forme est plus actionnable que le chiffre brut. Quel composant cède en premier ? Est-ce le pool de connexions base, le thread pool, la mémoire, l\u0026rsquo;API en aval, le rate limiter ? Le premier goulet d\u0026rsquo;étranglement est celui qui vaut la peine d\u0026rsquo;être corrigé. Est-ce que le système récupère ? Une fois le stress retiré, le système revient-il à une latence et un débit sains, ou reste-t-il dégradé et demande un redémarrage ? Le comportement de récupération compte autant que le point de rupture. Sans stress test, le capacity planning est de la devinette. Avec un, l\u0026rsquo;équipe a un chiffre, une forme, et un profil de récupération.\nVue d\u0026rsquo;ensemble : la forme d\u0026rsquo;un run stress # graph LR A[Charge baseline50 VUs] --\u003e B[Rampe+50 VUs toutes les 2 min] B --\u003e C[Observationpoint de rupture] C --\u003e D[Maintien au-dessusdu seuil 1-2 min] D --\u003e E[Rampe descendanteobservation de la récupération] Un stress test est une rampe contrôlée, pas un burst soudain. Le système démarre à la charge baseline, monte par paliers mesurés, et le test capture le point auquel l\u0026rsquo;objectif de service pré-défini est franchi. Ce point est le point de rupture. La rampe continue un court moment au-delà pour caractériser le mode de défaillance, puis descend pour observer la récupération.\nTrois règles façonnent un run stress utile :\nMonter, pas sauter. Un burst soudain est un test de spike, qui est une autre question. Un stress veut voir la pente de la dégradation, ce qui demande une montée progressive et mesurée.\nDéfinir l\u0026rsquo;échec avant le run. \u0026ldquo;Le système est cassé\u0026rdquo; n\u0026rsquo;est pas une affirmation objective. Décider à l\u0026rsquo;avance : par exemple, le point de rupture est atteint quand le p95 dépasse 1 seconde ou que le taux d\u0026rsquo;erreur dépasse 5%. Sans ça, l\u0026rsquo;équipe va discuter des résultats après coup.\nToujours redescendre en rampe. Observer comment le système récupère (ou pas) représente la moitié de la valeur du test. Un stress qui coupe le trafic au pic et rapporte \u0026ldquo;on a tenu 5000 RPS\u0026rdquo; n\u0026rsquo;a rien appris sur la capacité réelle que la prod pourrait soutenir.\nZoom : un run stress avec k6 #import http from \u0026#39;k6/http\u0026#39;; import { check, sleep, group } from \u0026#39;k6\u0026#39;; export const options = { stages: [ { duration: \u0026#39;2m\u0026#39;, target: 50 }, // maintien baseline { duration: \u0026#39;2m\u0026#39;, target: 100 }, // +50 VUs { duration: \u0026#39;2m\u0026#39;, target: 150 }, { duration: \u0026#39;2m\u0026#39;, target: 200 }, { duration: \u0026#39;2m\u0026#39;, target: 300 }, { duration: \u0026#39;2m\u0026#39;, target: 400 }, { duration: \u0026#39;2m\u0026#39;, target: 500 }, { duration: \u0026#39;2m\u0026#39;, target: 500 }, // maintien au pic { duration: \u0026#39;3m\u0026#39;, target: 0 }, // descente, observation récupération ], thresholds: { // Ces seuils sont la définition de l\u0026#39;échec. // Un seuil franchi fait échouer le run, ce qui est attendu au-delà du point de rupture. \u0026#39;http_req_duration{group:::hot}\u0026#39;: [\u0026#39;p(95)\u0026lt;1000\u0026#39;], \u0026#39;http_req_failed\u0026#39;: [\u0026#39;rate\u0026lt;0.05\u0026#39;], }, }; const BASE = __ENV.BASE_URL || \u0026#39;https://shop.preprod.internal\u0026#39;; export default function () { group(\u0026#39;hot\u0026#39;, () =\u0026gt; { http.get(`${BASE}/api/products/featured`); }); if (Math.random() \u0026lt; 0.3) { group(\u0026#39;write\u0026#39;, () =\u0026gt; { http.post(`${BASE}/api/cart`, JSON.stringify({ productId: \u0026#39;SKU-1\u0026#39;, quantity: 1, }), { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; } }); }); } sleep(0.5); } Un plafond à 500 VUs, atteint en six paliers de +50 à +100 VUs chacun. Chaque palier dure deux minutes, ce qui est assez long pour que le système se stabilise à ce niveau de charge avant le palier suivant. La descente est courte et délibérée : trois minutes du pic à zéro, et c\u0026rsquo;est là que le comportement de récupération est capturé.\n✅ Bonne pratique : Choisir la taille des paliers pour que la rampe entière prenne 15 à 25 minutes. Les runs plus courts manquent le comportement en régime stable à chaque niveau. Les runs plus longs brûlent du budget et rendent le résultat difficile à interpréter.\nZoom : le même run avec NBomber #using NBomber.CSharp; using NBomber.Http; using NBomber.Http.CSharp; using var httpClient = new HttpClient { BaseAddress = new Uri(\u0026#34;https://shop.preprod.internal\u0026#34;) }; var scenario = Scenario.Create(\u0026#34;hot_path\u0026#34;, async context =\u0026gt; { var request = Http.CreateRequest(\u0026#34;GET\u0026#34;, \u0026#34;/api/products/featured\u0026#34;); return await Http.Send(httpClient, request); }) .WithLoadSimulations( Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(2)), Simulation.RampingConstant(copies: 100, during: TimeSpan.FromMinutes(2)), Simulation.RampingConstant(copies: 200, during: TimeSpan.FromMinutes(2)), Simulation.RampingConstant(copies: 300, during: TimeSpan.FromMinutes(2)), Simulation.RampingConstant(copies: 400, during: TimeSpan.FromMinutes(2)), Simulation.RampingConstant(copies: 500, during: TimeSpan.FromMinutes(2)), Simulation.KeepConstant(copies: 500, during: TimeSpan.FromMinutes(2)), Simulation.RampingConstant(copies: 0, during: TimeSpan.FromMinutes(3)) ); NBomberRunner.RegisterScenarios(scenario) .WithReportFormats(ReportFormat.Html, ReportFormat.Csv) .WithReportFolder(\u0026#34;./reports/stress\u0026#34;) .Run(); Même profil en escalier, exprimé sous forme de liste de stages LoadSimulation. Le rapport HTML de NBomber trace la latence et le débit par palier, ce qui est exactement la forme qu\u0026rsquo;un stress test doit produire.\nZoom : identifier le point de rupture #Le point de rupture n\u0026rsquo;est pas toujours évident depuis un seul graphe. C\u0026rsquo;est l\u0026rsquo;intersection de trois signaux.\nLa courbe p95 de latence. Tracer le p95 en fonction du nombre de VUs. Dans un système sain, la courbe est quasiment plate, puis commence à monter, puis monte fortement. Le point de rupture est là où la montée devient super-linéaire, en général visible comme un point d\u0026rsquo;inflexion sur le graphe.\nLa courbe du taux d\u0026rsquo;erreur. Tracer le taux d\u0026rsquo;erreur en fonction du nombre de VUs. Dans la plupart des systèmes .NET, le taux d\u0026rsquo;erreur reste proche de zéro jusqu\u0026rsquo;au point de rupture, puis monte vite. Si le taux d\u0026rsquo;erreur commence à monter avant la latence, le goulet est une limite dure (un pool de connexions, un rate limiter, un disjoncteur). Si la latence monte d\u0026rsquo;abord, le goulet est une limite molle (CPU, mémoire, file d\u0026rsquo;attente du thread pool).\nLa courbe du débit. Tracer le RPS réussi en fonction du nombre de VUs. Dans un système sain, le débit croît avec les VUs, puis plafonne au maximum du système. Dans un système qui défaille, le débit atteint un pic, puis baisse à mesure que le système passe plus de temps à gérer des échecs qu\u0026rsquo;à traiter du vrai travail. La baisse est le signal le plus actionnable : elle signifie que le système fait pire sous plus de charge, pas juste moins bien.\nL\u0026rsquo;intersection de ces trois courbes donne un chiffre défendable : \u0026ldquo;le système supporte 320 RPS avant que le p95 ne dépasse 1 seconde et que le taux d\u0026rsquo;erreur ne dépasse 1%\u0026rdquo;. Ce chiffre est utilisable pour le capacity planning, pour la négociation de contrats, et pour le sizing du déploiement.\nZoom : la forme de la défaillance #La courbe elle-même compte autant que le chiffre. Quatre formes de défaillance sont courantes.\nDégradation linéaire. La latence monte doucement, le taux d\u0026rsquo;erreur reste proche de zéro, le débit plafonne proprement. La meilleure forme possible, parce qu\u0026rsquo;elle veut dire que l\u0026rsquo;équipe peut scaler horizontalement de façon linéaire pour suivre la demande et prédire le comportement au-delà du point de rupture. Indique en général un système CPU-bound avec des pools bien paramétrés.\nCourbe en coude. La latence est plate, puis se plie brutalement vers le haut à un niveau de charge précis. Indique une limite de ressource dure : un pool de connexions qui atteint son max, une tempête de cache miss, un thread pool qui sature. Le correctif est en général un seul changement de configuration, une fois la ressource identifiée.\nEffondrement en falaise. La latence est plate, tout a l\u0026rsquo;air bien, puis le système s\u0026rsquo;effondre en 30 secondes : les erreurs explosent, le débit tombe à zéro. Indique une défaillance en cascade : un disjoncteur qui s\u0026rsquo;ouvre et affame un service dépendant, un deadlock qui se propage à travers les requêtes, un OOM qui redémarre le process. Les défaillances en falaise sont les plus dangereuses parce qu\u0026rsquo;il n\u0026rsquo;y a aucun avertissement avant l\u0026rsquo;incident.\nSpirale de la mort. La latence monte, puis le débit baisse, puis la latence monte encore parce que les retries s\u0026rsquo;empilent sur un système déjà surchargé. Le système empire à mesure qu\u0026rsquo;il reçoit du trafic, même si le trafic arrête de croître. Le correctif est en général de la backpressure ou du load shedding, pas plus de capacité.\n💡 Info : Le runtime .NET a un limiteur de concurrence natif (Microsoft.AspNetCore.RateLimiting, disponible depuis .NET 7) conçu précisément pour prévenir les spirales de la mort. Ajouter un rate limiter basé sur une file devant les endpoints sensibles transforme une spirale de la mort en rejet contrôlé, qui est beaucoup plus facile à raisonner.\nZoom : la récupération #Une fois la descente commencée, la question passe de \u0026ldquo;jusqu\u0026rsquo;où c\u0026rsquo;est descendu\u0026rdquo; à \u0026ldquo;est-ce que le système revient\u0026rdquo;. Trois issues.\nRécupération propre. En quelques secondes après la baisse de charge, la latence revient au baseline, le taux d\u0026rsquo;erreur revient à zéro, le débit suit la demande. C\u0026rsquo;est le résultat attendu, et il confirme que le système peut se délester de la charge sans effet secondaire.\nRécupération lente. La latence met plusieurs minutes à revenir au baseline, même après la baisse de charge. Indique en général que quelque chose est encore en train de se drainer : une file qui a accumulé du backlog, un pool de connexions qui libère lentement ses connexions coincées, un cache qui se reconstruit à froid après une tempête d\u0026rsquo;invalidation. Le temps de récupération est lui-même une métrique, et c\u0026rsquo;est souvent là que le coût caché de la défaillance vit.\nPas de récupération. La latence reste élevée, ou le système continue de renvoyer des erreurs, même à charge zéro. Indique des dommages permanents : un thread fuité qui tient un verrou, une state machine async en deadlock, un disjoncteur bloqué ouvert, un cache qui ne peut pas se réhydrater. Le process a besoin d\u0026rsquo;un redémarrage pour retrouver la santé, et c\u0026rsquo;est une information que l\u0026rsquo;équipe doit avoir avant que la même défaillance ne se produise en prod.\n⚠️ Ça marche, mais\u0026hellip; : Un stress qui ne mesure que le RPS de pointe sans mesurer la récupération ne rapporte que la moitié de l\u0026rsquo;histoire. L\u0026rsquo;équipe peut atteindre un pic que la prod ne peut pas, si la récupération après ce pic est impossible. Le capacity planning doit tenir compte de la marge nécessaire pour éviter le pic, pas seulement du pic lui-même.\n❌ Ne jamais faire : Ne pas lancer un stress test contre la prod sans un blast radius strict et une condition d\u0026rsquo;arrêt convenue à l\u0026rsquo;avance. Les stress tests sont faits pour casser des choses, et la base de prod n\u0026rsquo;est pas l\u0026rsquo;endroit pour découvrir ce qui casse.\nWrap-up #Un stress test est le seul test qui produit un chiffre de capacité que l\u0026rsquo;équipe peut réellement défendre. On peut en monter un en k6 ou NBomber en un après-midi, utiliser une rampe en escalier de 15 à 25 minutes, définir la condition d\u0026rsquo;échec avant le run pour éviter de discuter des résultats après coup, capturer les courbes de latence, de taux d\u0026rsquo;erreur et de débit côte à côte, identifier la forme de la défaillance, et toujours inclure une phase de descente pour mesurer la récupération. On peut sortir d\u0026rsquo;un stress avec un chiffre défendable pour le capacity planning, un premier goulet d\u0026rsquo;étranglement nommé à corriger, et de la confiance sur le comportement du système quand le trafic de prod dépasse les limites attendues.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Le Baseline Testing en .NET : savoir à quoi ressemble la normale Le Soak Testing en .NET : les bugs qui n\u0026rsquo;apparaissent qu\u0026rsquo;après des heures Références # Patterns de stress testing k6 Load simulations NBomber Middleware de rate limiting ASP.NET Core, Microsoft Learn Google SRE book : Addressing Cascading Failures ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/load-testing-stress/","section":"Posts","summary":"","title":"Le Stress Testing en .NET : trouver le point de rupture et sa forme"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier les tests de charge en .NET, et plus particulièrement les quatre types qui structurent une vraie stratégie de performance.\nLes tests unitaires, les tests d\u0026rsquo;intégration, les tests d\u0026rsquo;API et les tests end-to-end partagent tous une hypothèse silencieuse : ils tournent un utilisateur à la fois. Cette hypothèse est confortable, productive, et complètement aveugle à la question que la prod finit toujours par poser : \u0026ldquo;que se passe-t-il quand mille utilisateurs arrivent en même temps ?\u0026rdquo;. Les tests de charge existent pour répondre à cette question avant que la réponse ne soit un appel téléphonique à 3 heures du matin.\nLa série Testing a couvert la correction à travers toute la pyramide, des tests unitaires aux tests d\u0026rsquo;intégration avec TestContainers, aux tests d\u0026rsquo;API avec WebApplicationFactory, et aux tests end-to-end avec Playwright. Les tests de charge sont sur un axe totalement différent. Ils ne demandent pas \u0026ldquo;est-ce que la logique marche\u0026rdquo;, ils demandent \u0026ldquo;est-ce que la logique tient sous concurrence, sous trafic soutenu, sous des bursts soudains, et au-delà de la capacité prévue\u0026rdquo;. Quatre questions, quatre types de tests, quatre articles dans cette série. Cet article est la carte.\nLe contexte : pourquoi les tests de charge existent #L\u0026rsquo;excuse classique pour les sauter a longtemps été \u0026ldquo;on scalera quand ce sera nécessaire\u0026rdquo;. Ça marche jusqu\u0026rsquo;au jour où une campagne marketing, un moment viral, ou une intégration avec un partenaire devenu populaire envoient dix fois le trafic habituel en trente secondes. À ce moment-là, l\u0026rsquo;équipe découvre, d\u0026rsquo;un coup, que le pool de connexions à la base est plafonné à 100, que le cache ne se reconstruit pas proprement quand tout le monde tape en même temps, qu\u0026rsquo;un framework de log tient un verrou qui sérialise chaque requête, et que l\u0026rsquo;autoscaler prend quatre minutes pour réagir à un burst qui en dure deux.\nLes tests de charge font remonter tout cela avant l\u0026rsquo;incident. Plus concrètement, ils répondent à quatre questions précises que la prod finira par poser :\nÀ quoi ressemble \u0026ldquo;la normale\u0026rdquo; ? Sans point de référence, il est impossible de détecter qu\u0026rsquo;un déploiement a dégradé les choses. Est-ce que le système se dégrade gracieusement sur des heures ou des jours ? Fuites mémoire, épuisement de pool, bugs de rotation de logs, dérive de cache : tout cela n\u0026rsquo;apparaît qu\u0026rsquo;après une exécution prolongée. Où le système casse-t-il, et comment ? Comprendre le mode de défaillance est aussi important que de connaître le point de rupture. Comment le système réagit-il à un burst soudain ? L\u0026rsquo;autoscaling, la backpressure, la profondeur de file, et les caches froids se comportent différemment selon qu\u0026rsquo;on monte progressivement ou qu\u0026rsquo;on passe de 0 à 100 en quelques secondes. Chaque question a son type de test dédié. Aucun ne remplace les autres.\nVue d\u0026rsquo;ensemble : les quatre types # graph TD A[Tests de charge] --\u003e B[BaselineÉtablir la normalerégime stable] A --\u003e C[SoakLongue duréecharge modérée] A --\u003e D[StressAu-delà de la capacitétrouver le point de rupture] A --\u003e E[SpikeBurst soudainde faible à très élevé] B --\u003e B1[Référencepour la régression] C --\u003e C1[Fuites, pool épuisé,logs, dérive cache] D --\u003e D1[Point de rupture,capacity planning] E --\u003e E1[Réactivité autoscale,cache froid, backpressure] Le baseline fait tourner le système sous le trafic qu\u0026rsquo;il est censé gérer au quotidien, assez longtemps pour produire des chiffres stables. La sortie est un jeu de métriques de référence : requêtes par seconde, percentiles de latence, taux d\u0026rsquo;erreur, CPU, mémoire, usage du pool de base de données. Chaque test de charge ultérieur est comparé à cette référence.\nLe soak fait tourner la même charge modérée pendant des heures, souvent toute une nuit, parfois plusieurs jours. Son rôle n\u0026rsquo;est pas de mesurer le débit de pointe, c\u0026rsquo;est de vérifier que le système ne se dégrade pas avec le temps. Les fuites mémoire, l\u0026rsquo;épuisement du pool de connexions, la croissance des fichiers de log, la dérive de l\u0026rsquo;invalidation de cache, et l\u0026rsquo;accumulation de tâches de fond, tout cela apparaît ici et nulle part ailleurs.\nLe stress pousse le système au-delà de sa capacité prévue, et continue à pousser jusqu\u0026rsquo;à ce que quelque chose cède. L\u0026rsquo;objectif n\u0026rsquo;est pas de prouver que le système peut supporter une charge infinie, c\u0026rsquo;est de caractériser le mode de défaillance : la latence grandit-elle linéairement, puis explose-t-elle ? Le taux d\u0026rsquo;erreur monte-t-il avant la latence ? Le système récupère-t-il proprement quand le stress est retiré ?\nLe spike part d\u0026rsquo;un état calme et monte à une charge très élevée en quelques secondes. C\u0026rsquo;est le test qui met en évidence la latence de l\u0026rsquo;autoscaling, les pénalités de cache froid, la gestion des bursts de connexions, et le coût de chauffe des chemins de code JIT. Un système qui encaisse parfaitement une montée progressive peut quand même s\u0026rsquo;effondrer sur un spike.\nChacun de ces types a son propre article dans la série. Le reste de cette vue d\u0026rsquo;ensemble couvre le vocabulaire partagé et les choix d\u0026rsquo;outillage qui s\u0026rsquo;appliquent aux quatre.\nZoom : les métriques qui comptent #Chaque test de charge, quel que soit son type, doit rapporter le même jeu de chiffres. Si l\u0026rsquo;un d\u0026rsquo;eux manque, le test est incomplet.\nLe débit mesuré en requêtes par seconde (RPS). Le compte brut du travail que le système traite par unité de temps. Un RPS élevé n\u0026rsquo;a de sens que couplé à la métrique suivante.\nLes percentiles de latence : p50, p95, p99, et p99.9. La latence moyenne n\u0026rsquo;est presque jamais utile, parce qu\u0026rsquo;un système où 90% des requêtes prennent 20 ms et 10% prennent 2 secondes a la même moyenne qu\u0026rsquo;un système où chaque requête prend 220 ms, et les deux ne sont pas équivalents pour un utilisateur. Rapporter des percentiles, toujours.\nLe taux d\u0026rsquo;erreur, ventilé par code de statut. Un test qui maintient un p95 sous 100 ms tout en renvoyant silencieusement 4% des requêtes en 500 n\u0026rsquo;est pas un test qui passe, c\u0026rsquo;est un test qui induit en erreur.\nLes signaux de saturation du runtime .NET et de l\u0026rsquo;infrastructure : CPU, mémoire, temps de pause GC (gen0/1/2), longueur de la file du thread pool, temps d\u0026rsquo;attente sur le pool de connexions de la base, nombre de connexions HTTP client. Ces signaux disent pourquoi la latence a monté, ce qui est la moitié actionnable de l\u0026rsquo;information.\nLa corrélation avec les transactions métier, pas seulement les endpoints HTTP. Un test qui rapporte \u0026ldquo;POST /orders p95 à 300 ms\u0026rdquo; est moins utile qu\u0026rsquo;un test qui rapporte \u0026ldquo;le tunnel de checkout (ajout au panier, remise, soumission, confirmation de paiement) p95 à 1,2 s\u0026rdquo;. L\u0026rsquo;expérience utilisateur est la composition des endpoints individuels, pas un seul d\u0026rsquo;entre eux.\n💡 Info : Dans .NET moderne (8+), System.Diagnostics.Metrics et l\u0026rsquo;histogramme natif http.server.request.duration exposent ces chiffres nativement. Les envoyer vers Prometheus et Grafana se fait en deux lignes de configuration, et c\u0026rsquo;est la fondation de tout ce qui est décrit dans cette série.\nZoom : le paysage des outils en 2026 #Deux outils compatibles .NET couvrent 90% des cas réels, et le choix entre les deux se joue surtout sur l\u0026rsquo;endroit où vit le code de test.\nk6 (Grafana Labs) est le standard actuel de l\u0026rsquo;industrie. Les tests s\u0026rsquo;écrivent en JavaScript, tournent sur un runner en Go, et passent à l\u0026rsquo;échelle de centaines de milliers d\u0026rsquo;utilisateurs virtuels depuis une seule machine. k6 s\u0026rsquo;intègre proprement avec Grafana pour la visualisation, avec Prometheus comme puits de métriques, et avec la plupart des systèmes de CI. Il est agnostique du langage, ce qui est un atout si l\u0026rsquo;équipe livre plusieurs stacks backend, et un point neutre si elle ne livre que du .NET.\n// k6 : un test baseline, 50 utilisateurs virtuels pendant 5 minutes import http from \u0026#39;k6/http\u0026#39;; import { check, sleep } from \u0026#39;k6\u0026#39;; export const options = { vus: 50, duration: \u0026#39;5m\u0026#39;, thresholds: { http_req_duration: [\u0026#39;p(95)\u0026lt;300\u0026#39;], http_req_failed: [\u0026#39;rate\u0026lt;0.01\u0026#39;], }, }; export default function () { const res = http.get(\u0026#39;https://shop.test/api/orders\u0026#39;); check(res, { \u0026#39;status is 200\u0026#39;: (r) =\u0026gt; r.status === 200 }); sleep(1); } NBomber est l\u0026rsquo;option native .NET. Les tests s\u0026rsquo;écrivent en C# ou F#, vivent dans un projet .NET normal, partagent les types avec l\u0026rsquo;application sous test, et tournent depuis dotnet test ou un host console. L\u0026rsquo;avantage est que la suite de tests de charge est du code que l\u0026rsquo;équipe sait déjà lire, relire et refactorer.\n// NBomber : même baseline, écrit en C# using NBomber.CSharp; using NBomber.Http; using NBomber.Http.CSharp; var scenario = Scenario.Create(\u0026#34;get_orders\u0026#34;, async context =\u0026gt; { var response = await Http.CreateRequest(\u0026#34;GET\u0026#34;, \u0026#34;https://shop.test/api/orders\u0026#34;) .WithHeader(\u0026#34;Accept\u0026#34;, \u0026#34;application/json\u0026#34;) .SendAsync(httpClient, context); return response; }) .WithLoadSimulations( Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(5))); NBomberRunner.RegisterScenarios(scenario).Run(); Les deux sont de qualité production. Pour les équipes .NET qui préfèrent garder tout en C#, NBomber est le choix qui a le moins de friction. Pour les équipes qui veulent la communauté et l\u0026rsquo;écosystème les plus larges, k6 est le pari le plus sûr. JMeter, Gatling, Artillery et Locust existent et ont des cas d\u0026rsquo;usage légitimes, mais pour un projet .NET greenfield en 2026, k6 ou NBomber est la recommandation par défaut.\n✅ Bonne pratique : Écrire le code des tests de charge dans le même repository que l\u0026rsquo;application, à côté des tests d\u0026rsquo;intégration. Les tests de charge font partie du codebase, ce ne sont pas un dossier à part sur le poste de quelqu\u0026rsquo;un.\nZoom : où les tests de charge tournent #Un test de charge contre un poste de développeur est presque toujours sans valeur. Le réseau, la base locale, le CPU partagé avec tous les IDE et navigateurs ouverts, et l\u0026rsquo;absence d\u0026rsquo;infrastructure réaliste faussent tous le résultat. Les environnements utiles sont :\nUn environnement de pré-prod dédié qui reflète le sizing et la topologie de la prod. C\u0026rsquo;est la cible par défaut pour les tests baseline, soak et spike. Un clone de la prod, monté pour une fenêtre de test planifiée. Plus coûteux, plus précis, réservé aux tests de stress et aux exercices de capacity planning. La prod elle-même, avec un sous-ensemble de trafic contrôlé, pour les équipes avancées qui pratiquent le load testing continu. Cela demande une maturité d\u0026rsquo;observabilité que la plupart des équipes n\u0026rsquo;ont pas, et ce n\u0026rsquo;est pas le point de départ. Pour la plupart des équipes, la bonne réponse est un environnement de pré-prod provisionné depuis le même Infrastructure-as-Code que la prod, avec la même classe de sizing de base, le même cache, et les mêmes dépendances démarrées via TestContainers quand un vrai service managé n\u0026rsquo;est pas disponible.\n⚠️ Ça marche, mais\u0026hellip; : Faire tourner des tests de charge contre une base cloud en free tier ou contre un petit container de dev va produire des chiffres qui ont l\u0026rsquo;air terribles par rapport à la prod, ou pire, des chiffres qui ont l\u0026rsquo;air excellents et qui sont totalement faux. L\u0026rsquo;attention doit autant porter sur le sizing de la cible que sur celui du générateur de charge.\nZoom : ce que les tests de charge n\u0026rsquo;attrapent pas #Les tests de charge ne remplacent aucune autre couche de la pyramide de tests. Ils n\u0026rsquo;attrapent pas :\nLes bugs de logique : le calculator peut être faux et tenir 10 000 RPS. C\u0026rsquo;est un problème de test unitaire. Les trous d\u0026rsquo;autorisation : un contrôle de rôle cassé est rapide. Rapide et faux est pire que lent et correct. C\u0026rsquo;est un problème de test WebApplicationFactory. La correction des migrations de données : un test de charge contre une migration cassée échouera simplement avec des données cassées. Les migrations se testent d\u0026rsquo;abord contre une vraie base, via les tests d\u0026rsquo;intégration avec TestContainers. Les race conditions au niveau UI : ce sont des tests Playwright E2E. Les tests de charge se posent sur un système correct, pas à la place d\u0026rsquo;un système correct. Les lancer avant que le reste de la pyramide soit au vert est une perte de temps pour le générateur de charge, et une source de fausse confiance.\nWrap-up #Tu as maintenant la carte des quatre types de tests de charge qui comptent pour un système .NET : le baseline pour établir ce que \u0026ldquo;la normale\u0026rdquo; veut dire, le soak pour vérifier que le système tient dans la durée, le stress pour trouver le point de rupture et sa forme, et le spike pour valider l\u0026rsquo;autoscaling et la gestion des bursts. Tu peux choisir k6 ou NBomber comme runner par défaut, capturer le débit, les percentiles de latence, le taux d\u0026rsquo;erreur et les signaux de saturation pour chaque test, et les lancer contre un environnement de pré-prod qui reflète réellement la prod.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles Tests d\u0026rsquo;Intégration avec TestContainers pour .NET Tests API avec WebApplicationFactory en ASP.NET Core Tests End-to-End avec Playwright pour .NET Références # Documentation k6 Documentation NBomber Métriques ASP.NET Core, Microsoft Learn Diagnostics et performance .NET, Microsoft Learn Google SRE book : Handling Overload ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/load-testing-overview/","section":"Posts","summary":"","title":"Les Tests de Charge en .NET : vue d'ensemble des quatre types qui comptent"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier les tests unitaires en .NET.\nLes tests unitaires sont les tests les moins chers à écrire, et les premiers à se dégrader quand personne ne les entretient. Une suite de tests qui casse au moindre renommage de variable, qui mocke tout jusqu\u0026rsquo;à en perdre son sens, et qui met quatre secondes pour une seule assertion, apporte moins de valeur que pas de tests du tout. L\u0026rsquo;objectif de cet article est d\u0026rsquo;aider à écrire l\u0026rsquo;autre catégorie : des tests rapides, ciblés, et sur lesquels on peut réellement s\u0026rsquo;appuyer pendant un refactoring.\nL\u0026rsquo;histoire du testing .NET est mature. xUnit.net a été lancé par James Newkirk en 2007, après qu\u0026rsquo;il ait co-créé NUnit, comme une réécriture qui nettoyait dix ans d\u0026rsquo;habitudes accumulées. C\u0026rsquo;est devenu le framework de test par défaut dans les templates ASP.NET Core vers 2016, et .NET 10 livre xUnit v3 comme version majeure courante. Autour, FluentAssertions (pour des asserts lisibles), NSubstitute ou Moq (pour les mocks), et Bogus (pour générer des données de test) composent la boîte à outils standard.\nLe contexte : pourquoi les tests unitaires existent #Les tests unitaires répondent à quatre problèmes concrets qu\u0026rsquo;aucune autre couche de la pyramide de tests ne traite aussi efficacement.\n1. Ils protègent contre la régression dans le temps. C\u0026rsquo;est leur raison d\u0026rsquo;être principale. Une équipe livre un moteur de pricing au mois 1. Au mois 2, un palier de remise par volume est ajouté. Au mois 4, un multiplicateur de fidélité interagit avec. Au mois 9, un nouvel arrivant refactore un helper et casse, sans s\u0026rsquo;en rendre compte, l\u0026rsquo;interaction entre les paliers et la fidélité. Sans tests unitaires, la régression atteint la prod, et l\u0026rsquo;équipe la découvre par une réclamation client. Avec des tests unitaires, le changement ne se merge jamais : le test qui fixait le comportement du mois 2 échoue en moins d\u0026rsquo;une seconde, sur le poste de la personne qui a fait le changement.\n2. Ils détectent tôt les god classes et les god methods. Une méthode difficile à tester en unitaire n\u0026rsquo;a presque jamais un problème de test, elle a un problème de conception. Quand un seul test demande quinze mocks, quatre pages d\u0026rsquo;arrange et une dizaine d\u0026rsquo;assertions pour couvrir un seul appel, le test nous dit que la méthode sous test fait trop de choses à la fois. La bonne réponse n\u0026rsquo;est pas d\u0026rsquo;écrire le test géant. C\u0026rsquo;est de découper la méthode. Les tests unitaires agissent comme un système d\u0026rsquo;alerte précoce sur les god classes et les god methods, bien avant qu\u0026rsquo;un rapport de qualité de code ne les signale.\n3. Ils testent la logique des méthodes, et rien d\u0026rsquo;autre. Les tests unitaires sont le bon outil quand la question est \u0026ldquo;est-ce que ce morceau de logique calcule le bon résultat pour un input donné\u0026rdquo;. Les requêtes base de données, les pipelines HTTP, les middlewares, la sérialisation, l\u0026rsquo;authentification : ce n\u0026rsquo;est pas de la logique de méthode, ce sont des comportements d\u0026rsquo;infrastructure. Ils relèvent des tests d\u0026rsquo;intégration, des tests d\u0026rsquo;API ou du E2E, pas d\u0026rsquo;ici. Garder le périmètre à la logique pure est ce qui rend les tests unitaires rapides, stables et dignes de confiance.\n4. La cerise sur le gâteau pour les conceptions orientées domaine. Quand la logique métier vit dans un agrégat bien conçu et non-anémique (c\u0026rsquo;est-à-dire une entité qui fait respecter ses propres invariants au lieu d\u0026rsquo;exposer des setters publics manipulés par un service), les tests unitaires deviennent exceptionnellement propres. L\u0026rsquo;agrégat contient les règles, les tests contiennent les scénarios, et il n\u0026rsquo;y a rien d\u0026rsquo;autre à câbler. C\u0026rsquo;est l\u0026rsquo;argument le plus fort pour garder la logique à l\u0026rsquo;intérieur du domaine plutôt que de la disperser entre services, mappers et validators. Un article dédié au DDD et aux agrégats reviendra sur ce point en profondeur.\nQue faut-il tester #Un défaut raisonnable pour toute méthode qui contient de la vraie logique : le cas nominal (happy path), les cas limites, et les cas d\u0026rsquo;échec. Ces trois catégories couvrent la quasi-totalité des bugs qui valent la peine d\u0026rsquo;être attrapés.\nLe cas nominal (aussi appelé happy path) : l\u0026rsquo;exécution normale et réussie avec des inputs valides. Un test par méthode, minimum. Les cas limites : les frontières où le comportement bascule. Une quantité de 0, de 1, exactement le seuil de remise, une collection vide, un champ optionnel null, la valeur maximale autorisée, le premier jour d\u0026rsquo;un mois, une année bissextile. Les cas d\u0026rsquo;échec : ce qui se passe quand un invariant est violé. Une quantité négative, une commande déjà soumise qu\u0026rsquo;on tente de soumettre à nouveau, un remboursement qui dépasse le montant initial. Le test vérifie que la bonne exception (ou le bon Result.Failure) revient, pas un état à moitié corrompu. Au-delà de ce socle, deux règles supplémentaires gagnent leur place dans toute équipe sérieuse.\nChaque bug en prod devrait ajouter un test. Quand un bug est trouvé en prod, la correction est incomplète tant qu\u0026rsquo;un test capable de l\u0026rsquo;attraper n\u0026rsquo;existe pas. C\u0026rsquo;est le seul moyen durable pour que la protection contre la régression s\u0026rsquo;accumule. Une suite de tests qui grandit d\u0026rsquo;un test par incident devient, au fil des années, une cartographie de tout ce qui a déjà mal tourné, et l\u0026rsquo;équipe hérite de cette connaissance gratuitement.\nLes garde-fous et l\u0026rsquo;autorisation méritent leurs propres tests. La programmation défensive n\u0026rsquo;est pas complète tant qu\u0026rsquo;elle n\u0026rsquo;est pas vérifiée. Pour chaque opération sensible au rôle, il faut écrire la paire : \u0026ldquo;en tant qu\u0026rsquo;admin, l\u0026rsquo;action est autorisée\u0026rdquo; et \u0026ldquo;en tant qu\u0026rsquo;utilisateur simple, l\u0026rsquo;action est refusée\u0026rdquo;. Même chose pour l\u0026rsquo;isolation de tenant, les vérifications de propriétaire, et les limites de débit. Ce sont les règles qui sont cassées en silence lors d\u0026rsquo;un refactoring et découvertes lors d\u0026rsquo;un audit.\nPour les applications principalement CRUD, la même catégorisation s\u0026rsquo;applique, mais l\u0026rsquo;accent change :\nLes opérations d\u0026rsquo;écriture avec logique métier : règles de validation, invariants croisés, transitions d\u0026rsquo;état. À tester au niveau unitaire, avec l\u0026rsquo;agrégat ou le service comme SUT. Les opérations de lecture avec transformation : projection d\u0026rsquo;entité vers DTO, agrégation, champs calculés, formatage. Tester la transformation elle-même. Le CRUD pur passe-plat (controller vers repository vers base, sans logique au milieu) n\u0026rsquo;a pas besoin de test unitaire. Il a besoin d\u0026rsquo;un test d\u0026rsquo;intégration qui prouve que l\u0026rsquo;aller-retour fonctionne. Les tests unitaires couvrent tout ce qui précède, à condition de rester correctement scopés. Dès qu\u0026rsquo;un \u0026ldquo;test unitaire\u0026rdquo; démarre une base de données, un hôte web ou le système de fichiers, ce n\u0026rsquo;est plus un test unitaire, c\u0026rsquo;est un test d\u0026rsquo;intégration lent. C\u0026rsquo;est un autre outil, pour un autre rôle.\nVue d\u0026rsquo;ensemble : les pièces #Avant de rentrer dans le code, voici les outils qu\u0026rsquo;une suite de tests unitaires .NET utilise vraiment en 2026 :\ngraph TD A[Projet de testxUnit v3] --\u003e B[AssertionsFluentAssertions ou Shouldly] A --\u003e C[Mocks / FakesNSubstitute ou Moq] A --\u003e D[Données de testBogus, AutoFixture] A --\u003e E[SUTSystem Under Test] B --\u003e E C --\u003e E D --\u003e E Le SUT est la classe sous test. Tout le reste est de l\u0026rsquo;échafaudage. L\u0026rsquo;objectif est de garder le ratio élevé : un minimum d\u0026rsquo;échafaudage, un maximum de SUT.\nZoom : le pattern AAA #Chaque bon test unitaire a trois sections : Arrange, Act, Assert. Séparées visuellement, elles se lisent comme du texte.\nusing FluentAssertions; using Xunit; public class PriceCalculatorTests { [Fact] public void Calculate_applique_une_remise_de_volume_au_dessus_de_10_articles() { // Arrange var calculator = new PriceCalculator(); var order = new Order( customerId: CustomerId.New(), lines: [new OrderLine(\u0026#34;SKU-42\u0026#34;, quantity: 12, unitPrice: 10m)]); // Act var total = calculator.Calculate(order); // Assert total.Amount.Should().Be(108m); // 10% de remise au dessus de 10 articles } } Trois choses font que ce test est bon : le nom décrit le comportement, pas la méthode ; l\u0026rsquo;arrange est minimal ; l\u0026rsquo;assert vérifie un seul résultat. Si quelqu\u0026rsquo;un change les entrailles de PriceCalculator demain, ce test continue de passer tant que la règle tient.\n💡 Info : L\u0026rsquo;attribut [Fact] marque un test sans paramètre. Pour plusieurs inputs, on utilise [Theory] avec [InlineData] ou [MemberData]. Ce n\u0026rsquo;est pas du sucre syntaxique, c\u0026rsquo;est précisément ce pour quoi les tests paramétrés existent.\n✅ Bonne pratique : Nommer les tests en Methode_etat_resultat ou en phrases claires comme applique_une_remise_de_volume_au_dessus_de_10_articles. La sortie du runner de test fait office de documentation pour le futur lecteur.\nZoom : Theory pour les tables d\u0026rsquo;inputs #Quand une méthode a plusieurs branches d\u0026rsquo;entrée, une theory bat dix facts copiés-collés :\n[Theory] [InlineData(1, 10.00, 0, 10.00)] // aucune remise [InlineData(10, 10.00, 0, 100.00)] // pile au seuil [InlineData(11, 10.00, 10, 99.00)] // 10% de remise [InlineData(50, 10.00, 15, 425.00)] // palier 15% public void Calculate_applique_une_remise_par_paliers( int quantity, decimal unitPrice, int expectedDiscountPct, decimal expectedTotal) { var calculator = new PriceCalculator(); var order = new Order( CustomerId.New(), [new OrderLine(\u0026#34;SKU-1\u0026#34;, quantity, unitPrice)]); var total = calculator.Calculate(order); total.Amount.Should().Be(expectedTotal); } Une seule méthode de test, quatre cas, quatre lignes dans le runner. Ajouter un nouveau palier, c\u0026rsquo;est une ligne.\nZoom : mocker, mais prudemment #Le mocking, c\u0026rsquo;est la technique la plus souvent mal employée dans les tests. La règle est simple : mocke les frontières, pas le comportement. Une frontière, c\u0026rsquo;est une interface vers laquelle le SUT appelle (repository, client HTTP, provider de temps). Tout le reste doit être réel.\n[Fact] public async Task Submit_debite_le_client_et_marque_la_commande_soumise() { // Arrange var payments = Substitute.For\u0026lt;IPaymentGateway\u0026gt;(); payments.ChargeAsync(Arg.Any\u0026lt;CustomerId\u0026gt;(), Arg.Any\u0026lt;Money\u0026gt;(), Arg.Any\u0026lt;CancellationToken\u0026gt;()) .Returns(new ChargeResult(Success: true)); var repo = Substitute.For\u0026lt;IOrderRepository\u0026gt;(); var order = Order.Create(CustomerId.New()); order.AddLine(new ProductId(1), 2, new Money(50m)); repo.GetByIdAsync(order.Id, Arg.Any\u0026lt;CancellationToken\u0026gt;()).Returns(order); var handler = new SubmitOrderHandler(repo, payments, new FakeUnitOfWork()); // Act var result = await handler.Handle(new SubmitOrderCommand(order.Id.Value), default); // Assert result.IsSuccess.Should().BeTrue(); order.Status.Should().Be(OrderStatus.Submitted); await payments.Received(1).ChargeAsync(order.CustomerId, order.Total, Arg.Any\u0026lt;CancellationToken\u0026gt;()); } L\u0026rsquo;entité de domaine Order est vraie, pas mockée. Seuls IPaymentGateway et IOrderRepository sont substitués, parce qu\u0026rsquo;ils parlent au monde extérieur.\n⚠️ Ça marche, mais\u0026hellip; : Lorsque nous nous retrouvons à mocker nos propres classes de domaine (Order, Invoice, Customer), il faut prendre du recul. Soit la classe est une frontière déguisée (extraire une interface), soit le test vérifie le mock plutôt que le SUT.\n❌ Ne jamais faire : Ne pas écrire de tests qui assertent mock.Received(1).HelperInterne(). Ce type de test fige l\u0026rsquo;implémentation, pas le comportement. Un refactoring qui garde le même contrat public casserait ces tests sans raison.\nZoom : ce qu\u0026rsquo;il ne faut pas tester en unitaire #Les tests unitaires sont le mauvais outil pour :\nLes requêtes base de données : une expression EF Core Where n\u0026rsquo;est pas testable de façon utile en unitaire. Le bug se cache dans le SQL généré, et se vérifie avec une vraie base via les tests d\u0026rsquo;intégration. Le pipeline HTTP, les middlewares, les filters : se testent en démarrant le vrai pipeline avec WebApplicationFactory. Les aller-retours de sérialisation : à vérifier sur le JSON réel en end-to-end, pas sur un mock. Le rendu UI : tester un composant Blazor pour son layout est le mauvais niveau. Le E2E Playwright attrape les vrais bugs. Si un test prend plus de 50 ms à tourner, ce n\u0026rsquo;est probablement pas un test unitaire. Ce n\u0026rsquo;est pas un problème en soi : il a simplement sa place dans un autre projet de test, avec un autre cycle de vie.\n✅ Bonne pratique : Découper la solution en MyApp.UnitTests, MyApp.IntegrationTests, MyApp.E2ETests. La CI peut alors lancer les tests unitaires à chaque commit et les suites plus lentes moins souvent, ou en étages parallèles.\nTourner vite et en parallèle #xUnit v3 exécute les classes de test en parallèle par défaut. C\u0026rsquo;est précieux, à condition que les tests ne partagent pas d\u0026rsquo;état statique (singletons cachés, DateTime.Now, variables d\u0026rsquo;environnement). Deux règles :\nAucun état mutable partagé entre les tests. Chaque test arrange son propre état. Injecter une horloge au lieu d\u0026rsquo;appeler DateTime.UtcNow directement. Depuis .NET 8, TimeProvider est l\u0026rsquo;abstraction canonique. public sealed class PromotionService(TimeProvider clock) { public bool IsActive(Promotion p) =\u0026gt; clock.GetUtcNow() \u0026lt; p.EndsAt; } // Dans les tests var fakeClock = new FakeTimeProvider(DateTimeOffset.Parse(\u0026#34;2026-04-08T12:00:00Z\u0026#34;)); var service = new PromotionService(fakeClock); service.IsActive(new Promotion { EndsAt = DateTimeOffset.Parse(\u0026#34;2026-04-09T00:00:00Z\u0026#34;) }) .Should().BeTrue(); FakeTimeProvider vit dans le package NuGet Microsoft.Extensions.TimeProvider.Testing. Fini le DateTime.UtcNow directement dans le code de production.\n💡 Info : TimeProvider a été introduit dans .NET 8. Avant, les équipes maintenaient leur propre interface IClock. Sur .NET 6/7, cette abstraction maison reste valable, le pattern de test est identique.\nWrap-up #Tu sais maintenant écrire des tests unitaires qui sont réellement utiles : scopés sur un seul comportement, structurés en AAA, en mockant uniquement les frontières, qui tournent en millisecondes, et qui survivent aux refactorings sans tout réécrire. Tu peux choisir xUnit v3 + FluentAssertions + NSubstitute comme défaut fiable, utiliser [Theory] pour les tables d\u0026rsquo;inputs, injecter TimeProvider au lieu de taper l\u0026rsquo;horloge système, et reconnaître les cas où un test unitaire n\u0026rsquo;est pas le bon outil.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nRéférences # Documentation xUnit.net Tests unitaires en .NET, Microsoft Learn TimeProvider en .NET, Microsoft Learn Documentation NSubstitute Documentation FluentAssertions ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/testing-unit-testing/","section":"Posts","summary":"","title":"Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/load-testing/","section":"Tags","summary":"","title":"Load-Testing"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/memory/","section":"Tags","summary":"","title":"Memory"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/n-couches/","section":"Tags","summary":"","title":"N-Couches"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/performance/","section":"Categories","summary":"","title":"Performance"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/playwright/","section":"Tags","summary":"","title":"Playwright"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/soak/","section":"Tags","summary":"","title":"Soak"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/spike/","section":"Tags","summary":"","title":"Spike"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/stress/","section":"Tags","summary":"","title":"Stress"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/testcontainers/","section":"Tags","summary":"","title":"Testcontainers"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/categories/testing/","section":"Categories","summary":"","title":"Testing"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/testing/","section":"Tags","summary":"","title":"Testing"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va comprendre les tests API avec WebApplicationFactory en ASP.NET Core.\nEntre les tests unitaires et les tests end-to-end pilotés par un vrai navigateur, il y a une couche intermédiaire très productive : des tests qui démarrent ton vrai pipeline ASP.NET Core (routing, model binding, middlewares, filters, DI, authentification) dans le même process que le test, et le pilotent via un HttpClient in-memory. Pas de Kestrel, pas de socket, pas de navigateur. Juste l\u0026rsquo;application, qui tourne pour de vrai, en quelques millisecondes.\nWebApplicationFactory\u0026lt;TEntryPoint\u0026gt; a été introduit dans ASP.NET Core 2.1 en 2018, dans le package Microsoft.AspNetCore.Mvc.Testing. Il a remplacé dix ans de solutions bricolées (TestServer à la main, host builders custom, bidouilles de Startup) par une primitive propre. Avec l\u0026rsquo;arrivée des minimal APIs et du Program.cs top-level en .NET 6, l\u0026rsquo;histoire est devenue encore plus simple. Si tu as lu l\u0026rsquo;article précédent sur les tests d\u0026rsquo;intégration avec TestContainers, tu as déjà la partie base de données. Cet article couvre la partie HTTP par-dessus.\nLe contexte : pourquoi ce pattern existe #Supposons que nous ayons une équipe dont l\u0026rsquo;API expose 40 endpoints. Ils ont de très bons tests unitaires sur les handlers et les repositories, mais les bugs qu\u0026rsquo;ils retrouvent régulièrement en prod, ce sont :\nUn filter qui a accidentellement sauté l\u0026rsquo;autorisation sur une route. Un model binder qui a silencieusement converti un enum null en 0. Une réponse Problem Details dont la forme a changé après un upgrade de middleware. Une collision de route entre /orders/{id} et /orders/export. Aucun de ces bugs ne vit dans une seule classe. Ils vivent dans le pipeline : l\u0026rsquo;interaction entre routing, filters, DI et sérialisation. Les tests unitaires ne peuvent pas les voir. Lancer l\u0026rsquo;appli en CI et la curler, ça marche mais c\u0026rsquo;est lent et fragile. Ce qu\u0026rsquo;il faut vraiment à cette équipe :\nLe vrai pipeline, pas une simulation, pour que le routing et les filters se comportent comme en prod. Un démarrage rapide, pour qu\u0026rsquo;un run de tests couvre 200 endpoints en moins d\u0026rsquo;une minute. Des hooks pour surcharger les services, pour qu\u0026rsquo;un test puisse remplacer une dépendance (le payment gateway, l\u0026rsquo;horloge) sans toucher au code de production. WebApplicationFactory te donne les trois.\nVue d\u0026rsquo;ensemble : comment ça se branche # graph TD A[Test] --\u003e B[WebApplicationFactory\u0026lt;Program\u0026gt;] B --\u003e C[TestServerin-memory] C --\u003e D[Ton Program.csDI, middlewares, endpoints] D --\u003e E[HttpClient] A --\u003e E D --\u003e F[(Postgres depuis TestContainers)] La factory démarre le Program.cs avec un TestServer au lieu de Kestrel. Le HttpClient qu\u0026rsquo;elle rend parle au pipeline directement, en court-circuitant le réseau. Tout ce qui compte (routing, filters, auth, sérialisation) tourne pour de vrai.\n💡 Info : WebApplicationFactory\u0026lt;TEntryPoint\u0026gt; prend un argument générique qui pointe vers un type de l\u0026rsquo;assembly de démarrage. La convention est WebApplicationFactory\u0026lt;Program\u0026gt;. Avec les top-level statements, il faut ajouter public partial class Program { } en bas du Program.cs pour que le projet de test puisse référencer le type.\nLe pipeline HTTP invisible que l\u0026rsquo;on teste vraiment #Quand un endpoint est déclaré en minimal API ou en action de controller, ASP.NET Core effectue une quantité surprenante de travail entre \u0026ldquo;une requête HTTP est arrivée\u0026rdquo; et \u0026ldquo;le handler tourne avec ses arguments C#\u0026rdquo;. La plupart de ce travail est invisible dans le code source, et c\u0026rsquo;est précisément pour ça que les bugs s\u0026rsquo;y cachent. Un vrai test WebApplicationFactory exerce tout cela en même temps :\nMatching de route et contraintes : {id:guid} rejette un non-GUID et renvoie 404 avant même que le handler ne soit appelé. Model binding depuis la query string : ?status=active\u0026amp;page=2 est parsé en paramètres typés, y compris les types nullables, les enums et les tableaux. Model binding depuis le body : le body JSON est désérialisé en DTO via System.Text.Json, en appliquant les converters custom, les naming policies et les formats numériques configurés dans JsonSerializerOptions. Binding des headers : les paramètres [FromHeader], la négociation Accept, If-None-Match, Authorization, tout cela alimente le pipeline. Binding de formulaire et upload de fichiers : le multipart/form-data est découpé en champs et en instances de IFormFile. Validation du modèle : les data annotations et IValidatableObject se déclenchent, et un échec de validation renvoie une réponse ValidationProblemDetails sans que le handler ne soit appelé. Négociation de contenu et sérialisation de sortie : la valeur de retour C# est reconvertie en JSON, Problem Details, ou tout autre formatter enregistré, avec le bon Content-Type et le bon charset. Sélection du status code : Results.Ok(...), Results.NotFound(), TypedResults.NoContent(), et les exceptions non gérées sont traduites en status codes HTTP corrects. Rien de tout cela n\u0026rsquo;est écrit dans le fichier de l\u0026rsquo;endpoint. Tout cela tourne pour de vrai dans un test WebApplicationFactory. Quand un test échoue parce qu\u0026rsquo;un paramètre de query string ne bind plus, qu\u0026rsquo;une propriété JSON a été renommée par une naming policy, qu\u0026rsquo;un format de date a changé, ou qu\u0026rsquo;un validator rejette un payload auparavant valide, l\u0026rsquo;échec signale quelque chose que le code source seul ne peut pas dire : le contrat entre HTTP et C# a bougé. C\u0026rsquo;est exactement la catégorie de bugs que les tests unitaires ne peuvent pas attraper, et c\u0026rsquo;est la raison pour laquelle WebApplicationFactory mérite sa place dans la pyramide.\nZoom : le test minimum #using Microsoft.AspNetCore.Mvc.Testing; using System.Net.Http.Json; using Xunit; public class OrderEndpointsTests : IClassFixture\u0026lt;WebApplicationFactory\u0026lt;Program\u0026gt;\u0026gt; { private readonly HttpClient _client; public OrderEndpointsTests(WebApplicationFactory\u0026lt;Program\u0026gt; factory) =\u0026gt; _client = factory.CreateClient(); [Fact] public async Task GET_orders_renvoie_200_avec_la_liste() { var response = await _client.GetAsync(\u0026#34;/orders\u0026#34;); response.StatusCode.Should().Be(HttpStatusCode.OK); var orders = await response.Content.ReadFromJsonAsync\u0026lt;List\u0026lt;OrderDto\u0026gt;\u0026gt;(); orders.Should().NotBeNull(); } } Cinq lignes de setup, le reste c\u0026rsquo;est une vraie assertion HTTP. factory.CreateClient() te rend un HttpClient déjà branché au test server. Pas de port, pas de hostname, pas de vrai socket.\n✅ Bonne pratique : Asserte sur les status codes et les bodies de réponse, pas sur l\u0026rsquo;état interne. Un bon test d\u0026rsquo;API doit pouvoir être remplacé par une commande curl qui prouve le même comportement. Le couplage interne rend les tests fragiles.\nZoom : surcharger des services #La fonctionnalité la plus précieuse de WebApplicationFactory, c\u0026rsquo;est ConfigureWebHost, où tu peux remplacer n\u0026rsquo;importe quel service enregistré en prod. Le gateway de paiement Stripe ? Tu le remplaces par un fake. L\u0026rsquo;horloge système ? Tu injectes un FakeTimeProvider.\npublic class TestAppFactory : WebApplicationFactory\u0026lt;Program\u0026gt; { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services =\u0026gt; { services.RemoveAll\u0026lt;IPaymentGateway\u0026gt;(); services.AddSingleton\u0026lt;IPaymentGateway, FakePaymentGateway\u0026gt;(); services.RemoveAll\u0026lt;TimeProvider\u0026gt;(); services.AddSingleton\u0026lt;TimeProvider\u0026gt;(new FakeTimeProvider( DateTimeOffset.Parse(\u0026#34;2026-04-08T12:00:00Z\u0026#34;))); }); } } public sealed class FakePaymentGateway : IPaymentGateway { public Task\u0026lt;ChargeResult\u0026gt; ChargeAsync(CustomerId c, Money m, CancellationToken ct) =\u0026gt; Task.FromResult(new ChargeResult(Success: true)); } Puis dans les tests :\npublic class SubmitOrderTests : IClassFixture\u0026lt;TestAppFactory\u0026gt; { private readonly TestAppFactory _factory; public SubmitOrderTests(TestAppFactory factory) =\u0026gt; _factory = factory; [Fact] public async Task POST_submit_debite_et_renvoie_204() { var client = _factory.CreateClient(); var response = await client.PostAsync($\u0026#34;/orders/{Guid.NewGuid()}/submit\u0026#34;, null); response.StatusCode.Should().Be(HttpStatusCode.NoContent); } } 💡 Info : services.RemoveAll\u0026lt;T\u0026gt;() vient de Microsoft.Extensions.DependencyInjection.Extensions. C\u0026rsquo;est la façon idiomatique de surcharger un enregistrement, plutôt que d\u0026rsquo;en ajouter un second.\n❌ Ne jamais faire : N\u0026rsquo;utilise pas Mock.Setup(...) pour simuler du comportement dans ConfigureServices. Les mocks, c\u0026rsquo;est pour les tests unitaires. Pour les tests d\u0026rsquo;intégration, une petite classe Fake* écrite à la main se lit mieux et survit mieux aux refactorings.\nZoom : combiner avec TestContainers #La vraie puissance, c\u0026rsquo;est de combiner WebApplicationFactory avec un container Postgres. Tes tests pilotent le vrai pipeline contre la vraie base. C\u0026rsquo;est là que se cachent 80% des bugs.\npublic class ApiWithDbFixture : WebApplicationFactory\u0026lt;Program\u0026gt;, IAsyncLifetime { private readonly PostgreSqlContainer _db = new PostgreSqlBuilder() .WithImage(\u0026#34;postgres:17-alpine\u0026#34;).Build(); protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services =\u0026gt; { services.RemoveAll\u0026lt;DbContextOptions\u0026lt;ShopDbContext\u0026gt;\u0026gt;(); services.AddDbContext\u0026lt;ShopDbContext\u0026gt;(o =\u0026gt; o.UseNpgsql(_db.GetConnectionString())); }); } public async ValueTask InitializeAsync() { await _db.StartAsync(); using var scope = Services.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService\u0026lt;ShopDbContext\u0026gt;(); await ctx.Database.MigrateAsync(); } public new async ValueTask DisposeAsync() { await _db.DisposeAsync(); await base.DisposeAsync(); } } Une fixture. Le vrai pipeline. La vraie base. Des tests qui POST une commande, commit une ligne, et vérifient que l\u0026rsquo;endpoint GET la renvoie. Si un mapping EF Core est faux, qu\u0026rsquo;un filter oublie de tourner, ou qu\u0026rsquo;un middleware bousille le body de réponse, ce genre de test l\u0026rsquo;attrape.\n✅ Bonne pratique : Seed tes données de test via l\u0026rsquo;API quand c\u0026rsquo;est possible, pas en insérant des lignes directement en base. Un test qui fait POST /orders puis GET /orders/{id} prouve que tout le flux marche de bout en bout. Un test qui court-circuite l\u0026rsquo;API ne prouve que les morceaux que tu as pensé à exercer.\nZoom : l\u0026rsquo;authentification dans les tests #Les vraies APIs sont protégées. Tu as deux options propres :\n1. Test authentication handler : enregistre un schéma fake qui authentifie chaque requête comme un utilisateur de test.\nservices.AddAuthentication(\u0026#34;Test\u0026#34;) .AddScheme\u0026lt;AuthenticationSchemeOptions, TestAuthHandler\u0026gt;(\u0026#34;Test\u0026#34;, _ =\u0026gt; { }); services.PostConfigure\u0026lt;AuthenticationOptions\u0026gt;(o =\u0026gt; { o.DefaultAuthenticateScheme = \u0026#34;Test\u0026#34;; o.DefaultChallengeScheme = \u0026#34;Test\u0026#34;; }); Le TestAuthHandler se contente de construire un ClaimsPrincipal à partir d\u0026rsquo;un utilisateur de test configuré. Simple, rapide, déterministe.\n2. Vrai flux JWT : le test appelle ton vrai endpoint /token avec un compte de test, récupère le token, l\u0026rsquo;attache au HttpClient. Plus lent, mais ça prouve que le flux d\u0026rsquo;auth marche aussi.\n⚠️ Ça marche, mais\u0026hellip; : L\u0026rsquo;option 1 est ok pour la plupart des tests, mais garde au moins un test par route protégée qui passe par l\u0026rsquo;option 2. Sinon, le jour où ta vraie config JWT casse, aucun test ne le verra.\nQuand ne pas l\u0026rsquo;utiliser #WebApplicationFactory, c\u0026rsquo;est top, mais ça reste in-process. Si ton système de prod dépend de comportements qui n\u0026rsquo;apparaissent qu\u0026rsquo;avec du vrai réseau, plusieurs process, ou un vrai reverse proxy (sticky sessions, SignalR sur WebSockets via Nginx, auth par certificat client), il te faudra un vrai setup E2E par-dessus. C\u0026rsquo;est le sujet de l\u0026rsquo;article suivant.\nWrap-up #Tu sais maintenant piloter ton vrai pipeline ASP.NET Core depuis des tests : créer une WebApplicationFactory\u0026lt;Program\u0026gt;, utiliser ConfigureWebHost pour remplacer les dépendances de prod par des fakes, la combiner avec un container Postgres pour une couverture d\u0026rsquo;intégration complète, et ajouter un handler d\u0026rsquo;authentification de test pour que tes routes protégées soient joignables. Tu peux écrire des tests qui postent une commande et vérifient l\u0026rsquo;état via une seconde requête, prouvant que tout le pipeline du routing à la persistence marche de bout en bout.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles Tests d\u0026rsquo;Intégration avec TestContainers pour .NET Références # Tests d\u0026rsquo;intégration dans ASP.NET Core, Microsoft Learn WebApplicationFactory\u0026lt;TEntryPoint\u0026gt;, documentation API Test d\u0026rsquo;authentification dans ASP.NET Core, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/testing-webapplicationfactory/","section":"Posts","summary":"","title":"Tests API avec WebApplicationFactory en ASP.NET Core"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va démystifier les tests d\u0026rsquo;architecture en .NET.\nChaque codebase .NET a des règles qui vivent uniquement dans les README, les docs d\u0026rsquo;onboarding, ou la mémoire tribale du dev senior. \u0026ldquo;Domain ne référence jamais Infrastructure.\u0026rdquo; \u0026ldquo;Les handlers se terminent par Handler.\u0026rdquo; \u0026ldquo;Pas de using System.Data; dans la couche Application.\u0026rdquo; Le compilateur n\u0026rsquo;en vérifie aucune. Six mois plus tard, quelqu\u0026rsquo;un ajoute un using Shop.Infrastructure; dans une classe de domaine parce qu\u0026rsquo;IntelliSense l\u0026rsquo;a proposé, le build passe, et la règle meurt en silence.\nLes tests d\u0026rsquo;architecture transforment ces règles en assertions exécutables. Ce sont des tests unitaires sur le graphe de tes assemblies : \u0026ldquo;assert qu\u0026rsquo;aucun type dans Shop.Domain ne dépend de Shop.Infrastructure\u0026rdquo;, \u0026ldquo;assert que chaque handler se termine par Handler\u0026rdquo;, \u0026ldquo;assert que chaque type dans Application.Orders.Commands implémente IRequest\u0026rdquo;. La première bibliothèque largement utilisée a été ArchUnit sur la JVM (2017). Le port .NET, NetArchTest de Ben Morris, est sorti en 2018, suivi par ArchUnitNET en 2020 avec une API fluent plus riche. Les deux sont matures, open source, et marchent avec n\u0026rsquo;importe quel runner de test.\nSi tu as suivi cette série depuis les tests unitaires jusqu\u0026rsquo;aux tests E2E avec Playwright, tu as déjà couvert l\u0026rsquo;axe de la correction. Les tests d\u0026rsquo;architecture couvrent un autre axe : la dérive structurelle dans le temps.\nLe contexte : pourquoi ce pattern existe #Supposons que nous ayons une équipe qui a adopté la Clean Architecture il y a dix-huit mois. Au jour 1, les règles étaient écrites au tableau et tout le monde les connaissait. Au mois 3, un nouvel arrivant a ajouté [Table(\u0026quot;orders\u0026quot;)] sur une entité de domaine parce que \u0026ldquo;c\u0026rsquo;était plus rapide\u0026rdquo;. Au mois 6, un raccourci pendant un incident a ajouté un using Microsoft.EntityFrameworkCore; dans Domain/Orders/Order.cs. Au mois 12, le projet \u0026ldquo;Clean Architecture\u0026rdquo; a l\u0026rsquo;air propre sur le diagramme et ressemble à du layered spaghetti dans le code.\nAucun de ces changements n\u0026rsquo;a causé de bug le jour où il a été mergé. Ils ont causé une dégradation lente. Ce qu\u0026rsquo;il faut vraiment à cette équipe :\nDes invariants exécutables : un test qui fait échouer le build quand une règle est cassée, pas une ligne dans une checklist de review. Une seule source de vérité : la règle vit dans le code, à côté des tests, lisible par chaque ingénieur. Peu de cérémonial : écrire une nouvelle règle prend cinq minutes, pas un sprint de yak-shaving. NetArchTest et ArchUnitNET livrent les deux.\nVue d\u0026rsquo;ensemble : ce que tu peux imposer #Les tests d\u0026rsquo;architecture couvrent trois grandes catégories :\ngraph TD A[Tests d'architecture] --\u003e B[Règles de dépendancesqui peut référencer quoi] A --\u003e C[Règles de nommagesuffixes, préfixes, namespaces] A --\u003e D[Règles structurellessealed, public, abstract, interfaces] Les règles de dépendances sont les plus précieuses : elles protègent la forme de l\u0026rsquo;applicationcation. Les règles de nommage et structurelles sont moins chères mais s\u0026rsquo;additionnent pour donner une vraie cohérence à un grand codebase.\n💡 Info : Les tests d\u0026rsquo;architecture tournent comme des tests xUnit / NUnit classiques. Pas d\u0026rsquo;outillage supplémentaire, pas de plugin SonarQube, pas d\u0026rsquo;analyseur Roslyn custom à moins que tu le veuilles. Les assertions s\u0026rsquo;exécutent en millisecondes contre tes assemblies compilés.\nZoom : règles de dépendances avec NetArchTest #NetArchTest a une API fluent qui se lit comme du texte. La règle classique \u0026ldquo;Domain ne référence rien\u0026rdquo; :\nusing NetArchTest.Rules; using Xunit; public class DomainDependencyTests { [Fact] public void Domain_ne_doit_pas_dependre_de_Application_Infrastructure_ou_Api() { var result = Types.InAssembly(typeof(Shop.Domain.Orders.Order).Assembly) .ShouldNot() .HaveDependencyOnAny( \u0026#34;Shop.Application\u0026#34;, \u0026#34;Shop.Infrastructure\u0026#34;, \u0026#34;Shop.Api\u0026#34;) .GetResult(); result.IsSuccessful.Should().BeTrue( \u0026#34;Domain doit être indépendant. Types fautifs : \u0026#34; + string.Join(\u0026#34;, \u0026#34;, result.FailingTypeNames ?? Array.Empty\u0026lt;string\u0026gt;())); } [Fact] public void Domain_ne_doit_pas_dependre_de_EntityFramework() { var result = Types.InAssembly(typeof(Shop.Domain.Orders.Order).Assembly) .ShouldNot() .HaveDependencyOn(\u0026#34;Microsoft.EntityFrameworkCore\u0026#34;) .GetResult(); result.IsSuccessful.Should().BeTrue(); } } Le message d\u0026rsquo;assertion inclut les types fautifs quand il se déclenche, donc le dev qui a cassé la règle sait exactement quel fichier ouvrir.\n✅ Bonne pratique : Ajoute un test de dépendance par couche, pas un seul test géant qui vérifie tout. Quand un échec apparaît, le nom du test te dit quelle frontière a été traversée.\nZoom : règles de nommage et structurelles #La cohérence de nommage, c\u0026rsquo;est surtout un boulot de code review, mais les tests d\u0026rsquo;architecture attrapent la dérive :\n[Fact] public void Chaque_IRequestHandler_doit_se_terminer_par_Handler() { var result = Types.InAssembly(typeof(SubmitOrderHandler).Assembly) .That() .ImplementInterface(typeof(IRequestHandler\u0026lt;,\u0026gt;)) .Should() .HaveNameEndingWith(\u0026#34;Handler\u0026#34;) .GetResult(); result.IsSuccessful.Should().BeTrue(); } [Fact] public void Chaque_commande_doit_etre_un_sealed_record() { var result = Types.InAssembly(typeof(SubmitOrderCommand).Assembly) .That() .ImplementInterface(typeof(IRequest\u0026lt;\u0026gt;)) .And() .ResideInNamespace(\u0026#34;Shop.Application\u0026#34;) .And() .HaveNameEndingWith(\u0026#34;Command\u0026#34;) .Should() .BeSealed() .GetResult(); result.IsSuccessful.Should().BeTrue(); } Ces règles sont peu chères, elles attrapent de la vraie dérive, et elles éduquent les nouveaux arrivants plus vite que n\u0026rsquo;importe quel style guide.\n⚠️ Ça marche, mais\u0026hellip; : Ne sur-contraint pas. Si tu écris 200 tests de nommage, chaque refactoring devient une négociation. Choisis les dix règles qui comptent vraiment pour ton équipe et laisse tomber le reste. Le goût fait partie du métier.\nZoom : ArchUnitNET pour des assertions plus riches #Pour des règles plus complexes, ArchUnitNET a une API plus expressive :\nusing ArchUnitNET.Domain; using ArchUnitNET.Fluent; using ArchUnitNET.Loader; using ArchUnitNET.xUnit; using static ArchUnitNET.Fluent.ArchRuleDefinition; public class ApplicationArchitectureTests { private static readonly Architecture Architecture = new ArchLoader() .LoadAssemblies(typeof(SubmitOrderHandler).Assembly, typeof(Order).Assembly) .Build(); [Fact] public void Handlers_ne_doivent_etre_utilises_que_par_le_mediator() { IObjectProvider\u0026lt;Class\u0026gt; handlers = Classes() .That().ImplementInterface(typeof(IRequestHandler\u0026lt;,\u0026gt;)) .As(\u0026#34;Handlers\u0026#34;); IObjectProvider\u0026lt;IType\u0026gt; mediator = Types() .That().ResideInNamespace(\u0026#34;MediatR\u0026#34;, true) .As(\u0026#34;MediatR\u0026#34;); Classes() .That().Are(handlers) .Should() .OnlyBeAccessedBy(mediator) .Check(Architecture); } } OnlyBeAccessedBy est exactement le genre de règle laborieuse à exprimer à la main et triviale avec ArchUnitNET. La règle dit \u0026ldquo;les handlers sont un détail d\u0026rsquo;implémentation privé, seul MediatR devrait y toucher\u0026rdquo;, ce qui est exactement le comportement attendu d\u0026rsquo;un codebase CQRS.\n💡 Info : ArchUnitNET charge l\u0026rsquo;assembly une fois dans un modèle in-memory, donc chaque règle tourne contre le même graphe. Pour un codebase avec 50 tests d\u0026rsquo;architecture, c\u0026rsquo;est nettement plus rapide que NetArchTest, qui reparcourt les types à chaque assertion.\nZoom : par où commencer #Commence par les trois règles qui protègent le plus de valeur :\n1. La direction des dépendances (Domain → rien, Application → Domain, Infrastructure → Application + Domain). C\u0026rsquo;est la seule règle qui empêche un projet Clean Architecture de s\u0026rsquo;effondrer en layered spaghetti.\n2. Pas de fuite de framework dans le domaine. Domain ne doit pas référencer Microsoft.EntityFrameworkCore, Microsoft.AspNetCore.*, System.Data.*, Stripe.*. Un seul test avec une liste de namespaces interdits.\n3. Le nommage du vocabulaire partagé. Si ton équipe dit \u0026ldquo;les commandes se terminent par Command\u0026rdquo;, \u0026ldquo;les queries par Query\u0026rdquo;, \u0026ldquo;les handlers par Handler\u0026rdquo;, impose-le. Quand les mots dans le code correspondent aux mots dans les réunions, l\u0026rsquo;onboarding accélère visiblement.\nLe reste, c\u0026rsquo;est du bonus. Ajoute des règles quand un vrai incident t\u0026rsquo;a appris la leçon, pas par anticipation.\n✅ Bonne pratique : Mets les tests d\u0026rsquo;architecture dans un projet dédié, Shop.ArchitectureTests, qui référence tous les autres assemblies. Ils tournent en CI à côté des tests unitaires et font échouer le build sur la moindre violation.\n❌ Ne jamais faire : Ne mets pas Skip sur un test d\u0026rsquo;architecture quand quelqu\u0026rsquo;un casse la règle \u0026ldquo;pour débloquer une release\u0026rdquo;. Un test d\u0026rsquo;architecture skippé, c\u0026rsquo;est une règle qui n\u0026rsquo;existe plus. Soit tu corriges le code, soit tu supprimes le test et tu reconnais ouvertement que la règle est partie.\nQuand les tests d\u0026rsquo;architecture sont le mauvais outil #Les tests d\u0026rsquo;architecture marchent bien pour des règles sur le graphe d\u0026rsquo;assembly, les noms de types, et la structure de types. Ce n\u0026rsquo;est pas le bon outil pour :\nLa correction logique : \u0026ldquo;la remise est de 15% au-dessus de 50 articles\u0026rdquo;. C\u0026rsquo;est un test unitaire. Le comportement runtime : \u0026ldquo;le handler commit la transaction\u0026rdquo;. C\u0026rsquo;est un test d\u0026rsquo;intégration. La performance : \u0026ldquo;cette requête tourne en moins de 100ms\u0026rdquo;. C\u0026rsquo;est un benchmark ou un test de charge. La sécurité : \u0026ldquo;cet endpoint exige le rôle admin\u0026rdquo;. C\u0026rsquo;est un test WebApplicationFactory. Si tu te surprends à écrire Types.InAssembly(...).Should().HaveMethodBody(\u0026quot;...\u0026quot;), arrête et écris un vrai test à la place.\nWrap-up #Tu sais maintenant comment transformer des décisions d\u0026rsquo;architecture en invariants exécutables : choisir NetArchTest pour des règles fluent simples ou ArchUnitNET pour des assertions de graphe plus riches, commencer par les tests de direction de dépendances et de fuite de framework, ajouter des règles de nommage qui reflètent le vocabulaire partagé de ton équipe, mettre tout ça dans un projet de test d\u0026rsquo;architecture dédié, et refuser de les skipper sous pression. Tu peux rendre le codebase résistant à la dérive structurelle lente qui tue tous les projets de longue durée.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Clean Architecture en .NET : des dépendances qui pointent dans le bon sens Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles Tests d\u0026rsquo;Intégration avec TestContainers pour .NET Tests API avec WebApplicationFactory en ASP.NET Core Tests End-to-End avec Playwright pour .NET Références # NetArchTest sur GitHub ArchUnitNET sur GitHub ArchUnit (original Java) Clean Architecture, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/testing-architecture-testing/","section":"Posts","summary":"","title":"Tests d'Architecture en .NET : les règles que le compilateur ne peut pas imposer"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer les tests d\u0026rsquo;intégration avec TestContainers pour .NET.\nLe test d\u0026rsquo;intégration est historiquement la discipline la plus compromise de la delivery .NET. Pas parce que les ingénieurs ne s\u0026rsquo;en souciaient pas, mais parce que les outils disponibles imposaient un choix entre une infrastructure partagée fragile et des tests qui, sans le dire, cessaient d\u0026rsquo;être des tests d\u0026rsquo;intégration. Si tu as lu l\u0026rsquo;article précédent sur les tests unitaires en .NET, tu sais déjà que mocker un DbContext ne peut pas attraper les bugs qui vivent dans le SQL généré. Ce que le métier attendait, c\u0026rsquo;était un moyen d\u0026rsquo;exécuter des tests d\u0026rsquo;intégration contre les vrais services auxquels ils prétendent s\u0026rsquo;intégrer, de façon reproductible, sans coordonner un environnement partagé.\nTestContainers fournit exactement cela. La bibliothèque Java d\u0026rsquo;origine a été publiée en 2015 par Richard North, et le port .NET est arrivé en 2017 sous le nom Testcontainers for .NET. C\u0026rsquo;est aujourd\u0026rsquo;hui le standard de référence, maintenu sous l\u0026rsquo;organisation GitHub testcontainers, et .NET 10 le traite comme un outil de test d\u0026rsquo;intégration de première classe. Le principe est direct : le code de test démarre un vrai Postgres, Redis, RabbitMQ, Keycloak, ou n\u0026rsquo;importe quel autre service dans un container Docker éphémère, attend qu\u0026rsquo;il devienne prêt, expose ses informations de connexion, et le détruit quand la fixture de test se libère.\nLe contexte : pourquoi ce pattern existe #Pendant la plus grande partie de l\u0026rsquo;histoire de .NET, écrire un test d\u0026rsquo;intégration honnête impliquait d\u0026rsquo;accepter cinq problèmes structurels, dont aucun n\u0026rsquo;avait de solution propre.\n1. Une infrastructure de dev ou d\u0026rsquo;intégration partagée. La base, le fournisseur d\u0026rsquo;identité, le broker de messages, le stockage objet : tout vivait sur un environnement central vers lequel chaque développeur et chaque job de CI pointait. Lancer deux suites de tests en parallèle était un vrai risque : les fixtures d\u0026rsquo;un ingénieur entraient en collision avec celles d\u0026rsquo;un autre, un script de cleanup effaçait une ligne dont quelqu\u0026rsquo;un dépendait, et un test flaky ressemblait soudain à une vraie régression. Les équipes se défendaient avec des systèmes de verrous, des conventions de nommage, et des contrats sociaux implicites qui se cassaient à l\u0026rsquo;arrivée du premier nouvel arrivant.\n2. La CI/CD devait avoir un accès réseau à ces services partagés. Les agents de build avaient besoin de routes vers la base de dev, de credentials renouvelés à la main, et de règles de firewall maintenues par une autre équipe. Chaque nouveau pipeline devenait un ticket. Chaque panne de l\u0026rsquo;infra partagée bloquait tous les builds. La disponibilité de la suite de tests était plafonnée par celle du service le moins fiable qu\u0026rsquo;elle appelait.\n3. Le setup se cassait avec une grande facilité. Un simple ALTER TABLE appliqué par un ingénieur en debug, un changement de rôle dans Keycloak, une expiration de certificat SSL sur le relais SMTP, un snapshot Redis obsolète : n\u0026rsquo;importe lequel invalidait silencieusement la suite pour tout le monde. Les matinées commençaient par la question \u0026ldquo;la CI est rouge à cause de mon changement, ou parce que quelqu\u0026rsquo;un a touché à l\u0026rsquo;environnement de test ?\u0026rdquo;.\n4. Elle exigeait un cleanup et un entretien permanents de la part des développeurs eux-mêmes. Les scripts de seed dérivaient par rapport aux migrations. Les utilisateurs de test s\u0026rsquo;accumulaient dans le fournisseur d\u0026rsquo;identité. Les lignes orphelines s\u0026rsquo;empilaient dans les tables de jointure. Quelqu\u0026rsquo;un dans l\u0026rsquo;équipe finissait par être le gardien officieux de l\u0026rsquo;environnement d\u0026rsquo;intégration, et son temps n\u0026rsquo;était jamais compté dans la planification.\n5. Et surtout : toute dépendance qui n\u0026rsquo;avait pas de package NuGet in-memory était mockée. C\u0026rsquo;est la conséquence la plus grave, et celle que personne n\u0026rsquo;aime reconnaître. Si ton service parlait à SQL Server, tu avais Microsoft.EntityFrameworkCore.InMemory et tu faisais comme si ça comptait, alors qu\u0026rsquo;il ignore silencieusement les foreign keys, la sensibilité à la casse, et tout ce qui est spécifique au SQL. S\u0026rsquo;il parlait à Keycloak, tu mockais IAuthenticationService. S\u0026rsquo;il parlait à MinIO, tu mockais IAmazonS3. S\u0026rsquo;il parlait à RabbitMQ, tu mockais IBus. Les suites étaient étiquetées \u0026ldquo;tests d\u0026rsquo;intégration\u0026rdquo; et étaient, en pratique, de faux tests d\u0026rsquo;intégration : elles exerçaient ton code contre une fiction que tu avais écrite toi-même. Le jour où la vraie dépendance se comportait différemment, les tests restaient silencieux.\nTestContainers démonte les cinq points d\u0026rsquo;un coup. Il remplace l\u0026rsquo;infrastructure partagée par des containers provisionnés par run, supprime la dépendance de la CI à des services externes (l\u0026rsquo;agent n\u0026rsquo;a besoin que de Docker), rend le setup reproductible depuis le code au lieu d\u0026rsquo;une page de wiki, déplace le cleanup de \u0026ldquo;la discipline des développeurs\u0026rdquo; vers \u0026ldquo;la disposition des containers\u0026rdquo;, et, surtout, supprime la dernière excuse pour mocker une dépendance qui a une image Docker : Postgres avec pg_trgm, Keycloak avec un realm complet, MinIO pour S3, RabbitMQ, Kafka, Mongo, Elasticsearch. Si l\u0026rsquo;outil a une image, tu testes contre la vraie chose.\nLe reste de cet article explique comment faire cela proprement.\nVue d\u0026rsquo;ensemble : comment ça se branche #Avant de rentrer dans le code, voici comment TestContainers s\u0026rsquo;installe dans un projet de test .NET :\ngraph TD A[Fixture de test] --\u003e B[Bibliothèque Testcontainers] B --\u003e C[Daemon Docker] C --\u003e D[Container Postgres] C --\u003e E[Container Redis] A --\u003e F[Ton SUTRepository + DbContext] F --\u003e D F --\u003e E La fixture de test possède le cycle de vie des containers. Le SUT reçoit une vraie chaîne de connexion et ne sait absolument pas qu\u0026rsquo;il parle à un container qui disparaîtra dans 20 secondes.\n💡 Info : TestContainers a besoin d\u0026rsquo;un Docker qui tourne sur la machine (Docker Desktop sous Windows/macOS, ou Docker rootless sous Linux). En CI, GitHub Actions et Azure DevOps fournissent tous les deux des runners Docker-in-Docker nativement.\nZoom : une fixture Postgres avec xUnit #Voici le minimum pour démarrer Postgres, appliquer les migrations EF Core, et le rendre disponible aux tests :\nusing Testcontainers.PostgreSql; using Microsoft.EntityFrameworkCore; using Xunit; public sealed class PostgresFixture : IAsyncLifetime { public PostgreSqlContainer Container { get; } = new PostgreSqlBuilder() .WithImage(\u0026#34;postgres:17-alpine\u0026#34;) .WithDatabase(\u0026#34;shop_test\u0026#34;) .WithUsername(\u0026#34;test\u0026#34;) .WithPassword(\u0026#34;test\u0026#34;) .Build(); public ShopDbContext CreateDbContext() { var options = new DbContextOptionsBuilder\u0026lt;ShopDbContext\u0026gt;() .UseNpgsql(Container.GetConnectionString()) .Options; return new ShopDbContext(options); } public async ValueTask InitializeAsync() { await Container.StartAsync(); await using var db = CreateDbContext(); await db.Database.MigrateAsync(); } public ValueTask DisposeAsync() =\u0026gt; Container.DisposeAsync(); } IAsyncLifetime, c\u0026rsquo;est le hook xUnit pour le setup et le teardown async. StartAsync() télécharge l\u0026rsquo;image (cachée après le premier run) et attend que Postgres soit prêt. Ensuite, EF Core applique tes vraies migrations dessus.\n✅ Bonne pratique : Fixe le tag de l\u0026rsquo;image (postgres:17-alpine, pas postgres:latest). La reproductibilité est l\u0026rsquo;objectif même. Un latest non fixé qui se déplace sous toi invalide silencieusement tous les runs qui l\u0026rsquo;ont précédé.\nZoom : un test qui utilise la fixture #[Collection(\u0026#34;postgres\u0026#34;)] public class OrderRepositoryTests { private readonly PostgresFixture _fixture; public OrderRepositoryTests(PostgresFixture fixture) =\u0026gt; _fixture = fixture; [Fact] public async Task AddAsync_persiste_la_commande_avec_ses_lignes() { // Arrange await using var db = _fixture.CreateDbContext(); var repo = new OrderRepository(db); var order = Order.Create(CustomerId.New()); order.AddLine(new ProductId(1), 2, new Money(49.99m)); // Act await repo.AddAsync(order, default); await db.SaveChangesAsync(); // Assert await using var verify = _fixture.CreateDbContext(); var loaded = await verify.Orders.Include(o =\u0026gt; o.Lines) .FirstOrDefaultAsync(o =\u0026gt; o.Id == order.Id); loaded.Should().NotBeNull(); loaded!.Lines.Should().HaveCount(1); loaded.Lines.First().Subtotal.Amount.Should().Be(99.98m); } } [CollectionDefinition(\u0026#34;postgres\u0026#34;)] public class PostgresCollection : ICollectionFixture\u0026lt;PostgresFixture\u0026gt; { } [Collection(\u0026quot;postgres\u0026quot;)] dit à xUnit de partager la même fixture entre tous les tests de la collection. Un container, beaucoup de tests, rapide.\n💡 Info : xUnit v3 utilise toujours les collection fixtures pour les ressources partagées coûteuses. La collection garantit que les tests à l\u0026rsquo;intérieur ne tournent pas en parallèle, ce qui est exactement ce qu\u0026rsquo;on veut quand ils partagent une base.\nZoom : nettoyer entre les tests #Partager un container entre les tests, ça veut dire que les tests voient les données des autres. Deux stratégies classiques :\n1. Respawn (le plus rapide) : la bibliothèque Respawn (encore de Jimmy Bogard) supprime toutes les lignes entre les tests, en gardant le schéma :\npublic async Task ResetDatabaseAsync() { await using var conn = new NpgsqlConnection(Container.GetConnectionString()); await conn.OpenAsync(); var respawner = await Respawner.CreateAsync(conn, new RespawnerOptions { DbAdapter = DbAdapter.Postgres }); await respawner.ResetAsync(conn); } Appelle ResetDatabaseAsync dans le constructeur du test ou dans un IAsyncLifetime sur la classe de test.\n2. Rollback de transaction : commence une transaction au début de chaque test, laisse le test tourner, rollback à la fin. Plus rapide que Respawn mais incompatible avec du code qui commit sa propre transaction.\n⚠️ Ça marche, mais\u0026hellip; : Un provider in-memory comme Microsoft.EntityFrameworkCore.InMemory est tentant parce qu\u0026rsquo;il est rapide, mais il ignore silencieusement les foreign keys, les contraintes, et tout le comportement SQL-spécifique. C\u0026rsquo;est ok pour tester des services avec une logique EF triviale et dangereux pour tout ce qui touche à une vraie requête. Préfère un vrai container Postgres.\n❌ Ne jamais faire : Ne pointe pas tes tests d\u0026rsquo;intégration vers une base de dev partagée. Deux devs qui lancent la suite en parallèle corrompent l\u0026rsquo;état l\u0026rsquo;un de l\u0026rsquo;autre, et l\u0026rsquo;échec ressemble à un test flaky plutôt qu\u0026rsquo;à une contention sur une ressource commune. TestContainers supprime directement la cause.\nZoom : les scénarios que tu ne pouvais pas tester avant #C\u0026rsquo;est là que TestContainers montre sa vraie valeur. Trois exemples concrets de choses qui étaient quasiment impossibles (ou qui te coûtaient une semaine de YAML) avant, et qui tiennent maintenant dans une fixture.\nComportement spécifique à Postgres : recherche floue avec pg_trgm #Supposons un endpoint de recherche qui trouve des clients par nom approximatif avec l\u0026rsquo;extension pg_trgm. Aucun mock ne peut reproduire le ranking de similarity(). La seule façon de le tester, c\u0026rsquo;est contre un vrai Postgres.\npublic sealed class SearchFixture : IAsyncLifetime { public PostgreSqlContainer Postgres { get; } = new PostgreSqlBuilder() .WithImage(\u0026#34;postgres:17-alpine\u0026#34;) .WithDatabase(\u0026#34;search_test\u0026#34;) .Build(); public async ValueTask InitializeAsync() { await Postgres.StartAsync(); await using var db = CreateDbContext(); await db.Database.MigrateAsync(); // Active l\u0026#39;extension, crée l\u0026#39;index GIN, seed les données de test. await db.Database.ExecuteSqlRawAsync(\u0026#34;CREATE EXTENSION IF NOT EXISTS pg_trgm\u0026#34;); await db.Database.ExecuteSqlRawAsync( \u0026#34;CREATE INDEX idx_customers_name_trgm ON customers USING gin (name gin_trgm_ops)\u0026#34;); db.Customers.AddRange( new Customer(\u0026#34;Jean Dupont\u0026#34;), new Customer(\u0026#34;Jeanne Dupond\u0026#34;), new Customer(\u0026#34;John Doe\u0026#34;)); await db.SaveChangesAsync(); } public ShopDbContext CreateDbContext() =\u0026gt; new(new DbContextOptionsBuilder\u0026lt;ShopDbContext\u0026gt;() .UseNpgsql(Postgres.GetConnectionString()).Options); public ValueTask DisposeAsync() =\u0026gt; Postgres.DisposeAsync(); } [Fact] public async Task Search_renvoie_les_matches_flous_classes_par_similarite() { await using var db = _fixture.CreateDbContext(); var repo = new CustomerRepository(db); var hits = await repo.SearchAsync(\u0026#34;Jen Dupon\u0026#34;, limit: 5); hits.Should().HaveCountGreaterThan(0); hits[0].Name.Should().BeOneOf(\u0026#34;Jean Dupont\u0026#34;, \u0026#34;Jeanne Dupond\u0026#34;); } Le test prouve que l\u0026rsquo;extension est installée, que l\u0026rsquo;index est utilisé, et que le SQL que tu as écrit classe les résultats comme un vrai utilisateur l\u0026rsquo;attend. Un mock du repository ne validerait rien de tout cela, parce que le comportement sous test vit à l\u0026rsquo;intérieur de Postgres, pas dans ton code C#.\nKeycloak avec un vrai realm, utilisateurs, rôles et clients #L\u0026rsquo;autorisation par rôle est difficile à tester correctement. \u0026ldquo;Est-ce que /admin/users refuse un non-admin ?\u0026rdquo; demandait autrefois un Keycloak partagé, un realm paramétré à la main, et une convention que personne ne documentait. Avec TestContainers, tu importes un JSON de realm au démarrage du container, et tu obtiens tout : utilisateurs, mots de passe, rôles, clients, client scopes, mappers.\npublic sealed class KeycloakFixture : IAsyncLifetime { public IContainer Keycloak { get; } = new ContainerBuilder() .WithImage(\u0026#34;quay.io/keycloak/keycloak:26.0\u0026#34;) .WithPortBinding(8080, true) .WithEnvironment(\u0026#34;KC_BOOTSTRAP_ADMIN_USERNAME\u0026#34;, \u0026#34;admin\u0026#34;) .WithEnvironment(\u0026#34;KC_BOOTSTRAP_ADMIN_PASSWORD\u0026#34;, \u0026#34;admin\u0026#34;) .WithResourceMapping( new FileInfo(\u0026#34;test-realm.json\u0026#34;), \u0026#34;/opt/keycloak/data/import/test-realm.json\u0026#34;) .WithCommand(\u0026#34;start-dev\u0026#34;, \u0026#34;--import-realm\u0026#34;) .WithWaitStrategy(Wait.ForUnixContainer() .UntilHttpRequestIsSucceeded(r =\u0026gt; r.ForPath(\u0026#34;/realms/test\u0026#34;).ForPort(8080))) .Build(); public string BaseUrl =\u0026gt; $\u0026#34;http://{Keycloak.Hostname}:{Keycloak.GetMappedPublicPort(8080)}\u0026#34;; public ValueTask InitializeAsync() =\u0026gt; new(Keycloak.StartAsync()); public ValueTask DisposeAsync() =\u0026gt; Keycloak.DisposeAsync(); } test-realm.json vit à côté de la fixture. Il contient alice (rôle admin), bob (rôle user), un client confidentiel, des scopes, tout ce que ton realm de prod a, figé comme donnée de test. Chaque run obtient un Keycloak propre avec exactement le même état.\n[Fact] public async Task Endpoint_admin_refuse_un_utilisateur_non_admin() { var token = await GetTokenAsync(\u0026#34;bob\u0026#34;, \u0026#34;bob-password\u0026#34;); // utilisateur simple _client.DefaultRequestHeaders.Authorization = new(\u0026#34;Bearer\u0026#34;, token); var response = await _client.GetAsync(\u0026#34;/admin/users\u0026#34;); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } Le test passe par un vrai Keycloak, un vrai JWT, le vrai pipeline d\u0026rsquo;autorisation ASP.NET Core, contre la vraie policy. Rien n\u0026rsquo;est mocké. Quand ton mapping de rôles change en prod, ce test te le dit avant le deploy.\nMinIO pour du stockage compatible S3 #Ton code utilise AmazonS3Client pour uploader des factures, générer des URLs présignées, et poser des policies de bucket. Tu veux vérifier que l\u0026rsquo;URL présignée télécharge vraiment le fichier, et qu\u0026rsquo;elle expire quand elle doit.\npublic sealed class MinioFixture : IAsyncLifetime { public IContainer Minio { get; } = new ContainerBuilder() .WithImage(\u0026#34;minio/minio:latest\u0026#34;) .WithPortBinding(9000, true) .WithEnvironment(\u0026#34;MINIO_ROOT_USER\u0026#34;, \u0026#34;minioadmin\u0026#34;) .WithEnvironment(\u0026#34;MINIO_ROOT_PASSWORD\u0026#34;, \u0026#34;minioadmin\u0026#34;) .WithCommand(\u0026#34;server\u0026#34;, \u0026#34;/data\u0026#34;) .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9000)) .Build(); public AmazonS3Client CreateClient() =\u0026gt; new( new BasicAWSCredentials(\u0026#34;minioadmin\u0026#34;, \u0026#34;minioadmin\u0026#34;), new AmazonS3Config { ServiceURL = $\u0026#34;http://{Minio.Hostname}:{Minio.GetMappedPublicPort(9000)}\u0026#34;, ForcePathStyle = true, }); public ValueTask InitializeAsync() =\u0026gt; new(Minio.StartAsync()); public ValueTask DisposeAsync() =\u0026gt; Minio.DisposeAsync(); } À partir de là, tu testes de vrais uploads multipart, de vraies URLs présignées, un vrai comportement d\u0026rsquo;expiration. Le même code client tourne en prod contre AWS S3, et en test contre MinIO, parce que les deux parlent le protocole S3.\n💡 Info : Le pattern se généralise. Si un outil a une image Docker officielle, tu peux le piloter depuis une fixture : RabbitMQ, Kafka, Mongo, Elasticsearch, Vault, Mailhog. Les packages NuGet Testcontainers.* fournissent des builders pré-construits pour les plus courants, et ContainerBuilder s\u0026rsquo;occupe du reste.\nZoom : composer plusieurs services #Les vraies applis ont besoin de plus d\u0026rsquo;une dépendance. Postgres + Keycloak + MinIO + Redis, c\u0026rsquo;est une forme classique. Compose-les dans une seule fixture et démarre-les en parallèle :\npublic sealed class AppServicesFixture : IAsyncLifetime { public PostgreSqlContainer Postgres { get; } = new PostgreSqlBuilder() .WithImage(\u0026#34;postgres:17-alpine\u0026#34;).Build(); public RedisContainer Redis { get; } = new RedisBuilder() .WithImage(\u0026#34;redis:7-alpine\u0026#34;).Build(); public async ValueTask InitializeAsync() { await Task.WhenAll(Postgres.StartAsync(), Redis.StartAsync()); } public async ValueTask DisposeAsync() { await Postgres.DisposeAsync(); await Redis.DisposeAsync(); } } Task.WhenAll les démarre en parallèle, économisant quelques secondes par run. Le premier run télécharge les images, les suivants réutilisent le cache Docker et démarrent en moins de deux secondes chacun.\n✅ Bonne pratique : Mets la fixture dans un projet de test partagé et référence-la depuis IntegrationTests, ApiTests, et E2ETests. Une seule source de vérité pour ce dont l\u0026rsquo;application dépend.\nQuand c\u0026rsquo;est surdimensionné #Tous les projets n\u0026rsquo;ont pas besoin de TestContainers. Un service sans base de données qui ne parle qu\u0026rsquo;à des APIs HTTP stateless peut tout tester en unitaire + WebApplicationFactory. Un prototype qui sera réécrit dans deux mois n\u0026rsquo;a probablement pas besoin de cette mise en place.\nSors TestContainers quand :\nTu as de vraies requêtes EF Core dont le SQL généré compte. Tes tests doivent prouver qu\u0026rsquo;une migration s\u0026rsquo;applique proprement. Tu dépends de Redis, d\u0026rsquo;un broker de messages ou d\u0026rsquo;un stockage compatible S3 dont le vrai comportement compte. Tu as plus d\u0026rsquo;un dev et tu veux que \u0026ldquo;clone et test\u0026rdquo; marche vraiment dès le premier jour. Wrap-up #Tu sais maintenant comment faire démarrer de vraies bases de données et dépendances pour tes tests d\u0026rsquo;intégration avec TestContainers : choisir un container builder, câbler une fixture xUnit avec IAsyncLifetime, appliquer les migrations EF Core dessus, la partager sur une collection de tests, et nettoyer l\u0026rsquo;état entre les tests avec Respawn ou une transaction. Tu peux composer Postgres, Redis et d\u0026rsquo;autres services dans la même fixture et offrir à ton équipe une expérience \u0026ldquo;clone et dotnet test\u0026rdquo; qui marche vraiment.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles Références # Testcontainers for .NET, documentation officielle Tests d\u0026rsquo;intégration dans ASP.NET Core, Microsoft Learn Tester EF Core, Microsoft Learn Respawn sur GitHub ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/testing-integration-testing-testcontainers/","section":"Posts","summary":"","title":"Tests d'Intégration avec TestContainers pour .NET"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va découvrir les tests end-to-end avec Playwright pour .NET.\nLes tests end-to-end ont acquis une réputation difficile au fil des années, et non sans raison. Dix ans de suites Selenium instables, des implicit waits qui n\u0026rsquo;attendent jamais tout à fait assez, des sélecteurs XPath qui cassent à chaque refresh d\u0026rsquo;UI, et des runs de CI qui plantent \u0026ldquo;parfois\u0026rdquo; ont convaincu beaucoup d\u0026rsquo;équipes que le E2E ne valait pas le coup. Ils avaient raison à propos de Selenium. Ils avaient tort à propos du E2E.\nPlaywright a changé la donne. Microsoft l\u0026rsquo;a publié en 2020 comme successeur moderne de Puppeteer, et les bindings .NET ont suivi début 2021. Il embarque Chromium, Firefox et WebKit, fait de l\u0026rsquo;auto-wait sur les éléments avant d\u0026rsquo;agir, isole chaque test dans un contexte de navigateur frais, et livre un générateur de code qui enregistre tes actions en fichier de test. Si tu as lu les articles précédents sur les tests unitaires, les tests d\u0026rsquo;intégration avec TestContainers, et les tests API avec WebApplicationFactory, tu as déjà les couches rapides, peu chères, in-process. Playwright, c\u0026rsquo;est le sommet de la pyramide : plus lent, mais la seule chose qui prouve que l\u0026rsquo;application marche vraiment comme un utilisateur va l\u0026rsquo;utiliser.\nLe contexte : pourquoi ce pattern existe #Supposons que nous ayons une équipe qui livre un tunnel de checkout. Les tests unitaires sont verts. Les tests d\u0026rsquo;API sont verts. Une QA manuelle découvre que le bouton \u0026ldquo;Payer\u0026rdquo; est désactivé quand le formulaire a des erreurs de validation, donc les utilisateurs ne voient jamais le message d\u0026rsquo;erreur que l\u0026rsquo;API renvoie. Une seconde passe découvre une race condition où le total du panier se met à jour après que le handler de soumission se soit déclenché, donc les utilisateurs paient l\u0026rsquo;ancien prix. Aucun des deux bugs n\u0026rsquo;est rattrapable à une couche inférieure au navigateur. Les deux partent en prod.\nCe qu\u0026rsquo;il faut vraiment à cette équipe :\nUn vrai navigateur qui rend la vraie page, pour que les événements DOM, le CSS et le JavaScript se comportent exactement comme un utilisateur les voit. Des sélecteurs fiables qui survivent à des changements mineurs d\u0026rsquo;UI, pour qu\u0026rsquo;un reorder de div ne casse pas 40 tests. Une exécution parallèle rapide et isolée, pour que la suite tourne en minutes, pas en heures, et qu\u0026rsquo;un test flaky n\u0026rsquo;empoisonne pas les autres. Playwright livre les trois.\nVue d\u0026rsquo;ensemble : les pièces # graph TD A[Test Playwright] --\u003e B[Microsoft.Playwright.NUnitou wrapper MSTest / xUnit] B --\u003e C[NavigateurChromium / Firefox / WebKit] C --\u003e D[L'application qui tourneKestrel sur localhost:5000] D --\u003e E[(Postgres depuis TestContainers)] A --\u003e F[Page ObjectCheckoutPage] F --\u003e C Le test pilote un vrai navigateur. Le navigateur parle à l\u0026rsquo;application qui tourne, qui elle-même parle à une vraie base. Le pattern Page Object garde les sélecteurs à un seul endroit, pour que les refactorings d\u0026rsquo;UI touchent un fichier et pas toute la suite.\n💡 Info : Playwright pour .NET livre son propre runner de tests via les packages Microsoft.Playwright.NUnit / Microsoft.Playwright.MSTest. Ils te donnent l\u0026rsquo;exécution parallèle, un contexte navigateur frais par test, et l\u0026rsquo;enregistrement de traces nativement. Tu peux aussi utiliser PlaywrightSharp brut dans xUnit, mais l\u0026rsquo;adapteur NUnit est plus mature.\nZoom : installation et premier test #Installe le package et les navigateurs en une étape :\ndotnet add package Microsoft.Playwright.NUnit dotnet build pwsh bin/Debug/net10.0/playwright.ps1 install Le script install télécharge Chromium, Firefox et WebKit (environ 500 Mo). Commit l\u0026rsquo;invocation dans ton script CI pour que les agents aient les navigateurs au premier run.\nusing Microsoft.Playwright; using Microsoft.Playwright.NUnit; using NUnit.Framework; [Parallelizable(ParallelScope.Self)] public class CheckoutTests : PageTest { [Test] public async Task User_peut_soumettre_une_commande_depuis_le_panier() { await Page.GotoAsync(\u0026#34;http://localhost:5000\u0026#34;); await Page.GetByRole(AriaRole.Link, new() { Name = \u0026#34;Catalogue\u0026#34; }).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Ajouter au panier\u0026#34; }).First.ClickAsync(); await Page.GetByRole(AriaRole.Link, new() { Name = \u0026#34;Panier\u0026#34; }).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Valider\u0026#34; }).ClickAsync(); await Expect(Page.GetByText(\u0026#34;Commande confirmée\u0026#34;)).ToBeVisibleAsync(); } } PageTest te donne une Page fraîche par test et libère tout à la fin. Aucun boilerplate.\n✅ Bonne pratique : Préfère GetByRole, GetByLabel, GetByPlaceholder et GetByText aux sélecteurs CSS ou XPath. Elles correspondent à la façon dont les utilisateurs et les technos d\u0026rsquo;assistance perçoivent la page, et ils survivent aux renommages de classes CSS.\nZoom : locators et auto-wait #La caractéristique la plus distinctive de Playwright, c\u0026rsquo;est l\u0026rsquo;auto-wait. Chaque action (ClickAsync, FillAsync) attend que l\u0026rsquo;élément soit visible, activé et stable avant d\u0026rsquo;agir. Chaque assertion (ToBeVisibleAsync, ToHaveTextAsync) retry jusqu\u0026rsquo;à ce que la condition soit vraie ou qu\u0026rsquo;un timeout expire. Tu n\u0026rsquo;écris quasiment jamais d\u0026rsquo;attente explicite.\n// Playwright attend que le bouton existe, soit activé et visible. await Page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Valider\u0026#34; }).ClickAsync(); // Playwright retry l\u0026#39;assertion jusqu\u0026#39;à 5 secondes par défaut. await Expect(Page.GetByTestId(\u0026#34;order-total\u0026#34;)).ToHaveTextAsync(\u0026#34;199,98 €\u0026#34;); Compare ça au monde Selenium où tu écrivais Thread.Sleep(2000) parce que l\u0026rsquo;élément chargeait depuis une API. Ces sleeps ont disparu.\n❌ Ne jamais faire : N\u0026rsquo;ajoute pas de Task.Delay dans un test Playwright. Si un test échoue par intermittence, la réponse est presque toujours un meilleur sélecteur (utilise getByTestId sur un attribut stable) ou une meilleure assertion (laisse Playwright retry), pas un sleep plus long.\nZoom : le pattern Page Object #Garde les sélecteurs en dehors des tests. Une classe par page ou composant, réutilisée dans plusieurs tests :\npublic sealed class CheckoutPage { private readonly IPage _page; public CheckoutPage(IPage page) =\u0026gt; _page = page; public ILocator CheckoutButton =\u0026gt; _page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Valider\u0026#34; }); public ILocator OrderTotal =\u0026gt; _page.GetByTestId(\u0026#34;order-total\u0026#34;); public ILocator Confirmation =\u0026gt; _page.GetByText(\u0026#34;Commande confirmée\u0026#34;); public Task GotoAsync() =\u0026gt; _page.GotoAsync(\u0026#34;/panier\u0026#34;); public Task SubmitAsync() =\u0026gt; CheckoutButton.ClickAsync(); } [Test] public async Task Checkout_affiche_la_confirmation() { var checkout = new CheckoutPage(Page); await checkout.GotoAsync(); await checkout.SubmitAsync(); await Expect(checkout.Confirmation).ToBeVisibleAsync(); } Quand le bouton \u0026ldquo;Valider\u0026rdquo; devient \u0026ldquo;Passer commande\u0026rdquo; au trimestre suivant, tu changes une ligne dans CheckoutPage.cs et 40 tests continuent de passer.\n💡 Info : Ajoute des attributs data-testid dans tes composants Razor ou React pour les éléments sans nom accessible naturel. GetByTestId(\u0026quot;cart-line-1-qty\u0026quot;) est stable et survit à la plupart des refactorings d\u0026rsquo;UI.\nZoom : héberger l\u0026rsquo;appli sous test #Tu as deux options pour où \u0026ldquo;l\u0026rsquo;appli\u0026rdquo; tourne pendant le test :\n1. La démarrer in-process : utilise WebApplicationFactory (couvert dans l\u0026rsquo;article précédent) pour lancer l\u0026rsquo;appli sur un vrai port Kestrel à l\u0026rsquo;intérieur du process de test.\npublic sealed class AppFixture : IDisposable { private readonly WebApplication _app; public string BaseUrl { get; } public AppFixture() { var builder = WebApplication.CreateBuilder(); // ... mêmes services que Program.cs ... _app = builder.Build(); _app.Urls.Add(\u0026#34;http://127.0.0.1:0\u0026#34;); // port aléatoire libre _app.StartAsync().GetAwaiter().GetResult(); BaseUrl = _app.Urls.First(); } public void Dispose() =\u0026gt; _app.StopAsync().GetAwaiter().GetResult(); } Avantages : pas de dépendance externe, le test possède le cycle de vie. Inconvénient : il te faut du vrai Kestrel, pas le TestServer in-memory, parce que Playwright pilote un vrai navigateur qui a besoin d\u0026rsquo;un vrai socket.\n2. La lancer comme process séparé : un job CI démarre dotnet run en arrière-plan, attend le health endpoint, puis lance la suite Playwright. Plus réaliste, plus proche de la prod, mais plus de éléments à orchestrer.\nL\u0026rsquo;option 1 est le sweet spot pour la plupart des équipes.\n⚠️ Ça marche, mais\u0026hellip; : Associer Playwright à TestContainers pour la base fonctionne, et c\u0026rsquo;est le E2E le plus honnête que tu peux obtenir sans un vrai environnement de staging. Le compromis, c\u0026rsquo;est le temps de démarrage : un run à froid (téléchargement de Postgres et des binaires du navigateur) peut prendre 30 secondes. Un run à chaud est rapide.\nZoom : traces, vidéos, debug #Quand un test échoue en CI, la trace viewer de Playwright est précieuse. Active-la uniquement sur les tests qui plantent :\n[SetUp] public async Task BeforeEach() { await Context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true }); } [TearDown] public async Task AfterEach() { var failed = TestContext.CurrentContext.Result.Outcome != ResultState.Success; await Context.Tracing.StopAsync(new() { Path = failed ? $\u0026#34;traces/{TestContext.CurrentContext.Test.Name}.zip\u0026#34; : null }); } Upload le dossier traces/ comme artefact CI. Ouvre le zip avec pwsh bin/Debug/net10.0/playwright.ps1 show-trace trace.zip et tu vois le snapshot DOM exact à chaque action, avec les appels réseau et les logs console.\n✅ Bonne pratique : Garde la suite E2E petite et à haute valeur. Dix tests qui couvrent les flux critiques (inscription, ajout au panier, checkout, historique de commandes, annulation) valent mieux que cent tests qui cliquent partout sur des pages à faible valeur. La flakiness grandit avec la taille de la suite.\nQuand ne pas utiliser Playwright #Le E2E, c\u0026rsquo;est la couche la plus lente et la plus chère de ta pyramide de tests. Utilise-la pour :\nLes parcours critiques visibles par l\u0026rsquo;utilisateur qui ne doivent pas casser. Les flux qui touchent plusieurs systèmes (auth + API + base + UI). Les régressions après un gros refactoring du front ou de la topologie de déploiement. Ne l\u0026rsquo;utilise pas pour :\nLes règles métier : elles vont dans les tests unitaires. Les contrats d\u0026rsquo;API : ils vont dans les tests WebApplicationFactory. Le comportement base de données : il va dans les tests d\u0026rsquo;intégration TestContainers. Suis la pyramide : beaucoup de tests unitaires, moins de tests d\u0026rsquo;intégration, très peu de tests E2E. Le ratio garde la suite rapide et fiable.\nWrap-up #Tu sais maintenant écrire des tests end-to-end qui survivent vraiment : installer Playwright et ses navigateurs, utiliser des locators basés sur les roles et les labels pour que tes tests reflètent la façon dont les utilisateurs et les outils d\u0026rsquo;accessibilité perçoivent la page, t\u0026rsquo;appuyer sur l\u0026rsquo;auto-wait au lieu de sleeps manuels, organiser les sélecteurs via le pattern Page Object, héberger l\u0026rsquo;appli sous test avec WebApplicationFactory sur un vrai port Kestrel, et capturer des traces pour les runs qui échouent en CI. Tu peux garder la suite concise, concentrée sur les parcours critiques, et la faire tourner en minutes plutôt qu\u0026rsquo;en heures.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles Tests d\u0026rsquo;Intégration avec TestContainers pour .NET Tests API avec WebApplicationFactory en ASP.NET Core Références # Playwright pour .NET, documentation officielle Guide des locators Playwright Auto-waiting Playwright Trace viewer Playwright ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/testing-e2e-playwright/","section":"Posts","summary":"","title":"Tests End-to-End avec Playwright pour .NET"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer le découpage UI / Repositories / Services, probablement le pattern de structure de code que tu as le plus croisé dans ta carrière .NET.\nAvant que Clean Architecture devienne un buzzword et avant que le Vertical Slicing s\u0026rsquo;invite à toutes les confs, il y avait un pattern plus humble qui a livré des milliers d\u0026rsquo;applications .NET en silence : UI, Services, Repositories. Trois dossiers, trois responsabilités, un seul projet. Si tu as déjà ouvert un codebase ASP.NET Core de taille moyenne, il y a de fortes chances que ce soit ce que tu y as trouvé.\nAttention, c\u0026rsquo;est pas exactement la même chose que l\u0026rsquo;architecture N-Couches, même si les deux termes se mélangent souvent dans les conversations. Le N-Couches, c\u0026rsquo;est une séparation physique en plusieurs projets, avec des règles de dépendances vérifiées par le compilateur. UI / Repos / Services, c\u0026rsquo;est une séparation logique à l\u0026rsquo;intérieur d\u0026rsquo;un seul projet. Plus léger, plus rapide à mettre en place, et parfaitement adapté à un paquet d\u0026rsquo;applications. Ça a aussi des modes de défaillance très spécifiques qu\u0026rsquo;il faut savoir reconnaître avant qu\u0026rsquo;ils ne pourrissent ton code.\nLe contexte : pourquoi ce pattern existe #Imaginons que nous ayons une petite équipe qui construit un outil interne. Un seul projet ASP.NET Core, une trentaine d\u0026rsquo;endpoints, une base de données. Créer quatre csproj, tricoter les références entre projets et débattre de savoir si AutoMapper va dans Infrastructure ou dans Application, c\u0026rsquo;est surdimensionné. Ce qu\u0026rsquo;il faut vraiment à cette équipe, c\u0026rsquo;est :\nUn endroit pour le HTTP, pour que les controllers restent lisibles. Un endroit pour la logique métier, pour arrêter de debugger à travers six fichiers juste pour comprendre une règle. Un endroit pour l\u0026rsquo;accès aux données, pour que changer une requête n\u0026rsquo;oblige pas à toucher l\u0026rsquo;UI. Trois dossiers. Trois suffixes. Fini. C\u0026rsquo;est ça le deal : juste assez de structure pour arrêter la pourriture, pas assez pour ralentir la livraison.\nVue d\u0026rsquo;ensemble : les trois boîtes #Avant de rentrer dans le code, voici les grandes briques de ce découpage :\ngraph TD A[Controllers / EndpointsCouche HTTP] --\u003e B[ServicesLogique métier] B --\u003e C[RepositoriesAccès aux données] C --\u003e D[(Base de données)] A -.-\u003e|DTOs| E[Models / Contrats] B -.-\u003e|Entités + DTOs| E C -.-\u003e|Entités| E Dossier Rôle Fichiers typiques Controllers/ Recevoir le HTTP, valider la forme, appeler un service OrdersController.cs Services/ Orchestrer les règles métier, appeler les repositories OrderService.cs, IOrderService.cs Repositories/ Abstraire la base OrderRepository.cs, IOrderRepository.cs Models/ Entités, DTOs, enums Order.cs, CreateOrderRequest.cs Tout vit dans un seul projet. Pas de gymnastique de solution, pas de références circulaires, pas de débat d\u0026rsquo;une heure sur où doit vivre une extension d\u0026rsquo;ILogger.\nZoom technique : brique par brique #Structure dans un seul projet #MyApp.Api/ ├── Controllers/ │ └── OrdersController.cs ├── Services/ │ ├── IOrderService.cs │ └── OrderService.cs ├── Repositories/ │ ├── IOrderRepository.cs │ └── OrderRepository.cs ├── Models/ │ ├── Entities/ │ │ └── Order.cs │ └── Dtos/ │ ├── CreateOrderRequest.cs │ └── OrderResponse.cs ├── Data/ │ └── AppDbContext.cs └── Program.cs 💡 Info : Ce n\u0026rsquo;est pas \u0026ldquo;mal parce que c\u0026rsquo;est un seul projet\u0026rdquo;. Des milliers d\u0026rsquo;applis en prod tournent comme ça. La règle est appliquée par les dossiers et les interfaces, pas par des frontières csproj.\nRepositories : le seam d\u0026rsquo;accès aux données #Un repository, c\u0026rsquo;est une classe ennuyeuse qui emballe EF Core (ou Dapper) et expose des méthodes nommées d\u0026rsquo;après les cas d\u0026rsquo;usage, pas d\u0026rsquo;après le SQL. C\u0026rsquo;est l\u0026rsquo;interface qui sert de point de couplage pour le reste de l\u0026rsquo;appli.\n// Repositories/IOrderRepository.cs public interface IOrderRepository { Task\u0026lt;Order?\u0026gt; GetByIdAsync(Guid id, CancellationToken ct = default); Task\u0026lt;IReadOnlyList\u0026lt;Order\u0026gt;\u0026gt; GetPendingForCustomerAsync(string customerId, CancellationToken ct = default); Task AddAsync(Order order, CancellationToken ct = default); Task\u0026lt;int\u0026gt; SaveChangesAsync(CancellationToken ct = default); } // Repositories/OrderRepository.cs public sealed class OrderRepository : IOrderRepository { private readonly AppDbContext _db; public OrderRepository(AppDbContext db) =\u0026gt; _db = db; public Task\u0026lt;Order?\u0026gt; GetByIdAsync(Guid id, CancellationToken ct = default) =\u0026gt; _db.Orders .AsNoTracking() .Include(o =\u0026gt; o.Lines) .FirstOrDefaultAsync(o =\u0026gt; o.Id == id, ct); public async Task\u0026lt;IReadOnlyList\u0026lt;Order\u0026gt;\u0026gt; GetPendingForCustomerAsync( string customerId, CancellationToken ct = default) =\u0026gt; await _db.Orders .AsNoTracking() .Where(o =\u0026gt; o.CustomerId == customerId \u0026amp;\u0026amp; o.Status == OrderStatus.Pending) .ToListAsync(ct); public async Task AddAsync(Order order, CancellationToken ct = default) =\u0026gt; await _db.Orders.AddAsync(order, ct); public Task\u0026lt;int\u0026gt; SaveChangesAsync(CancellationToken ct = default) =\u0026gt; _db.SaveChangesAsync(ct); } ✅ Bonne pratique : Nomme tes méthodes de repository d\u0026rsquo;après l\u0026rsquo;intention, pas d\u0026rsquo;après la requête SQL. GetPendingForCustomerAsync dit à l\u0026rsquo;appelant pourquoi. GetByCustomerIdAndStatusAsync fait remonter le filtre dans le nom et finit par générer une explosion combinatoire de surcharges.\n❌ Ne jamais faire : N\u0026rsquo;expose pas IQueryable\u0026lt;Order\u0026gt; depuis l\u0026rsquo;interface. Le jour où un controller ou un service commence à chaîner .Where().Include().OrderBy(), ton repository n\u0026rsquo;est plus un seam, c\u0026rsquo;est juste un wrapper fin au-dessus de DbSet. Tu as perdu toutes les raisons d\u0026rsquo;avoir introduit cette abstraction.\nServices : là où vivent les règles #Un service dépend d\u0026rsquo;un ou plusieurs repositories et contient les règles métier. Pas de types HTTP (IActionResult, HttpContext), pas de types EF Core (IQueryable, DbSet). Juste le domaine et les interfaces.\n// Services/IOrderService.cs public interface IOrderService { Task\u0026lt;Guid\u0026gt; CreateAsync(CreateOrderRequest request, CancellationToken ct = default); Task\u0026lt;OrderResponse?\u0026gt; GetAsync(Guid id, CancellationToken ct = default); } // Services/OrderService.cs public sealed class OrderService : IOrderService { private readonly IOrderRepository _orders; private readonly IProductRepository _products; private readonly ILogger\u0026lt;OrderService\u0026gt; _logger; public OrderService( IOrderRepository orders, IProductRepository products, ILogger\u0026lt;OrderService\u0026gt; logger) { _orders = orders; _products = products; _logger = logger; } public async Task\u0026lt;Guid\u0026gt; CreateAsync(CreateOrderRequest request, CancellationToken ct = default) { if (request.Lines.Count == 0) throw new ValidationException(\u0026#34;Une commande doit avoir au moins une ligne.\u0026#34;); var productIds = request.Lines.Select(l =\u0026gt; l.ProductId).ToHashSet(); var products = await _products.GetManyAsync(productIds, ct); if (products.Count != productIds.Count) throw new ValidationException(\u0026#34;Un ou plusieurs produits n\u0026#39;existent pas.\u0026#34;); var order = new Order { Id = Guid.NewGuid(), CustomerId = request.CustomerId, Status = OrderStatus.Pending, CreatedAt = DateTime.UtcNow, Lines = request.Lines .Select(l =\u0026gt; new OrderLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = products[l.ProductId].Price }) .ToList() }; await _orders.AddAsync(order, ct); await _orders.SaveChangesAsync(ct); _logger.LogInformation(\u0026#34;Commande {OrderId} créée pour {CustomerId}\u0026#34;, order.Id, order.CustomerId); return order.Id; } public async Task\u0026lt;OrderResponse?\u0026gt; GetAsync(Guid id, CancellationToken ct = default) { var order = await _orders.GetByIdAsync(id, ct); return order?.ToResponse(); } } ✅ Bonne pratique : Un service peut dépendre de plusieurs repositories. C\u0026rsquo;est l\u0026rsquo;endroit naturel pour orchestrer un workflow du genre \u0026ldquo;vérifier le stock produit, créer la commande, publier un événement\u0026rdquo;. Le jour où un service commence à dépendre d\u0026rsquo;un autre service, arrête-toi et demande-toi si cette collaboration cachée n\u0026rsquo;est pas en fait un seul cas d\u0026rsquo;usage déguisé.\n⚠️ Ça marche, mais\u0026hellip; : Lever une ValidationException, c\u0026rsquo;est très bien pour les petites applis. Dès que tu as plus d\u0026rsquo;une poignée de chemins de validation, le Result pattern vaut sa cérémonie. C\u0026rsquo;est couvert dans la série Error Handling.\nControllers : fins, ennuyeux, prévisibles #Les controllers doivent être les fichiers les moins intéressants du projet. Recevoir, appeler, retourner.\n// Controllers/OrdersController.cs [ApiController] [Route(\u0026#34;api/orders\u0026#34;)] public sealed class OrdersController : ControllerBase { private readonly IOrderService _orders; public OrdersController(IOrderService orders) =\u0026gt; _orders = orders; [HttpPost] [ProducesResponseType(typeof(CreatedResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task\u0026lt;IActionResult\u0026gt; Create( [FromBody] CreateOrderRequest request, CancellationToken ct) { var id = await _orders.CreateAsync(request, ct); return CreatedAtAction(nameof(Get), new { id }, new CreatedResponse(id)); } [HttpGet(\u0026#34;{id:guid}\u0026#34;)] [ProducesResponseType(typeof(OrderResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task\u0026lt;IActionResult\u0026gt; Get(Guid id, CancellationToken ct) { var order = await _orders.GetAsync(id, ct); return order is null ? NotFound() : Ok(order); } } ✅ Bonne pratique : Si une action de controller fait plus de 5 à 10 lignes, c\u0026rsquo;est que quelque chose a fuité du service. Fais-le redescendre.\nLe câblage dans Program.cs #Tout est enregistré une fois, en général scoped par requête pour que la durée de vie du DbContext colle.\nvar builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext\u0026lt;AppDbContext\u0026gt;(opt =\u0026gt; opt.UseNpgsql(builder.Configuration.GetConnectionString(\u0026#34;Default\u0026#34;))); // Repositories builder.Services.AddScoped\u0026lt;IOrderRepository, OrderRepository\u0026gt;(); builder.Services.AddScoped\u0026lt;IProductRepository, ProductRepository\u0026gt;(); // Services builder.Services.AddScoped\u0026lt;IOrderService, OrderService\u0026gt;(); builder.Services.AddControllers(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); app.MapControllers(); app.Run(); 💡 Info : Disponible depuis .NET 8, AddProblemDetails() combiné à UseExceptionHandler() te donne des réponses d\u0026rsquo;erreur RFC 7807 sans rien écrire de custom. Pas besoin de middleware maison pour le cas général.\nUI / Repos / Services vs N-Couches : ce qui change vraiment #Sur un diagramme, ça se ressemble. La différence est physique :\nAspect UI / Repos / Services N-Couches Projets 1 3 ou 4 (Api, Application, Infrastructure, Domain) Règles de dépendances Respectées à la review Respectées par le compilateur Temps de mise en place Quelques minutes Un après-midi Coût d\u0026rsquo;un refactor Déplacer des fichiers Déplacer des fichiers + corriger les csproj Idéal pour Petites et moyennes applis, solo ou petite équipe Applis multi-équipes ou contraintes fortes La version mono-projet n\u0026rsquo;est pas un petit cousin honteux. C\u0026rsquo;est le bon compromis pour une énorme partie du travail qu\u0026rsquo;on fait vraiment. Passe à la saveur N-Couches multi-projets seulement quand l\u0026rsquo;enforcement par le compilateur est en train de te rapporter plus qu\u0026rsquo;il ne te coûte.\nOù ça commence à faire mal #Les mêmes modes de défaillance que le N-Couches, mais amplifiés par l\u0026rsquo;absence de garde-fou du compilateur :\nServices obèses : OrderService finit avec 30 méthodes parce que chaque nouveau endpoint ajoute une méthode plutôt qu\u0026rsquo;une classe. Fuites dans les repositories : quelqu\u0026rsquo;un ajoute un GetOrdersForAdminDashboardWithFiltersAndSortingAsync et plus personne n\u0026rsquo;ose le découper. Couplage inter-services : OrderService appelle InvoiceService, qui appelle NotificationService, qui appelle OrderService. Bienvenue dans le cycle. Dérive des DTOs : sans projet Domain pour tenir la ligne, DTOs et entités commencent à se référencer dans les deux sens. Quand ces symptômes apparaissent, la réponse n\u0026rsquo;est pas \u0026ldquo;ajouter plus de dossiers\u0026rdquo;. C\u0026rsquo;est soit passer à une vraie Clean Architecture (pour enforcer la direction), soit au Vertical Slicing (pour arrêter d\u0026rsquo;organiser par rôle technique).\nWrap-up #Tu sais maintenant ce qu\u0026rsquo;est vraiment le pattern UI / Repositories / Services, en quoi il diffère du N-Couches formel, et comment le câbler proprement dans un seul projet ASP.NET Core. Tu peux choisir ce découpage en conscience pour tes petites et moyennes applis, garder tes controllers fins, isoler ton accès aux données derrière des méthodes de repository nommées par intention, et reconnaître le moment exact où il arrête de te rendre service.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # L\u0026rsquo;architecture N-Couches en .NET : les fondations que tu dois maîtriser Références # Architectures d\u0026rsquo;applications web courantes, Microsoft Learn Injection de dépendances dans ASP.NET Core, Microsoft Learn Problem Details pour les API HTTP dans ASP.NET Core, Microsoft Learn Entity Framework Core, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/code-structure-ui-repos-services/","section":"Posts","summary":"","title":"UI / Repositories / Services : le découpage .NET pragmatique"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va découvrir le Vertical Slicing, le pattern qui remet en cause à peu près tout ce que les patterns précédents de cette série t\u0026rsquo;ont appris.\nTous les patterns en couches qu\u0026rsquo;on a vus jusqu\u0026rsquo;ici, le N-Couches, le UI / Repositories / Services et la Clean Architecture, partagent la même hypothèse de base : la bonne façon de découper le code, c\u0026rsquo;est par rôle technique. Les controllers ici, les services là, les repositories au fond, les entités au milieu. C\u0026rsquo;est tellement ancré dans la communauté .NET que la plupart des devs ne remettent jamais ça en question.\nLe Vertical Slicing le remet directement en question. Son affirmation est simple : les fonctionnalités changent ensemble, donc elles doivent vivre ensemble. Quand tu livres \u0026ldquo;soumettre une commande\u0026rdquo;, tu touches un controller, un service, un validator, une requête, un DTO de réponse, et probablement un appel à la base. Dans un découpage horizontal, ces six morceaux vivent dans six dossiers différents. Dans une slice verticale, ils vivent dans un seul. L\u0026rsquo;idée a été formalisée par Jimmy Bogard vers 2018, en s\u0026rsquo;appuyant sur son expérience avec MediatR et CQRS sur de vrais codebases .NET, comme réponse à la friction que les architectures en couches ajoutent au travail quotidien sur les fonctionnalités.\nLe contexte : pourquoi ce pattern existe #Supposons que nous soyons en planning de sprint. L\u0026rsquo;équipe prend quatre stories : \u0026ldquo;exporter les factures\u0026rdquo;, \u0026ldquo;rembourser une commande\u0026rdquo;, \u0026ldquo;envoyer un email de bienvenue\u0026rdquo;, \u0026ldquo;mettre un produit en vedette\u0026rdquo;. Dans une architecture horizontale, chaque story traverse cinq dossiers. Deux devs qui bossent sur deux stories finissent par éditer OrderService.cs en même temps, et à résoudre des conflits de merge dans un fichier qu\u0026rsquo;aucun des deux ne possède pleinement. Un troisième dev réutilise une méthode dans OrderRepository qui était taillée pour une autre fonctionnalité, et un bug subtil passe en prod. La revue de code prend plus de temps parce que les relecteurs doivent sauter entre sept fichiers pour suivre un seul changement. Rien de tout ça n\u0026rsquo;est la faute de quiconque : c\u0026rsquo;est le coût d\u0026rsquo;organiser le code par rôle technique quand le travail arrive fonctionnalité par fonctionnalité.\nL\u0026rsquo;insight, c\u0026rsquo;est que les architectures en couches optimisent pour l\u0026rsquo;axe de réutilisation, qui est rarement l\u0026rsquo;axe de changement. Les fonctionnalités changent. Les couches, pas vraiment. Alors pourquoi on s\u0026rsquo;organise autour des couches ?\nLe Vertical Slicing inverse le défaut :\nChaque fonctionnalité possède son propre dossier, avec tout ce dont elle a besoin : requête, handler, validator, réponse. La réutilisation inter-fonctionnalités est l\u0026rsquo;exception, pas la règle. Un peu de duplication est acceptable quand ça isole les changements. Les abstractions émergent des patterns observés, pas d\u0026rsquo;un jardinage préventif d\u0026rsquo;interfaces. Vue d\u0026rsquo;ensemble : concerns vs features, côte à côte #Le moyen le plus rapide de voir ce que le Vertical Slicing change vraiment, c\u0026rsquo;est de comparer les deux modèles mentaux sur le même ensemble de fonctionnalités. Mêmes trois features, mêmes trois préoccupations techniques, deux façons complètement différentes de les poser sur disque :\ngraph LR subgraph H[\"Horizontal slicing : par concern\"] direction TB HC[Controllers] HS[Services] HR[Repositories] HC --\u003e HS HS --\u003e HR end subgraph V[\"Vertical slicing : par feature\"] direction TB subgraph S1[SubmitOrder] direction TB A1[Endpoint] --\u003e A2[Handler] --\u003e A3[DB] end subgraph S2[RefundOrder] direction TB B1[Endpoint] --\u003e B2[Handler] --\u003e B3[DB] end subgraph S3[ExportInvoices] direction TB C1[Endpoint] --\u003e C2[Handler] --\u003e C3[DB] end end À gauche, chaque fonctionnalité doit traverser trois couches partagées, donc chaque sprint tire plusieurs devs dans les mêmes dossiers Controllers/, Services/, Repositories/. À droite, chaque fonctionnalité est une colonne autonome : livrer RefundOrder ne t\u0026rsquo;oblige jamais à ouvrir SubmitOrder. Les concerns techniques sont toujours là, ils vivent juste à l\u0026rsquo;intérieur de la feature au lieu d\u0026rsquo;être éparpillés dans tout le projet.\nLa forme d\u0026rsquo;une slice #Avant de rentrer dans le code, voici comment une seule slice verticale s\u0026rsquo;installe dans un projet .NET :\ngraph TD subgraph \"Features/Orders/SubmitOrder\" A[SubmitOrderEndpoint.cs] B[SubmitOrderCommand.cs] C[SubmitOrderHandler.cs] D[SubmitOrderValidator.cs] E[SubmitOrderResponse.cs] end subgraph \"Features/Orders/GetOrderDetails\" F[GetOrderDetailsEndpoint.cs] G[GetOrderDetailsQuery.cs] H[GetOrderDetailsHandler.cs] I[GetOrderDetailsResponse.cs] end subgraph \"Shared\" J[ShopDbContext] K[Entités de domaine] end C --\u003e J H --\u003e J C --\u003e K Deux fonctionnalités, deux dossiers, tout ce qu\u0026rsquo;il faut pour livrer une feature au même endroit. Le seul code partagé, c\u0026rsquo;est le DbContext et les entités de domaine, et c\u0026rsquo;est fait exprès.\n💡 Info : Le Vertical Slice Architecture a été popularisé par Jimmy Bogard, le créateur de MediatR et AutoMapper. Ce n\u0026rsquo;est pas une spécification formelle. C\u0026rsquo;est un ensemble de principes à appliquer avec du jugement, et la disposition des dossiers n\u0026rsquo;est que la partie visible.\nZoom : une vraie slice, de bout en bout #Écrivons la fonctionnalité SubmitOrder comme une slice autonome avec Minimal APIs, MediatR et FluentValidation. Tout vit dans Features/Orders/SubmitOrder/.\n// Features/Orders/SubmitOrder/SubmitOrderCommand.cs public sealed record SubmitOrderCommand(Guid OrderId) : IRequest\u0026lt;SubmitOrderResponse\u0026gt;; // Features/Orders/SubmitOrder/SubmitOrderResponse.cs public sealed record SubmitOrderResponse(Guid OrderId, string Status, decimal Total); // Features/Orders/SubmitOrder/SubmitOrderValidator.cs public sealed class SubmitOrderValidator : AbstractValidator\u0026lt;SubmitOrderCommand\u0026gt; { public SubmitOrderValidator() { RuleFor(x =\u0026gt; x.OrderId).NotEmpty(); } } // Features/Orders/SubmitOrder/SubmitOrderHandler.cs public sealed class SubmitOrderHandler : IRequestHandler\u0026lt;SubmitOrderCommand, SubmitOrderResponse\u0026gt; { private readonly ShopDbContext _db; private readonly IPaymentGateway _payments; public SubmitOrderHandler(ShopDbContext db, IPaymentGateway payments) { _db = db; _payments = payments; } public async Task\u0026lt;SubmitOrderResponse\u0026gt; Handle( SubmitOrderCommand cmd, CancellationToken ct) { var order = await _db.Orders .Include(o =\u0026gt; o.Lines) .FirstOrDefaultAsync(o =\u0026gt; o.Id == cmd.OrderId, ct) ?? throw new NotFoundException($\u0026#34;Commande {cmd.OrderId} introuvable.\u0026#34;); order.Submit(); var charge = await _payments.ChargeAsync( order.CustomerId, order.Total, ct); if (!charge.Success) throw new PaymentFailedException(charge.Error); await _db.SaveChangesAsync(ct); return new SubmitOrderResponse( order.Id, order.Status.ToString(), order.Total.Amount); } } // Features/Orders/SubmitOrder/SubmitOrderEndpoint.cs public static class SubmitOrderEndpoint { public static void MapSubmitOrder(this IEndpointRouteBuilder app) { app.MapPost(\u0026#34;/orders/{id:guid}/submit\u0026#34;, async ( Guid id, ISender mediator, CancellationToken ct) =\u0026gt; { var response = await mediator.Send(new SubmitOrderCommand(id), ct); return Results.Ok(response); }) .WithName(\u0026#34;SubmitOrder\u0026#34;) .WithTags(\u0026#34;Orders\u0026#34;); } } Cinq fichiers, un dossier, une fonctionnalité. Si tu dois comprendre tout le flux, tu ouvres le dossier et tu lis de haut en bas. Si tu dois changer la façon dont une commande est soumise, toutes les lignes que tu vas toucher sont dans le même répertoire. Pas de chasse au grep.\n✅ Bonne pratique : Garde la requête, le validator, le handler et la réponse en sealed, scopés à la fonctionnalité. Ne les expose pas à l\u0026rsquo;extérieur. Si une autre slice a besoin du même concept, c\u0026rsquo;est souvent le signe qu\u0026rsquo;il ne faut pas réutiliser : écris une nouvelle commande avec la forme qui colle au nouveau cas.\nZoom : le côté lecture est encore plus simple #Les lectures n\u0026rsquo;ont pas besoin de passer par le modèle de domaine. Une query de slice verticale peut projeter directement depuis EF Core (ou Dapper) vers la forme exacte de la réponse. Pas de repository, pas de mapper, pas de chaîne de montage de DTOs.\n// Features/Orders/GetOrderDetails/GetOrderDetailsQuery.cs public sealed record GetOrderDetailsQuery(Guid OrderId) : IRequest\u0026lt;GetOrderDetailsResponse\u0026gt;; // Features/Orders/GetOrderDetails/GetOrderDetailsResponse.cs public sealed record GetOrderDetailsResponse( Guid Id, string CustomerName, decimal Total, string Status, IReadOnlyList\u0026lt;LineDto\u0026gt; Lines); public sealed record LineDto(string ProductName, int Quantity, decimal Subtotal); // Features/Orders/GetOrderDetails/GetOrderDetailsHandler.cs public sealed class GetOrderDetailsHandler : IRequestHandler\u0026lt;GetOrderDetailsQuery, GetOrderDetailsResponse\u0026gt; { private readonly ShopDbContext _db; public GetOrderDetailsHandler(ShopDbContext db) =\u0026gt; _db = db; public async Task\u0026lt;GetOrderDetailsResponse\u0026gt; Handle( GetOrderDetailsQuery q, CancellationToken ct) { return await _db.Orders .AsNoTracking() .Where(o =\u0026gt; o.Id == q.OrderId) .Select(o =\u0026gt; new GetOrderDetailsResponse( o.Id, o.Customer.Name, o.Lines.Sum(l =\u0026gt; l.Quantity * l.UnitPrice), o.Status.ToString(), o.Lines.Select(l =\u0026gt; new LineDto( l.Product.Name, l.Quantity, l.Quantity * l.UnitPrice)) .ToList())) .FirstOrDefaultAsync(ct) ?? throw new NotFoundException($\u0026#34;Commande {q.OrderId} introuvable.\u0026#34;); } } Une seule requête SQL. Une seule projection. Zéro repository. Le côté lecture ne prétend pas respecter le modèle de domaine, parce qu\u0026rsquo;il n\u0026rsquo;en a pas besoin : il n\u0026rsquo;y a pas d\u0026rsquo;invariant à faire respecter quand tu affiches juste des données.\n💡 Info : C\u0026rsquo;est l\u0026rsquo;idée du CQRS appliquée au niveau de la slice. Les commandes passent par le domaine (pour faire respecter les invariants). Les queries le court-circuitent (pour la vitesse et la simplicité). Pas besoin de bases de données séparées ou d\u0026rsquo;event sourcing pour en profiter. Pour le tableau complet de ce qu\u0026rsquo;est CQRS, d\u0026rsquo;où ça vient, et comment l\u0026rsquo;appliquer sans sur-ingénierie, vois l\u0026rsquo;article dédié : Couche Application en .NET : CQS et CQRS sans le hype.\n⚠️ Ça marche, mais\u0026hellip; : Résiste à la tentation d\u0026rsquo;introduire une interface IReadRepository pour les queries. Ça ajoute une couche d\u0026rsquo;indirection qu\u0026rsquo;aucune autre fonctionnalité ne réutilisera jamais. Si tu veux le mocker pour les tests, mocke le DbContext ou utilise un provider in-memory.\nZoom : où vivent encore les abstractions #Vertical Slicing, ce n\u0026rsquo;est pas \u0026ldquo;pas de code partagé du tout\u0026rdquo;. Certaines choses sont vraiment partagées et ont leur place en dehors des slices :\nLes entités de domaine et les value objects qui portent des invariants. Order.Submit() vit toujours dans le domaine. Les slices l\u0026rsquo;appellent. L\u0026rsquo;infrastructure transverse : le DbContext, le bus de messages, l\u0026rsquo;expéditeur d\u0026rsquo;emails, l\u0026rsquo;interface du prestataire de paiement. Les pipeline behaviors (logging, validation, transaction) qui s\u0026rsquo;exécutent autour de chaque handler. Un layout de projet typique ressemble à ça :\nsrc/ Shop.Api/ Features/ Orders/ SubmitOrder/ GetOrderDetails/ CancelOrder/ Customers/ Register/ UpdateProfile/ Domain/ (entités, value objects) Infrastructure/ (DbContext, configs EF, clients externes) Common/ (pipeline behaviors, problem details, result types) Program.cs Remarque l\u0026rsquo;absence des dossiers Controllers/, Services/ et Repositories/. Ces formes émergent par fonctionnalité, pas imposées au niveau du projet.\n✅ Bonne pratique : Ajoute un pipeline behavior MediatR pour la validation, comme ça chaque handler récupère FluentValidation gratuitement. Un fichier dans Common/, toutes les slices en profitent, aucune slice n\u0026rsquo;a à le câbler.\n// Common/Behaviors/ValidationBehavior.cs public sealed class ValidationBehavior\u0026lt;TRequest, TResponse\u0026gt; : IPipelineBehavior\u0026lt;TRequest, TResponse\u0026gt; where TRequest : IRequest\u0026lt;TResponse\u0026gt; { private readonly IEnumerable\u0026lt;IValidator\u0026lt;TRequest\u0026gt;\u0026gt; _validators; public ValidationBehavior(IEnumerable\u0026lt;IValidator\u0026lt;TRequest\u0026gt;\u0026gt; validators) =\u0026gt; _validators = validators; public async Task\u0026lt;TResponse\u0026gt; Handle( TRequest request, RequestHandlerDelegate\u0026lt;TResponse\u0026gt; next, CancellationToken ct) { if (!_validators.Any()) return await next(); var context = new ValidationContext\u0026lt;TRequest\u0026gt;(request); var failures = (await Task.WhenAll(_validators .Select(v =\u0026gt; v.ValidateAsync(context, ct)))) .SelectMany(r =\u0026gt; r.Errors) .Where(f =\u0026gt; f is not null) .ToList(); if (failures.Count != 0) throw new ValidationException(failures); return await next(); } } La question de la duplication #Le Vertical Slicing va te pousser à écrire du code qui a l\u0026rsquo;air dupliqué. Deux slices vont toutes les deux lire une commande par id. Deux handlers vont toutes les deux utiliser ShopDbContext. Un dev junior va demander \u0026ldquo;on extrait pas ça dans un helper ?\u0026rdquo;. La réponse, neuf fois sur dix, c\u0026rsquo;est non.\nLa duplication est bon marché. Le couplage, lui, coûte cher. Dès que tu extrais une méthode partagée utilisée par deux slices, les slices cessent d\u0026rsquo;être indépendantes : changer l\u0026rsquo;une sans vérifier l\u0026rsquo;autre devient impossible. Quelques lignes d\u0026rsquo;EF Core dupliquées, c\u0026rsquo;est une feature, pas un bug. Ça laisse la slice A évoluer sans casser la slice B.\nExtrais uniquement quand :\nTu retrouves le même pattern dans trois slices ou plus. Le pattern est vraiment stable et a un nom clair. L\u0026rsquo;extraction supprime un vrai risque, pas juste des lignes. ❌ Ne jamais faire : Évite de construire une BaseHandler\u0026lt;TCommand, TResponse\u0026gt; avec des helpers protégés partagés entre les fonctionnalités. Ça paraît propre au début, et au bout de six mois le moindre changement sur la classe de base impacte toutes les slices d\u0026rsquo;un coup, ce qui est exactement le couplage que le Vertical Slicing cherche à éviter. Garde chaque slice indépendamment supprimable.\nLà où ça commence à faire mal #Aucun pattern n\u0026rsquo;est gratuit. Les modes de défaillance du Vertical Slicing sont différents de ceux des patterns en couches, et il faut les connaître :\nPas de frontières de domaine imposées : rien n\u0026rsquo;empêche un handler de court-circuiter une méthode de domaine et de muter une entité directement. Il faut de la discipline, ou des tests d\u0026rsquo;architecture, pour garder les invariants à leur place. Discoverability pour les nouveaux : un dev habitué à \u0026ldquo;je dois changer le service des commandes, j\u0026rsquo;ouvre OrderService.cs\u0026rdquo; doit apprendre un nouveau modèle mental. \u0026ldquo;Je dois changer la façon dont les commandes sont soumises, j\u0026rsquo;ouvre Features/Orders/SubmitOrder/.\u0026rdquo; Coordination inter-slices : quand une règle métier s\u0026rsquo;étend sur quatre fonctionnalités, tu as quatre endroits à mettre à jour. Un bon nommage et les événements de domaine aident, mais c\u0026rsquo;est du vrai boulot. Peu d\u0026rsquo;intérêt sur des très petites applis : si tu as quinze endpoints et pas de vrai domaine, un découpage en slices verticales est surdimensionné. UI / Repos / Services reste probablement le bon choix. Le Vertical Slicing brille sur des applications de taille moyenne à large, avec des équipes actives, où les fonctionnalités changent souvent et où deux devs sur deux fonctionnalités ne doivent pas se marcher dessus.\nWrap-up #Tu sais maintenant ce qu\u0026rsquo;est vraiment le Vertical Slicing : organiser le codebase par fonctionnalité pour que tout ce qu\u0026rsquo;il faut pour livrer un changement vive au même endroit. Tu peux écrire des slices autonomes avec commande, handler, validator et endpoint, court-circuiter le domaine sur les chemins de lecture pour des queries plus simples, garder les vraies préoccupations partagées dans un dossier Common ou Infrastructure fin, et résister à la tentation de dédupliquer trop tôt. Tu peux aussi reconnaître quand ce pattern colle et quand un découpage plus traditionnel reste la bonne réponse.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # L\u0026rsquo;architecture N-Couches en .NET : les fondations que tu dois maîtriser UI / Repositories / Services : le découpage .NET pragmatique Clean Architecture en .NET : des dépendances qui pointent dans le bon sens Références # Architectures d\u0026rsquo;applications web courantes, Microsoft Learn Minimal APIs dans ASP.NET Core, Microsoft Learn MediatR sur GitHub Documentation FluentValidation Entity Framework Core, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/code-structure-vertical-slicing/","section":"Posts","summary":"","title":"Vertical Slicing en .NET : organise par fonctionnalité, pas par couche"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/vertical-slicing/","section":"Tags","summary":"","title":"Vertical-Slicing"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/webapplicationfactory/","section":"Tags","summary":"","title":"Webapplicationfactory"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/fr/tags/xunit/","section":"Tags","summary":"","title":"Xunit"},{"content":"Hello tous le monde, aujourd\u0026rsquo;hui on va explorer le zero allocation en .NET, et les techniques qui permettent de sortir du rôle passif de \u0026ldquo;on laisse le GC faire\u0026rdquo; quand la performance le demande.\nPour 95% du code .NET, le garbage collector est un aide silencieux auquel personne ne pense. Les allocations ont lieu, la mémoire est récupérée, le programme continue. Pour les 5% restants, les chemins chauds d\u0026rsquo;un système à fort débit, le garbage collector est le goulet d\u0026rsquo;étranglement, et chaque octet alloué par requête devient une requête par seconde que le système ne pourra pas servir. La différence entre ces deux mondes n\u0026rsquo;est pas la qualité du code, c\u0026rsquo;est la fréquence : à 100 000 requêtes par seconde, une seule allocation de 1 Ko par requête devient 100 Mo par seconde de pression sur le heap, et le GC se met à tourner en continu pour suivre.\nLa programmation zero-allocation est l\u0026rsquo;ensemble des techniques qui permettent au code sensible à la performance d\u0026rsquo;éviter de produire des déchets sur les chemins chauds. Ce n\u0026rsquo;est pas un style à appliquer partout. C\u0026rsquo;est une boîte à outils à sortir quand un test de stress ou un soak montre que les temps de pause GC, la fréquence des collectes gen0, ou la pression sur le heap plafonnent le débit. Utilisé aux bons endroits, cela peut doubler ou tripler la capacité d\u0026rsquo;un système sans rien changer d\u0026rsquo;autre.\nLe contexte : pourquoi le zero allocation compte #Le garbage collector .NET est générationnel. Les objets démarrent en gen0, passent en gen1 s\u0026rsquo;ils vivent assez longtemps, et atteignent gen2 s\u0026rsquo;ils survivent à deux collectes. Collecter gen0 est peu coûteux (quelques centaines de microsecondes), gen1 est plus cher, gen2 est celui qui produit des pauses applicatives visibles et peut prendre des dizaines de millisecondes. Un système bien élevé garde la plupart de ses allocations en gen0, où la collecte est presque gratuite.\nLe problème est que \u0026ldquo;presque gratuit\u0026rdquo; n\u0026rsquo;est pas gratuit. Chaque collecte gen0 arrête les threads managés (en server GC mode, brièvement), mesure les racines, compacte la jeune génération, et reprend. À 100 000 requêtes par seconde, si chaque requête alloue 2 Ko, gen0 se remplit en millisecondes, et le GC tourne plusieurs fois par seconde. Chaque passe introduit du jitter, des pics de latence, et de la contention avec le vrai travail.\nLe code zero-allocation change cette équation. Au lieu d\u0026rsquo;allouer à chaque opération, il réutilise des buffers, utilise la pile pour les données temporaires, et garde le heap managé au calme. Les objectifs sont concrets :\nUne latence en queue stable, parce que moins de pauses GC signifie moins de pics de latence au p99 et au p99.9. Un débit plus élevé, parce que le CPU passe moins de temps à collecter et plus de temps à faire tourner le code applicatif. Moins de pression mémoire, parce que le working set reste borné et que le système peut tasser plus d\u0026rsquo;instances par hôte. Un comportement prévisible sous charge, parce que le GC n\u0026rsquo;est plus l\u0026rsquo;une des pièces mobiles dont le coût croît avec le trafic. Vue d\u0026rsquo;ensemble : la pyramide des allocations # graph TD A[Types valeur sur la pilegratuit] --\u003e B[Span\u0026lt;T\u0026gt; sur stackallocgratuit] B --\u003e C[ArrayPool\u0026lt;T\u0026gt;réutilisé, pas alloué] C --\u003e D[Objets poolésObjectPool\u0026lt;T\u0026gt;] D --\u003e E[Heap Gen0peu cher, mais pas gratuit] E --\u003e F[LOH / Gen2cher, à éviter] Toutes les allocations ne se valent pas. La pyramide ci-dessus classe les options du moins cher au plus cher. Le principe directeur est simple : sur un chemin chaud, essayer de rester le plus haut possible dans la pyramide. Si la donnée tient sur la pile, la mettre sur la pile. Si elle ne tient pas, la louer à un pool. Si ni l\u0026rsquo;un ni l\u0026rsquo;autre ne marche, au moins garder l\u0026rsquo;allocation en gen0 et hors du Large Object Heap.\nCet article couvre quatre techniques dans cette pyramide, chacune s\u0026rsquo;appliquant à une situation précise.\nZoom : Span\u0026lt;T\u0026gt; et stackalloc #Span\u0026lt;T\u0026gt; a été introduit dans .NET Core 2.1 comme l\u0026rsquo;abstraction canonique au-dessus de la mémoire contiguë. Il peut pointer vers un tableau managé, vers un pointeur natif, vers une portion de string, ou vers de la mémoire allouée sur la pile, avec la même API. Combiné à stackalloc, il permet des buffers zero-allocation pour les opérations de courte durée.\npublic static bool IsValidIban(ReadOnlySpan\u0026lt;char\u0026gt; iban) { if (iban.Length \u0026lt; 15 || iban.Length \u0026gt; 34) return false; // Buffer sur la pile, aucune allocation heap. Span\u0026lt;char\u0026gt; rearranged = stackalloc char[iban.Length]; iban[4..].CopyTo(rearranged); iban[..4].CopyTo(rearranged[^4..]); // Conversion en chiffres, validation modulo 97. Span\u0026lt;byte\u0026gt; digits = stackalloc byte[rearranged.Length * 2]; int digitCount = 0; foreach (char c in rearranged) { if (char.IsDigit(c)) digits[digitCount++] = (byte)(c - \u0026#39;0\u0026#39;); else if (c is \u0026gt;= \u0026#39;A\u0026#39; and \u0026lt;= \u0026#39;Z\u0026#39;) { int value = c - \u0026#39;A\u0026#39; + 10; digits[digitCount++] = (byte)(value / 10); digits[digitCount++] = (byte)(value % 10); } else return false; } int remainder = 0; for (int i = 0; i \u0026lt; digitCount; i++) remainder = (remainder * 10 + digits[i]) % 97; return remainder == 1; } Cette méthode valide un IBAN sans allouer un seul octet sur le heap. Les buffers stackalloc vivent dans la frame de pile courante et sont récupérés automatiquement au retour de la méthode. L\u0026rsquo;appelant passe un ReadOnlySpan\u0026lt;char\u0026gt;, qui peut venir d\u0026rsquo;un string, d\u0026rsquo;un body de requête parsé, ou d\u0026rsquo;un autre span, sans coût d\u0026rsquo;allocation.\n💡 Info : stackalloc est sûr à l\u0026rsquo;intérieur d\u0026rsquo;une méthode qui ne stocke pas le span résultant dans un champ et ne le retourne pas. Le compilateur le garantit via les règles ref struct de Span\u0026lt;T\u0026gt;. La taille du buffer sur la pile doit rester sous environ 1 Ko pour éviter le risque de StackOverflowException. Pour des buffers plus grands, passer à ArrayPool\u0026lt;T\u0026gt;.\n✅ Bonne pratique : Accepter ReadOnlySpan\u0026lt;char\u0026gt; ou ReadOnlySpan\u0026lt;byte\u0026gt; en paramètre de méthode plutôt que string ou byte[]. Les appelants peuvent passer des tranches de données existantes sans copie, et la méthode gagne un comportement zero-allocation par défaut.\nZoom : ArrayPool\u0026lt;T\u0026gt; pour les buffers loués #Quand le buffer nécessaire est plus grand que ce qu\u0026rsquo;une allocation sur la pile devrait gérer (disons, 4 Ko ou plus), ArrayPool\u0026lt;T\u0026gt;.Shared fournit un pool managé de tableaux réutilisables. Louer un tableau au pool est bien moins cher que d\u0026rsquo;en allouer un neuf, et le rendre le rend disponible pour le prochain appelant.\npublic static async Task\u0026lt;int\u0026gt; ReadAllToCountAsync(Stream input, CancellationToken ct) { // Location d\u0026#39;un buffer 16 Ko au pool partagé. Aucune allocation heap pour ce buffer. byte[] buffer = ArrayPool\u0026lt;byte\u0026gt;.Shared.Rent(16 * 1024); try { int total = 0; int read; while ((read = await input.ReadAsync(buffer, ct)) \u0026gt; 0) total += read; return total; } finally { ArrayPool\u0026lt;byte\u0026gt;.Shared.Return(buffer); } } Le try/finally n\u0026rsquo;est pas négociable. Louer sans rendre fuite un buffer du pool, ce qui en réduit silencieusement l\u0026rsquo;efficacité. Le pattern standard est toujours rent → try → use → finally → return.\n⚠️ Ça marche, mais\u0026hellip; : Si le buffer peut contenir des données sensibles (tokens, informations personnelles), appeler ArrayPool\u0026lt;T\u0026gt;.Shared.Return(buffer, clearArray: true) pour zéroter la mémoire avant qu\u0026rsquo;elle ne retourne au pool. Sinon la prochaine location voit le contenu précédent. Le coût de nettoyage d\u0026rsquo;un buffer 16 Ko est négligeable face aux conséquences de sécurité de ne pas le nettoyer.\nZoom : ValueTask pour le cas courant du \u0026ldquo;déjà terminé\u0026rdquo; #Chaque méthode async qui retourne Task alloue au moins un objet Task, plus un box de state machine si la méthode yield réellement. Pour les méthodes qui retournent souvent synchroniquement (la valeur en cache, la collection vide, le retour précoce sur une garde), cette allocation est du pur gaspillage.\nValueTask\u0026lt;T\u0026gt; a été ajouté dans .NET Core 2.0 précisément pour ce cas. C\u0026rsquo;est un type valeur qui peut représenter soit un résultat terminé inline (zéro allocation), soit une tâche sous-jacente (allocation normale). Utilisé correctement, il supprime les allocations pour les 80% des appels qui se terminent synchroniquement.\npublic sealed class PriceCache { private readonly IDistributedCache _cache; private readonly IPriceRepository _repo; private readonly ConcurrentDictionary\u0026lt;string, decimal\u0026gt; _local = new(); public ValueTask\u0026lt;decimal\u0026gt; GetPriceAsync(string sku, CancellationToken ct) { // Chemin chaud : déjà en cache local, pas d\u0026#39;async, pas d\u0026#39;allocation. if (_local.TryGetValue(sku, out var price)) return new ValueTask\u0026lt;decimal\u0026gt;(price); // Chemin froid : cache plus lent, vrai await, vraie allocation. return new ValueTask\u0026lt;decimal\u0026gt;(FetchAsync(sku, ct)); } private async Task\u0026lt;decimal\u0026gt; FetchAsync(string sku, CancellationToken ct) { var bytes = await _cache.GetAsync(sku, ct); if (bytes is not null) { var cached = BitConverter.ToDecimal(bytes); _local[sku] = cached; return cached; } var fresh = await _repo.GetPriceAsync(sku, ct); _local[sku] = fresh; return fresh; } } Dans un service de pricing typique où le cache local hit 95% du temps, ce pattern supprime 95% des allocations Task\u0026lt;decimal\u0026gt;. À 100 000 requêtes par seconde, cela fait 95 000 allocations économisées par seconde, auxquelles s\u0026rsquo;ajoutent celles du box de state machine qui n\u0026rsquo;ont plus lieu.\n❌ Ne jamais faire : Ne pas await un ValueTask deux fois, ne pas le stocker dans un champ, ne pas appeler .Result sur un qui n\u0026rsquo;est pas encore terminé. ValueTask est optimisé pour une consommation avec un seul await, et une mauvaise utilisation peut corrompre l\u0026rsquo;objet sous-jacent ou causer des hangs. Le pattern sûr est await ValueTaskMethod(); une seule fois, sur le site d\u0026rsquo;appel.\nZoom : objets poolés avec ObjectPool\u0026lt;T\u0026gt; #Pour les objets plus complexes qu\u0026rsquo;un buffer (un StringBuilder, un état de parser custom, un contexte de requête), Microsoft.Extensions.ObjectPool fournit un pool que les applications peuvent utiliser directement. C\u0026rsquo;est le même mécanisme qu\u0026rsquo;ASP.NET Core utilise en interne pour la réutilisation de StringBuilder dans le pipeline.\npublic sealed class ReportFormatter { private readonly ObjectPool\u0026lt;StringBuilder\u0026gt; _builderPool; public ReportFormatter(ObjectPoolProvider provider) { _builderPool = provider.Create( new StringBuilderPooledObjectPolicy { MaximumRetainedCapacity = 16 * 1024 }); } public string Format(Order order) { var sb = _builderPool.Get(); try { sb.Append(\u0026#34;Order \u0026#34;).Append(order.Id).Append(\u0026#34;: \u0026#34;); foreach (var line in order.Lines) sb.Append(line.ProductName).Append(\u0026#39; \u0026#39;).Append(line.Quantity).Append(\u0026#34;, \u0026#34;); return sb.ToString(); } finally { _builderPool.Return(sb); // la policy Return nettoie le builder } } } Le même pattern rent → try → finally → return que ArrayPool, avec une policy dédiée qui borne la capacité retenue. Le réglage MaximumRetainedCapacity compte : un pool qui garde des StringBuilder de taille arbitraire trahit son propre intérêt en retenant indéfiniment la mémoire du pire cas.\n💡 Info : ObjectPoolProvider est enregistré par défaut dans ASP.NET Core via services.AddSingleton\u0026lt;ObjectPoolProvider, DefaultObjectPoolProvider\u0026gt;() (ASP.NET Core le fait automatiquement). Pour une application console ou un worker, il faut l\u0026rsquo;enregistrer explicitement.\nZoom : mesurer le gain #Le code zero-allocation ne compte que s\u0026rsquo;il économise réellement des allocations. La seule façon fiable de le vérifier est BenchmarkDotNet avec l\u0026rsquo;attribut [MemoryDiagnoser]. Il rapporte les allocations par opération, ventilées par génération, à côté du runtime.\n[MemoryDiagnoser] public class IbanValidationBench { private static readonly string Iban = \u0026#34;FR7630006000011234567890189\u0026#34;; [Benchmark(Baseline = true)] public bool Naive() { var sb = new StringBuilder(); sb.Append(Iban.AsSpan(4)); sb.Append(Iban.AsSpan(0, 4)); var rearranged = sb.ToString(); return Validate(rearranged); } [Benchmark] public bool ZeroAlloc() =\u0026gt; IsValidIban(Iban.AsSpan()); private static bool Validate(string s) { /* ... */ return true; } } Une sortie BenchmarkDotNet typique pour cette comparaison ressemble à :\n| Method | Mean | Allocated | |----------- |----------:|------------:| | Naive | 412.7 ns | 216 B | | ZeroAlloc | 89.3 ns | 0 B | Quatre à cinq fois plus rapide, zéro octet alloué, et la différence est directement attribuable à la pression GC qui n\u0026rsquo;a plus lieu. Sans le benchmark, l\u0026rsquo;optimisation est de la spéculation. Avec, l\u0026rsquo;optimisation est un gain mesuré qui vaut la peine d\u0026rsquo;être livré.\n✅ Bonne pratique : Faire tourner les benchmarks [MemoryDiagnoser] comme partie du repository, commités à côté du code qu\u0026rsquo;ils mesurent. Quand quelqu\u0026rsquo;un refactore le chemin chaud six mois plus tard, le benchmark dit immédiatement si les allocations ont ressurgi.\nZoom : quand le zero allocation est le mauvais objectif #Le code zero-allocation est plus difficile à lire, plus difficile à debugger, et plus facile à rater. L\u0026rsquo;appliquer à une méthode qui tourne deux fois par minute est un retour sur investissement pur négatif. À sortir quand :\nUn test de stress montre que le temps GC domine le chemin chaud. Un soak montre une pression heap qui monte, avec une grande fraction du temps passée dans les collectes. Un profil BenchmarkDotNet montre une boucle interne qui alloue à chaque itération sur un chemin appelé des milliers de fois par seconde. Les percentiles de latence montrent une longue queue qui s\u0026rsquo;aligne sur les événements de collecte gen2 dans les logs GC. À ne pas sortir quand :\nLa méthode n\u0026rsquo;est pas sur un chemin chaud. Les endpoints CRUD, les opérations d\u0026rsquo;admin, et les jobs de fond en ont rarement besoin. La lisibilité est le goulet d\u0026rsquo;étranglement. Du code qu\u0026rsquo;un ingénieur comprend aujourd\u0026rsquo;hui vaut souvent plus que du code qui tourne 2% plus vite et que personne ne peut modifier. Les allocations sont inévitables par construction (sérialisation JSON, rendu d\u0026rsquo;une page HTML complète). Optimiser les allocations qui sont réellement optionnelles. Wrap-up #Le zero-allocation .NET est un outil de précision, pas un mode de vie. Tu peux sortir Span\u0026lt;T\u0026gt; et stackalloc sur les buffers de courte durée, ArrayPool\u0026lt;T\u0026gt; sur les plus grands, ValueTask\u0026lt;T\u0026gt; sur les méthodes async qui se terminent souvent synchroniquement, et ObjectPool\u0026lt;T\u0026gt; sur les objets complexes réutilisables. Tu peux mesurer chaque changement avec des benchmarks [MemoryDiagnoser] pour que les gains soient réels et ne régressent pas en silence. Tu peux appliquer ces techniques là où un stress ou un soak prouve que le GC est le goulet, et laisser le reste du codebase tranquille.\nPrêt à booster ton prochain projet ou à le partager avec ton équipe ? À la prochaine, a++ 👋\nPour aller plus loin # Les Tests de Charge en .NET : vue d\u0026rsquo;ensemble des quatre types qui comptent Le Stress Testing en .NET : trouver le point de rupture et sa forme Le Soak Testing en .NET : les bugs qui n\u0026rsquo;apparaissent qu\u0026rsquo;après des heures Les Tests Unitaires en .NET : rapides, ciblés, et vraiment utiles Références # Types Memory et Span, Microsoft Learn ArrayPool\u0026lt;T\u0026gt;, Microsoft Learn Guidance ValueTask\u0026lt;T\u0026gt;, Microsoft Learn Pattern object pool, Microsoft Learn Documentation BenchmarkDotNet Fondamentaux du garbage collection, Microsoft Learn ","date":"8 avril 2026","permalink":"https://dotnet-senior-blog.pages.dev/fr/posts/performance-zero-allocation/","section":"Posts","summary":"","title":"Zero Allocation en .NET : quand le GC devient le goulet d'étranglement"}]