[{"content":"CQS and CQRS are two of the most misquoted acronyms in .NET. Teams say \u0026ldquo;we do CQRS\u0026rdquo; when they mean \u0026ldquo;we have a MediatR pipeline\u0026rdquo;, or \u0026ldquo;CQRS is overkill for us\u0026rdquo; when they actually mean \u0026ldquo;we do not need event sourcing\u0026rdquo;. The two ideas are related, but they live at different levels: CQS is a rule about methods, CQRS is a rule about entire read and write paths. Knowing which one you are reaching for is the difference between a clean application layer and two years of regret.\nThis article unpacks both, with realistic ASP.NET Core code, and shows where the line sits in practice.\nWhy these ideas exist #CQS (Command Query Separation) was coined by Bertrand Meyer in the 1980s in Object-Oriented Software Construction. His rule is simple: a method should either perform an action (a command, which changes state and returns nothing) or answer a question (a query, which returns data and does not change state), but not both. The point is not religious purity, it is to make reasoning about code easier. If you know a method is a query, you can call it a hundred times in a debugger without worrying about side effects.\nCQRS (Command Query Responsibility Segregation) was introduced by Greg Young around 2010, building on Udi Dahan\u0026rsquo;s ideas and his own experience with event sourcing. Young took CQS and pushed it up one level: instead of separating commands and queries at the method level, separate them at the architectural level. Writes and reads become two different paths through the application, potentially with two different models, two different stores, and two different teams. CQRS is a response to a specific pain: trying to serve a rich write model and a dozen different read views from the same set of entities turns your domain classes into a bag of IsXxx, HasYyy, and \u0026ldquo;include this only on the summary screen\u0026rdquo; properties.\nThe two ideas share a lineage, but they solve problems at very different scales. CQS is something you should probably do in every class you write. CQRS is something you should reach for when the complexity of your reads genuinely diverges from the complexity of your writes.\nOverview: where each one lives # graph TD subgraph CQS[\"CQS: method-level rule\"] A[OrderService] --\u003e B[\"PlaceOrder cmd : void\"] A --\u003e C[\"GetTotal query : decimal\"] end subgraph CQRS[\"CQRS: architectural split\"] D[API] --\u003e E[Command side] D --\u003e F[Query side] E --\u003e G[Domain model] G --\u003e H[(Write store)] F --\u003e I[(Read storeor projections)] end CQS lives inside a class. CQRS lives across your codebase. You can do CQS without CQRS. You can also do CQRS without event sourcing, without two databases, and without a message bus. Losing track of these distinctions is how \u0026ldquo;let us add CQRS\u0026rdquo; becomes a six-month project.\nZoom: CQS in a service, end to end #Start with a plain service that violates CQS, then fix it. Here is the kind of method you have almost certainly written or reviewed:\npublic sealed class OrderService { private readonly ShopDbContext _db; public OrderService(ShopDbContext db) =\u0026gt; _db = db; // Violates CQS: changes state AND returns data. 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; } } The method places an order and returns the full entity. The caller now has a tracked Order with lazy-loadable navigation properties, which may or may not be safely used outside this transaction. Two unrelated concerns travel on the same return value. Tests that want to assert \u0026ldquo;the order was placed\u0026rdquo; end up also asserting \u0026ldquo;the returned entity has the expected shape\u0026rdquo;.\nThe CQS version splits it:\npublic sealed class OrderService { private readonly ShopDbContext _db; public OrderService(ShopDbContext db) =\u0026gt; _db = db; // Command: changes state, returns only what the caller strictly needs (an 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: returns data, never mutates. 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); } } Two methods, two intents. The command returns an id (a purist would return void, but an id is the minimum information the caller needs to route to the next screen and is still trivially \u0026ldquo;not part of the domain state\u0026rdquo;). The query is AsNoTracking, projects into a DTO, and has no business logic.\n💡 Info — Returning an id from a command is the pragmatic compromise every serious .NET codebase makes. Strict Meyer-style CQS would return void and make the caller issue a follow-up query. In practice, that costs a round trip for no real benefit. Returning Guid (or Result\u0026lt;Guid\u0026gt;) is fine.\n✅ Good practice — Write queries with AsNoTracking() and projections to DTOs by default. The change tracker is a feature you pay for on every load, and queries almost never need it.\nZoom: CQRS with MediatR, the honest version #CQRS at the architectural level means: the command path and the query path are two different shapes, even if they share the same database. In .NET, the most common way to express this is with MediatR (or any in-process mediator), sending ICommand\u0026lt;T\u0026gt; and IQuery\u0026lt;T\u0026gt; objects from the API layer to handlers.\nHere is a command handler, the write path, going through the domain model:\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;Order {cmd.OrderId} not found.\u0026#34;); order.Submit(); // enforces invariants inside the aggregate 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()); } } And here is a query handler, the read path, bypassing the domain entirely and projecting straight from the DbContext into the shape the UI actually wants:\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); } } Same DbContext. One database. Two very different shapes of code. The command goes through aggregates to protect invariants. The query goes around aggregates to deliver pixels. This is CQRS at its most useful: a clean architectural split without two databases, without event sourcing, without eventual consistency headaches.\n💡 Info — Single database, split code paths. This is sometimes called \u0026ldquo;soft CQRS\u0026rdquo; or \u0026ldquo;CQRS lite\u0026rdquo;, and for most business applications it is the version that earns its keep. The heavier variants (separate read store, event sourcing) are covered in the last section of this article.\nZoom: where the endpoint layer fits #Commands and queries need to be dispatched from somewhere. Endpoints (whether Controllers or Minimal APIs) become thin dispatchers: they bind the request, call mediator.Send, and return the result.\n// Endpoint, Minimal API style 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); }); No business logic in the endpoint. Binding, dispatch, return. For the endpoint style tradeoff (and why the two examples above use Minimal APIs), see Endpoints in .NET: Controllers vs Minimal API.\n✅ Good practice — Add a MediatR pipeline behavior for cross-cutting concerns (validation, logging, transactions). One behavior added to the DI container applies to every command and every query, which means you do not sprinkle try/catch or using var tx = ... in every 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) { // Only wrap commands in a transaction, not 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: when CQRS earns its complexity #A soft CQRS setup (split handlers, shared DB) is almost always a good idea in any non-trivial application. The harder variants are a different conversation:\nSeparate read store: when the read model is genuinely different (denormalized, full-text indexed, geographically distributed), and keeping it in sync with the write store is worth the operational cost. Event sourcing: when you need to reconstruct history, support temporal queries, or audit every state change. This is a huge commitment. It changes how you design tests, migrations, and deployments. Separate command and query APIs: when different teams own reads and writes, or when you need to scale the two independently. Each of these layers solves a real problem, and each adds real cost. The right question is not \u0026ldquo;should we do CQRS\u0026rdquo;, it is \u0026ldquo;which layer of CQRS does the problem actually need\u0026rdquo;.\n⚠️ It works, but\u0026hellip; — Adding a separate read database and a message bus to a system with 3 aggregates and 12 endpoints is a classic over-engineering pattern. The split handlers inside one DB give you most of the benefit (clean read paths, no domain pollution, easier query tuning) with almost none of the cost.\n❌ Never do this — Do not force your queries to go through your domain aggregates \u0026ldquo;for consistency\u0026rdquo;. A query that loads an Order aggregate, walks its lines, and reshapes them into a DTO in C# is slower, more memory-hungry, and harder to maintain than a single Select projection. Reads and writes are allowed to have different shapes on purpose.\nWrap-up #You now know the difference between CQS and CQRS: CQS is the method-level rule that says a method should either do or ask, never both; CQRS is the architectural pattern that takes that rule and lifts it to whole command and query paths in your application. You can write clean CQS in any service class today. You can adopt soft CQRS with MediatR and one database on any greenfield .NET project. You can reach for the heavier variants (separate stores, event sourcing) only when the problem demands it, not because a blog post said so.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # Endpoints in .NET: Controllers vs Minimal API, the Honest Comparison Vertical Slicing in .NET: Organize by Feature, Not by Layer References # Command Query Separation, Bertrand Meyer (original book reference) CQRS Documents by Greg Young MediatR on GitHub Entity Framework Core, Microsoft Learn CQRS pattern, Microsoft Learn ","date":"10 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/layer-focused-cqs-cqrs/","section":"Posts","summary":"","title":"Application Layer in .NET: CQS and CQRS Without the Hype"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/application-layer/","section":"Tags","summary":"","title":"Application-Layer"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/architecture/","section":"Categories","summary":"","title":"Architecture"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/","section":"Categories","summary":"","title":"Categories"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/cqrs/","section":"Tags","summary":"","title":"Cqrs"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/cqs/","section":"Tags","summary":"","title":"Cqs"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/dotnet/","section":"Tags","summary":"","title":"Dotnet"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/posts/","section":"Posts","summary":"","title":"Posts"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/","section":"Road to Senior .NET Developer","summary":"","title":"Road to Senior .NET Developer"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/","section":"Tags","summary":"","title":"Tags"},{"content":"For a decade, the gap between \u0026ldquo;my .NET solution runs on my laptop\u0026rdquo; and \u0026ldquo;my .NET solution is deployed to a cloud platform\u0026rdquo; has been filled with tooling the developer had to assemble themselves: a docker-compose for local orchestration, a separate set of Kubernetes or ACA manifests for deployment, OpenTelemetry wiring per service, a dashboard to watch traces, a way to pass connection strings to containers. Every team reinvented the same scaffolding, slightly differently, and the friction kept .NET microservices more expensive to start than they needed to be.\n.NET Aspire closes that gap. Released as GA in May 2024, it is Microsoft\u0026rsquo;s opinionated framework for composing, running, and deploying multi-service .NET applications. It is not a new hosting platform. It is a C#-first orchestration layer that sits on top of the hosting you already use ( Docker, Kubernetes, ACA), replacing hand-written YAML and shell scripts with a typed AppHost project that describes the entire topology in C#. For a lot of .NET teams, especially those starting new distributed applications, it removes a significant amount of boilerplate without locking anyone into a specific cloud.\nThis final article of the Deployment series covers what Aspire actually is, how to use it as a development and deployment tool, and when it is the right choice.\nWhy .NET Aspire exists #Aspire answers a specific observation: every non-trivial .NET application in 2024 looked the same in its orchestration layer. It had an API, a worker, a database, a cache, maybe a message broker. Each service needed OpenTelemetry configured, a connection string wired up, health checks registered, and a way to run locally against the same dependencies. Teams wrote the same twenty boilerplate lines per service, forever, and each team wrote them slightly differently.\nThe goals of Aspire, stated concretely:\nReplace docker-compose with a typed C# model. The topology of the application (what services run, what they depend on, what they talk to) is described in a regular .NET project called the AppHost, with strong typing, IntelliSense, and refactoring support. Standardize cross-cutting concerns. OpenTelemetry, health checks, service discovery, resilience policies, and structured logging are packaged into Service Defaults, a shared project that every service in the solution references. Add the reference, call one extension method, and you have them all. Provide a local dashboard. When you press F5, Aspire starts all the services and opens a local dashboard that shows traces, metrics, logs, and the console output of each process, in one place. Emit deployment manifests for real targets. The same AppHost can generate the manifests needed to deploy to Azure Container Apps, Kubernetes, or Docker Compose, without the developer writing them by hand. This is the part that replaces the \u0026ldquo;I have to maintain three different deployment descriptions\u0026rdquo; problem. Overview: the Aspire project shape # graph TD A[AppHost projectC# topology] --\u003e B[Shop.Api] A --\u003e C[Shop.Worker] A --\u003e D[Postgres resource] A --\u003e E[Redis resource] A --\u003e F[Azure Service Bus] B --\u003e D B --\u003e E C --\u003e D C --\u003e F G[ServiceDefaults projectOTel, health, resilience] --\u003e B G --\u003e C H[Aspire Dashboard] --\u003e B H --\u003e C H --\u003e D An Aspire solution has a distinctive shape. Two new projects sit alongside the usual service projects:\nAppHost: a console project that references every service project in the solution and declares, in C#, the resources each one depends on. When you run the AppHost, it launches all the referenced projects, starts the dependencies (Postgres, Redis, whatever), and wires the connection strings between them automatically.\nServiceDefaults: a class library that every service project references. It contains the extension methods that wire up OpenTelemetry, health check endpoints, service discovery, and resilience policies in a single call. Instead of copy-pasting 30 lines of telemetry setup into every Program.cs, you call builder.AddServiceDefaults() and it is done.\nThe rest of the solution (the API project, the worker project, the domain library) is regular .NET code, unchanged. Aspire does not ask you to restructure your application. It adds orchestration on top.\nZoom: the AppHost project #// Shop.AppHost/Program.cs var builder = DistributedApplication.CreateBuilder(args); // Managed dependencies. Aspire starts these automatically in dev mode. 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;); // The API project, with explicit references to its dependencies. var api = builder.AddProject\u0026lt;Projects.Shop_Api\u0026gt;(\u0026#34;shop-api\u0026#34;) .WithReference(postgres) .WithReference(redis) .WithReference(servicebus) .WithExternalHttpEndpoints() .WithReplicas(2); // The worker project. builder.AddProject\u0026lt;Projects.Shop_Worker\u0026gt;(\u0026#34;shop-worker\u0026#34;) .WithReference(postgres) .WithReference(servicebus); builder.Build().Run(); Twelve lines of C# describe the entire topology of a distributed application. Five things worth noticing.\nAddPostgres(\u0026quot;db\u0026quot;) with WithDataVolume() does not just spin up a container. It declares Postgres as a managed resource in the AppHost, persists its data across runs via a Docker volume, and exposes its connection string to any project that calls WithReference(postgres). The AddDatabase(\u0026quot;shopdb\u0026quot;) call creates the database inside the Postgres instance automatically.\nAddAzureServiceBus(\u0026quot;sb\u0026quot;) is an interesting case. In development mode, Aspire runs an emulator (based on a container) that speaks the Service Bus protocol. In production, the same AppHost descriptor maps to a real Azure Service Bus namespace. The application code does not change between the two; Aspire resolves the difference at deployment time.\nWithReference(postgres) is the magic. It takes the connection string that Aspire constructs for the managed Postgres and injects it into the referenced project as an environment variable, following the same naming convention ASP.NET Core uses (ConnectionStrings__db). The project then reads it from IConfiguration without any extra glue.\nWithExternalHttpEndpoints() marks the project as externally reachable. In local dev, Aspire assigns a random port and shows it in the dashboard. In production, it maps to an ingress rule on the target platform.\nWithReplicas(2) declares how many instances of the project should run. In local dev, Aspire launches two copies and load-balances between them. In production, the number translates into replica count on Kubernetes or ACA.\n💡 Info : Aspire\u0026rsquo;s catalog of Add* methods covers most common dependencies out of the box: Postgres, SQL Server, MySQL, Redis, MongoDB, RabbitMQ, Kafka, Azure Service Bus, Azure Storage, Azure Cosmos DB, Azure Key Vault, and more. The full list is in the Aspire.Hosting.* NuGet packages. Third-party integrations (Dapr, NATS, Elastic) are available as community packages.\nZoom: the ServiceDefaults project #Every service in the solution references a shared ServiceDefaults project that provides the common cross-cutting setup:\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; } } And in each service\u0026rsquo;s Program.cs:\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(); Two calls: AddServiceDefaults() in ConfigureServices and MapDefaultEndpoints() in the pipeline. Every service now has OpenTelemetry wired to the dashboard, health check endpoints, service discovery via DNS, and resilient HTTP clients with retry and circuit breaker. No copy-paste. No drift. If the team decides to add a new telemetry exporter or a new resilience policy, it happens in one place.\n✅ Good practice : Keep ServiceDefaults under strict review. It is the blast radius for every service\u0026rsquo;s startup behavior. Changes to it affect every service at once, which is exactly what makes it valuable and exactly what makes it dangerous. Treat it like a shared library with its own release notes.\nZoom: the Aspire Dashboard #When you press F5 on the AppHost, Aspire starts the dashboard on a local port and opens it in the browser. The dashboard shows:\nResources: every service and dependency, with their status, ports, environment variables, and container logs. Console logs: a unified view of stdout/stderr from every running process, with filtering by service and log level. Structured logs: the ILogger entries, indexed and searchable. Traces: OpenTelemetry spans, with distributed tracing across services. A single request that hits the API, queries Postgres, publishes to Service Bus, and triggers the worker shows as a single trace with all the spans. Metrics: the runtime counters (GC, thread pool, HTTP request duration) and any custom metrics the application emits. This is, for many teams, the most visible benefit of adopting Aspire. Getting the same level of local observability without Aspire requires running Jaeger, Prometheus, Grafana, and a log aggregator in a compose file, configuring each one, and making sure every service exports to the right endpoint. Aspire does all of it by default, in process, with zero configuration.\n💡 Info : The Aspire Dashboard is a standalone application. It can also run against any OpenTelemetry-compatible workload (including non-Aspire apps) via the standalone image at mcr.microsoft.com/dotnet/aspire-dashboard. Some teams adopt it as their local observability stack even when they are not using the rest of Aspire.\nZoom: deploying an Aspire app #Aspire is not a hosting platform. It generates manifests or resources for a real hosting platform. The canonical deployment path uses the Azure Developer CLI (azd) to deploy an Aspire solution to Azure Container Apps with a single command.\n# Once, at the root of the solution azd init # interactive wizard, detects the AppHost azd auth login # authenticates with Azure # Every deployment after that azd up # provisions Azure resources and deploys Under the hood, azd up does three things:\nProvisions infrastructure. From the AppHost description, azd generates a Bicep template that creates the required Azure resources: a Container Apps Environment, a Log Analytics workspace, a Service Bus namespace (because the AppHost references one), a Postgres Flexible Server, and so on. Builds the container images for each service project in the solution, using the standard .NET SDK container publish (dotnet publish -t:PublishContainer), and pushes them to an Azure Container Registry that azd also provisions. Deploys the Container Apps with the right environment variables, secrets, ingress configuration, and replica counts, derived from the AppHost. The whole round trip, from git clone to a running production-like environment on Azure, is typically under 10 minutes on a fresh account.\nFor teams targeting Kubernetes instead, Aspire can emit a manifest via aspire publish:\naspire publish --publisher kubernetes --output ./deploy/k8s This generates Kubernetes manifests for every service in the AppHost, which can then be further customized with Kustomize (covered in the Kubernetes primer) or packaged with Helm. The generated output is a starting point, not the final artifact, but it captures the dependency graph and the environment wiring, which is the tedious part.\n⚠️ It works, but\u0026hellip; : azd up is excellent for development, demos, and proof of concept environments. For production, most teams move to a proper CI/CD pipeline with separate build, test, and deploy stages, using the Aspire manifest as an input to their existing deployment tooling rather than calling azd up from a workstation.\nZoom: when Aspire is the right choice #Aspire is particularly well-suited for:\nNew distributed .NET applications where the team wants a fast on-ramp to multi-service development without assembling the scaffolding from scratch. Existing solutions that struggle with cross-cutting concerns. If the team has five services and each has a slightly different OpenTelemetry setup, moving them all under a shared ServiceDefaults is a net win. Teams that want local observability without running a parallel compose stack for Jaeger, Prometheus, and friends. Azure-first .NET shops. The azd deployment path is the smoothest experience on Azure. It works elsewhere, but the rough edges are fewer on Azure. Demos, workshops, and internal tools where fast F5-to-running is more important than deployment flexibility. It is not the right choice when:\nThe solution is a single service. Aspire\u0026rsquo;s value comes from orchestrating multiple services. For a single API, the AppHost is overhead without benefit. The team has a mature deployment pipeline. If there is already a working Kubernetes + Helm + GitOps setup, introducing Aspire as the authoring layer may create friction rather than reduce it. Non-.NET services are part of the topology. Aspire can reference containers or executables of any language, but its strongest integration is with .NET projects. A polyglot system with heavy Python, Go, or Node.js services may fit better in a compose-first or Kubernetes-first workflow. The target is not Azure and not Kubernetes. Aspire can generate compose files, but its strongest deployment paths are ACA and K8s. For bare VMs, IIS, or plain Docker hosts, the benefit is smaller. Wrap-up #.NET Aspire replaces the \u0026ldquo;every team reinvents the same scaffolding\u0026rdquo; pattern with a typed, C#-first orchestration layer that describes the full topology in the AppHost project, standardizes observability and resilience via ServiceDefaults, provides a local dashboard for free, and generates deployment manifests for Azure Container Apps, Kubernetes, or Docker Compose. You can start a new distributed .NET application with two extra projects and a handful of lines of code, get traces and metrics on the dashboard without wiring anything, and deploy to ACA with azd up in minutes. You can also recognize when an existing solution would not benefit from the migration and stick with the hosting and deployment tools already in place.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # Hosting ASP.NET Core with Docker: A Pragmatic Guide Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers Hosting ASP.NET Core on Azure Container Apps Docker for .NET Deployment: Dockerfile and Compose in Practice Kubernetes Primer for .NET Developers: From kubectl to Helm References # .NET Aspire documentation, Microsoft Learn .NET Aspire overview Azure Developer CLI documentation Aspire Dashboard standalone .NET Aspire on GitHub ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/deployment-dotnet-aspire/","section":"Posts","summary":"","title":".NET Aspire: Cloud-Native Orchestration Made Simple"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/aspire/","section":"Tags","summary":"","title":"Aspire"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/controllers/","section":"Tags","summary":"","title":"Controllers"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/dapper/","section":"Tags","summary":"","title":"Dapper"},{"content":"Dapper has been around since 2011, written by the Stack Overflow team to handle the SQL workload that Entity Framework of the time could not. It is still maintained, still fast, and still the right answer for a specific set of problems. The mistake most teams make is treating it as a choice between Dapper and EF Core, as if you have to pick one for the whole project. In practice, the two coexist cleanly: EF Core for writes and for the 90% of reads that LINQ handles well, Dapper for the reporting queries, the dashboards, the bulk exports, and the hot paths where the SQL is fundamentally not a LINQ query.\nThis article covers what Dapper actually is, where it wins, how to use it without rebuilding a mini-ORM around it, and how to run it in the same codebase as EF Core.\nWhy Dapper exists #Dapper is a set of extension methods on IDbConnection. That is the entire library. You open a connection, you call connection.QueryAsync\u0026lt;T\u0026gt;(\u0026quot;SELECT ...\u0026quot;, parameters), and Dapper maps the result set to T by matching column names to property names. There is no model, no change tracker, no migrations, no LINQ provider, no relationship mapping. You write the SQL, Dapper runs it and hydrates the objects.\nThat minimalism is the point. EF Core solves the problem \u0026ldquo;I have a domain model and I want the database to follow it\u0026rdquo;. Dapper solves the problem \u0026ldquo;I have a SQL query and I want it to return typed objects\u0026rdquo;. Those are different problems, and a team that understands the distinction stops arguing about which one to use.\nThe performance gap between Dapper and EF Core has narrowed significantly since EF Core 6. On a single-entity query, EF Core with AsNoTracking() and a projection is typically within 10-15% of Dapper. The gap widens on queries that involve unusual SQL shapes: window functions, recursive CTEs, UNION ALL across unrelated tables, or hand-tuned JOIN orders. Those are the queries where Dapper earns its keep, not because it is faster on trivial cases but because writing them in LINQ is either impossible or produces translated SQL you would not ship.\nOverview: where Dapper fits # graph TD A[Data access need] --\u003e B{Shape} B --\u003e|Domain write: load aggregate, mutate, save| C[EF Core] B --\u003e|Typed list/detail read from an entity| C B --\u003e|Reporting query, CTE, window function| D[Dapper] B --\u003e|Bulk export, read-heavy endpoint| D B --\u003e|Multi-table stitching with hand-tuned SQL| D C --\u003e E[ShopDbContext] D --\u003e F[IDbConnection + SQL files] The split is by query shape, not by feature area. The same module can have EF Core for the command handlers and Dapper for the read model that feeds the dashboard.\nZoom: a clean Dapper setup #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;Missing connection string\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); Three things worth noting. First, CommandDefinition is the modern overload that accepts a CancellationToken. Use it for every call: cancellation is not free on long-running reports. Second, parameters are passed as an anonymous object, which Dapper turns into parameterized SQL. Never concatenate user input into the SQL string. Third, the SQL is in a raw string literal (\u0026quot;\u0026quot;\u0026quot;), which keeps it readable and indents naturally.\n💡 Info — Raw string literals arrived in C# 11. For older target frameworks, move the SQL to an embedded .sql file and read it with typeof(OrderReports).Assembly.GetManifestResourceStream(...).\nZoom: reusing the EF Core connection #When EF Core and Dapper coexist in the same request, the common mistake is opening two separate connections. EF Core has one open inside DbContext, and Dapper opens a second one, which doubles the connection pool usage and breaks any transactional guarantee you might have wanted between the two. The fix is to ask the DbContext for its connection:\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 = /* same SQL as above */; var rows = await conn.QueryAsync\u0026lt;MonthlyRevenueRow\u0026gt;( new CommandDefinition(sql, new { Year = year }, cancellationToken: ct)); return rows.ToList(); } } _db.Database.GetDbConnection() returns the underlying DbConnection that EF Core manages. Dapper can run any query on it, and you stay inside the same transaction if one is active. When the DbContext is disposed at the end of the scope, the connection goes back to the pool.\n✅ Good practice — For anything that runs in a request that also touches EF Core, share the connection. For background jobs or standalone reporting endpoints with no EF Core involvement, a dedicated NpgsqlConnection is perfectly fine.\nZoom: multi-row results and splitting #When the query joins multiple tables and you want a parent with children mapped into different CLR types, use QueryAsync with a 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; tells Dapper where to cut the result row between the two mapped types. Everything from the first column up to (and not including) the first id column after it becomes the first type, and the rest becomes the second type. It is the one Dapper detail that catches everyone the first time.\n⚠️ Works, but\u0026hellip; — For queries that join four or more tables, the splitOn syntax becomes hard to read. At that point, return flat DTOs and do the stitching in C#, or move the query to a SQL view and map the view to a single DTO.\nZoom: dynamic parameters and stored procedures #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 is the escape hatch for anything beyond plain input parameters: output parameters, return values, stored procedures, typed DbType control for edge cases where the default Dapper mapping is wrong.\n❌ Never do — Do not build SQL by string concatenation under the excuse that \u0026ldquo;it is only for an admin dashboard\u0026rdquo;. The admin dashboard is the first place SQL injection gets noticed, because that is where someone eventually pastes a filter value with a single quote in it. Parameters, always.\nZoom: when not to use Dapper #Dapper is wrong for three things:\nWriting domain aggregates. It has no change tracker, so you either write all the INSERTs and UPDATEs by hand, or you end up rebuilding EF Core badly. Keep writes on EF Core. Multi-tenant query filtering. EF Core global query filters are declarative and hard to forget. In Dapper, the tenant filter is a WHERE tenant_id = @TenantId you have to remember on every query. One miss and you are reading another tenant\u0026rsquo;s data. Projects with junior developers and no SQL review culture. Dapper trusts the author. It does not protect you from a bad JOIN order, a missing index, or a query that looks fine in dev and brings production to its knees. On a team that cannot review SQL, the EF Core guardrails are worth the minor overhead. Wrap-up #Dapper and EF Core are complements, not competitors. EF Core for the command side and for typed reads that LINQ expresses cleanly; Dapper for the reporting, the CTE queries, the dashboards, and anything where SQL is the first-class language. Sharing the connection through _db.Database.GetDbConnection() lets them coexist in the same request and the same transaction. Once the split is clear, the \u0026ldquo;EF Core versus Dapper\u0026rdquo; debate stops being a debate.\nThe next article in this series is the one that ties everything together: how to diagnose and fix a slow query when the code is already written and production is burning.\nRelated articles # EF Core: Read Optimization Data Access in .NET: Repository Pattern, or Not? Application Layer: CQS and CQRS References # Dapper on GitHub Dapper on NuGet EF Core: Accessing the Underlying ADO.NET Connection Npgsql documentation ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/database-dapper/","section":"Posts","summary":"","title":"Dapper: When and How"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/database/","section":"Categories","summary":"","title":"Database"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/database/","section":"Tags","summary":"","title":"Database"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/deployment/","section":"Categories","summary":"","title":"Deployment"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/deployment/","section":"Tags","summary":"","title":"Deployment"},{"content":"Database performance problems are the incident category where experienced engineers still reach for guesses. \u0026ldquo;It is probably a missing index.\u0026rdquo; \u0026ldquo;It must be a bad query plan.\u0026rdquo; \u0026ldquo;The connection pool is full.\u0026rdquo; Sometimes they are right on the first try; most of the time they replace one guess with another until the symptoms shift. The issue is not knowledge, it is method. A reproducible diagnosis flow beats a good guess every time, because it converges on the real cause in minutes instead of hours, and because it produces evidence you can attach to the post-mortem.\nThis article lays out that flow: how to triage the symptom, how to confirm the actual bottleneck before touching anything, how to read an execution plan without getting lost, and the .NET-side patterns that look like database problems but are not.\nWhy database diagnosis needs a method #The database is a black box with a public interface, and the interface lies about the cause of latency. A slow endpoint measured from the browser could be the database, the network, the serializer, the JSON response size, a foreach that awaits a query per iteration, or a garbage collection pause on the application server. A query that runs in 200 ms in SSMS and 2 seconds from the app could be parameter sniffing, a different connection string option, a different collation, or the application opening a second transaction the query has to wait for. Every one of those has a different fix, and picking the wrong hypothesis wastes the first thirty minutes of the incident.\nThe method exists so that the first thirty minutes produce evidence instead of theories.\nOverview: the diagnosis flow # flowchart TD A[Symptom: slow endpoint or timeout] --\u003e B[Step 1: Measure where the time goes] B --\u003e C{Bottleneck?} C --\u003e|.NET side| D[Profile the app: GC, serializer, N+1] C --\u003e|Database side| E[Step 2: Capture the query] E --\u003e F[Step 3: Execution plan] F --\u003e G{Plan issue?} G --\u003e|Missing index| H[Add index] G --\u003e|Bad estimate| I[Update statistics / rewrite] G --\u003e|Plan is fine| J[Step 4: Wait types] J --\u003e K{What is it waiting on?} K --\u003e|Locks| L[Blocking chain] K --\u003e|IO| M[Storage / data size] K --\u003e|CPU| N[Hot query or regression] Four steps, in order, every time. Skipping step 1 is the reason teams spend an hour tuning a query that was not the bottleneck.\nStep 1: measure where the time goes #Before you open SSMS, confirm the slow endpoint is actually slow because of the database. If you have OpenTelemetry instrumentation, the answer is in the trace:\nservices.AddOpenTelemetry() .WithTracing(t =\u0026gt; t .AddAspNetCoreInstrumentation() .AddEntityFrameworkCoreInstrumentation(o =\u0026gt; o.SetDbStatementForText = true) .AddNpgsql() .AddOtlpExporter()); A request span with its child spans tells you immediately whether the 2-second latency is 1.9 seconds of database time on a single query, 2 seconds of 400 queries at 5 ms each (N+1), or 100 ms of database time and 1.9 seconds of something else entirely. If the sum of the database spans is a small fraction of the request, the bottleneck is on the .NET side and no amount of query tuning will help.\nIf you do not have tracing yet, the minimum viable measurement is a stopwatch around the database call and a count of queries per 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 queries in one request\u0026rdquo; is a diagnosis before you even read one of them.\n💡 Info — Since EF Core 7, DbCommandInterceptor gives you a clean hook to count commands without changing the LINQ. Keep the interceptor in development only; in production the OpenTelemetry span count is the same data.\nStep 2: capture the exact query #Once you know the database is the bottleneck, you need the exact SQL, with actual parameter values, to reproduce it outside the app. EF Core\u0026rsquo;s LogTo or the OpenTelemetry span gives you this. Copy the query verbatim. Do not rewrite it \u0026ldquo;for clarity\u0026rdquo;: the tuning decisions depend on the literal SQL the driver sent.\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; Now run it against a copy of production. An exact copy, not dev seed data. Most slow queries are slow because of data shape: the dev database has 500 rows and a hash join; production has 40 million rows and a nested loop join over a missing index. You cannot diagnose that on a 500-row table.\nStep 3: read the execution plan #Every relational database exposes the execution plan. On PostgreSQL:\nEXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ... ; On SQL Server:\nSET STATISTICS IO, TIME ON; -- or use \u0026#34;Include Actual Execution Plan\u0026#34; in SSMS SELECT ... ; You are reading the plan for three things, in order:\n1. Where is the time actually spent? The plan is a tree of operations; each node reports its own cost and its own row count. The expensive node is where you spend your attention. It is often not where you expected.\n2. Are the estimated rows far from the actual rows? A 100x gap between estimate and actual almost always means the planner made a wrong choice: a nested loop where a hash join would have been better, a sort that spilled to disk, a bitmap that did not help. The fix is usually to update statistics, or to rewrite the predicate in a way the planner can estimate.\n3. Is there a sequential scan on a big table? A sequential scan on 40 million rows is the classic \u0026ldquo;missing index\u0026rdquo; signature. It is not always wrong: if the query returns 30% of the rows, a sequential scan is correct. But if the query returns 50 rows and the plan scans 40 million, you are missing an index.\n-- The fix for the example query above, on PostgreSQL: CREATE INDEX idx_orders_status_created_at ON orders (status, created_at DESC) WHERE status = \u0026#39;completed\u0026#39;; A partial index on the rows that match the most common predicate is smaller, faster to maintain, and more selective than a full index. Most dashboards query on one or two statuses; a partial index matches that access pattern exactly.\n✅ Good practice — Add the execution plan (both the \u0026ldquo;before\u0026rdquo; and the \u0026ldquo;after\u0026rdquo;) to the pull request that introduces the index. Future-you reading the commit in six months will want to know why this specific index exists.\n⚠️ Works, but\u0026hellip; — Adding an index based on the missing-index hint without reading the plan works often enough to be tempting. It also produces duplicate indexes, write-path slowdowns, and indexes that cover queries nobody runs anymore. Read the plan; add the index you need.\nStep 4: wait types #When the plan is fine and the query is still slow, the database is waiting on something. Every major DBMS exposes 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; Three wait categories account for almost every case:\nLock waits: the query is blocked by another transaction. The fix is upstream: shorter transactions, different isolation level, or breaking the blocking query into smaller ones. IO waits: the query is waiting on storage. The fix is usually data-shape (smaller rows, better compression, archiving old data) or infrastructure (faster disks, more memory so the hot data fits in cache). CPU: no waits, the query is just computing. The fix is query rewrite or index covering. 💡 Info — pg_stat_activity and sys.dm_exec_requests are point-in-time views. For repeat incidents, install a sampler that snapshots them every 5 seconds into a log so the next incident has history.\nZoom: .NET-side traps that look like database problems #Half of the \u0026ldquo;database is slow\u0026rdquo; incidents I have seen in 15 years were not database incidents at all. The classics:\nConnection pool exhaustion. When all connections are in use, the next query blocks until one frees up. It looks like a slow query in the application log, but the database shows the query as \u0026ldquo;took 4 ms once it started\u0026rdquo;. The fix is finding the connection leak (usually a DbContext held longer than a request, or a missing await using), not tuning the query.\nTask.Run over a sync EF Core call. Running a sync query on a thread pool thread looks fine until the thread pool saturates, at which point every query starts queuing on the thread pool before it even reaches the database. Convert to async, always.\nA foreach that awaits a query per iteration. The total latency is N * query_time, which feels like a slow query until you realize there are 400 of them. The fix is the projection/include pattern from EF Core: Read Optimization.\nSerialization dominating the response. A 20 MB JSON response takes a second to serialize regardless of the database. The database span in the trace is 20 ms; the response time is 1200 ms. The fix is pagination.\n❌ Never do — Do not \u0026ldquo;fix\u0026rdquo; a .NET-side bottleneck by adding a database index. The index will not help and the write path will get slower for everyone.\nWrap-up #The diagnosis method is four steps, in order: measure where the time goes, capture the exact query, read the execution plan, and if the plan is fine, look at wait types. Add an index when the plan tells you to, not when the incident ticket says \u0026ldquo;probably missing an index\u0026rdquo;. And always confirm the database is the bottleneck before touching it, because half the \u0026ldquo;database is slow\u0026rdquo; incidents are .NET-side problems in disguise. With this flow, incidents stop being guesswork and become a checklist.\nRelated articles # EF Core: Read Optimization Dapper: When and How EF Core: Migrations in Practice References # PostgreSQL: Using EXPLAIN PostgreSQL: pg_stat_activity SQL Server: Query Execution Plans SQL Server: Wait Statistics EF Core: Logging, Events, and Diagnostics OpenTelemetry .NET: EF Core Instrumentation ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/database-database-performance-diagnosis/","section":"Posts","summary":"","title":"Diagnosis: How to Investigate and Fix Database Performance Issues"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/docker/","section":"Tags","summary":"","title":"Docker"},{"content":"The Hosting series article on Docker covered how to run an ASP.NET Core container correctly at runtime: chiseled base image, health probes, signal handling, non-root user. This article looks at the other half of the story, the build and deployment pipeline itself. A Dockerfile that is great at runtime can still be terrible in CI if it rebuilds everything from scratch on every commit, produces only linux/amd64 when half your hosts are linux/arm64, or cannot be composed into a multi-service stack for staging.\nThe goal here is concrete: a production-grade Dockerfile that uses BuildKit cache mounts to turn a two-minute image build into a 20-second one, a multi-stage structure that plays nicely with CI, a docker bake setup that builds multi-architecture images in a single command, and a docker compose file that is actually usable beyond docker compose up on a laptop.\nWhy the build pipeline matters #A deployment is not \u0026ldquo;the moment the container runs in production\u0026rdquo;. It is everything between a git push and a healthy replica serving traffic, and the Dockerfile is the hinge of that process. Three concrete pain points make this worth the attention:\nCI minutes are real money. A Dockerfile that rebuilds NuGet restore on every commit wastes 60 to 120 seconds per run. Multiplied by 50 commits per day, across branches, that is a significant chunk of the CI budget going to redundant work. Multi-architecture is no longer optional. Apple Silicon developers on arm64, cloud providers offering cheaper arm64 instances (Graviton, Ampere, Azure Cobalt), and edge devices all need the same image in multiple architectures. A Dockerfile that only produces amd64 starts to feel legacy very quickly. Deployment is often multi-service. A backend API alone is rarely the whole unit of deployment. There is a worker, a reverse proxy, a background scheduler, a frontend. The composition is part of the deployment artifact, and treating it as an afterthought leads to drift between environments. Overview: the build pipeline shape # graph LR A[git push] --\u003e B[CI runner] B --\u003e C[docker buildxBuildKit] C --\u003e D[Cache layerregistry or local] C --\u003e E[Multi-arch imageamd64 + arm64] E --\u003e F[Container registry] F --\u003e G[Deployment target] Three tools carry most of the weight in a modern .NET container deployment: BuildKit (the modern Docker builder, default since Docker 23), buildx (the CLI frontend for multi-platform builds), and bake (a declarative build orchestrator that replaces ad-hoc shell scripts).\nNone of these are strictly required, but together they turn a deployment pipeline from a fragile sequence of docker build and docker push calls into a reproducible, cacheable, multi-target build that a team can reason about.\nZoom: the CI-friendly Dockerfile ## syntax=docker/dockerfile:1.9 ARG DOTNET_VERSION=10.0 ARG TARGETARCH # --- Build stage --- FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build WORKDIR /src # Copy csproj files first to maximize layer cache hits on 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;] # BuildKit cache mount for the NuGet global-packages folder. # Persists across builds, so restore is near-instant on warm CI runners. 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 # --- Runtime stage --- 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;] Five details that differ from the hosting-side Dockerfile and specifically target the build pipeline:\n# syntax=docker/dockerfile:1.9 at the top opts into the latest Dockerfile frontend, which is what enables --mount=type=cache and the newer build features. Without it, older Docker versions interpret the file with a more restricted syntax.\n--mount=type=cache,id=nuget,... is the BuildKit cache mount. It persists /root/.nuget/packages across builds on the same builder instance, so the second and subsequent builds skip the slow NuGet restore entirely. A cold CI runner still pays the download cost once; a warm one restores in a second. The id=nuget shared identifier lets both the restore and publish steps use the same cache.\n--platform=$BUILDPLATFORM on the build stage keeps compilation on the native host architecture (fast) even when producing cross-architecture output. The alternative, running the full build under emulation, is 3-5x slower on amd64 → arm64.\n-a $TARGETARCH on dotnet restore and --arch $TARGETARCH on dotnet publish tells the .NET SDK to produce output for the target architecture even though the build itself runs on the host architecture. This is the .NET way of doing cross-compilation and is significantly faster than emulation.\nFinal stage has no --platform override, so it inherits the target platform from the docker buildx build --platform flag. The end result is a multi-arch manifest where each architecture\u0026rsquo;s runtime matches its target, without emulation overhead.\n💡 Info : BuildKit cache mounts persist per builder instance, not per image. On a CI runner with a persistent workspace (GitHub Actions with cache, GitLab CI with shared runner), the cache survives between jobs. On an ephemeral runner, use a registry-backed cache with --cache-to type=registry,... to externalize it.\nZoom: multi-architecture builds with buildx #A single command produces a multi-arch image and pushes it:\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 \\ . The --platform linux/amd64,linux/arm64 flag tells buildx to build for both architectures in parallel. The --cache-from and --cache-to flags externalize the BuildKit cache to the container registry, which is the pattern that works on ephemeral CI runners. The --push flag pushes the resulting manifest directly; without it, you get a local multi-arch image that cannot be inspected with docker images.\nThe registry then stores a manifest list: a single tag (1.4.7) that points to two images (one amd64, one arm64), and any runtime pulling the tag gets the architecture it actually needs. This is transparent to Kubernetes, ACA, Azure Web App, and any modern runtime.\n✅ Good practice : Tag images with both a version and a cache alias in the same registry. The version tag (1.4.7) is immutable and rolled forward on each release; the cache tag is used only by the builder. This keeps the build cache separate from release artifacts and makes garbage collection simpler.\nZoom: docker bake for declarative builds #Running the docker buildx build command from a Makefile or CI YAML works, but it gets ugly when a repository has multiple images (API, worker, admin UI) with shared base configuration. docker bake replaces the shell incantations with an HCL file:\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 all three targets for both architectures, with shared cache. VERSION=1.4.7 docker buildx bake --push One command builds the three images for both architectures, shares the cache across them, and pushes everything. The _common target holds shared configuration, and inherits = [\u0026quot;_common\u0026quot;] on each image avoids the repetition. A build pipeline that was 150 lines of shell shrinks to 30 lines of HCL plus a single invocation.\n⚠️ It works, but\u0026hellip; : docker bake is powerful but not yet universal. Some CI providers do not have it installed by default, and some older Docker versions need docker buildx install first. Check the CI environment before standardizing on bake, or bake a warmup step into the pipeline.\nZoom: docker compose for multi-service deployment #docker compose is widely used for local development (covered in the hosting article), but it is also a legitimate deployment target for small-to-medium systems. A single Linux host with Docker Engine, running a Compose file, can serve real production traffic for internal tools, staging environments, or small SaaS products.\nThe key is a Compose file that is environment-aware, not hard-coded for \u0026ldquo;my laptop\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: # Deploy VERSION=1.4.7 docker compose up -d # Update to a new version VERSION=1.4.8 docker compose up -d # Compose pulls the new image and recreates only the api Seven details make this a deployment-grade Compose file.\n${VERSION:-latest} substitution drives the image tag from an environment variable, enabling the same file for multiple versions without editing it. restart: unless-stopped auto-restarts on failure or reboot. healthcheck gives Docker a way to know when the container is actually ready. deploy.resources.limits caps CPU and memory. logging configuration rotates container logs to prevent disk fill. Environment variables for secrets come from an env file or from the shell, never hardcoded. A reverse proxy (Caddy here, could be Traefik or NGINX) handles TLS termination with automatic Let\u0026rsquo;s Encrypt certificates.\nFor systems larger than a single host, Compose is the wrong answer and the next article in this series (and the hosting Kubernetes article) covers the migration path.\n✅ Good practice : Keep secrets in a .env file that is gitignored, and load them with docker compose --env-file prod.env up -d. Compose substitutes the variables at launch time, and the .env file never reaches version control. For stronger guarantees, use Docker secrets (in Swarm mode) or externalize to a secret store.\nZoom: compose profiles for environment variants #A single Compose file can describe multiple environment variants using profiles:\nservices: api: { ... } postgres: { ... } # Only starts with --profile debug adminer: image: adminer:latest ports: [\u0026#34;8081:8080\u0026#34;] profiles: [\u0026#34;debug\u0026#34;] # Only starts with --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 only docker compose --profile debug up -d # + adminer docker compose --profile monitoring up -d # + prometheus docker compose --profile debug --profile monitoring up -d # everything Profiles let one file serve several environments: plain production, production-with-observability, dev-with-admin-ui. The alternative of maintaining three separate Compose files leads to drift between them; profiles keep them in sync.\nWrap-up #Building and deploying .NET containers well in 2026 means a Dockerfile that uses BuildKit cache mounts to keep CI builds fast, the --platform flag to produce multi-architecture images without emulation overhead, docker buildx or docker bake to orchestrate multi-image builds declaratively, and a Compose file that is environment-aware enough to serve as a real deployment artifact for small-to-medium systems. You can cut CI build times in half with cache mounts alone, ship multi-arch images in a single command, and keep your deployment topology in one version-controlled file that is read by both the pipeline and the runtime.\nReady to level up your next project or share it with your team? See you in the next one, Docker Security Best Practices is where we go next.\nRelated articles # Hosting ASP.NET Core with Docker: A Pragmatic Guide Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers Integration Testing with TestContainers for .NET References # Dockerfile reference BuildKit cache mounts, Docker docs Multi-platform builds, Docker docs docker bake file reference Docker Compose specification Cross-targeting for .NET CLI, Microsoft Learn ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/deployment-docker-dockerfile-compose/","section":"Posts","summary":"","title":"Docker for .NET Deployment: Dockerfile and Compose in Practice"},{"content":"A container is not automatically secure because it is a container. The default Docker image for a typical .NET application, built without care, runs as root, ships with a full Linux userland, exposes a large attack surface to anyone who can reach the network interface, and carries known CVEs from the base image\u0026rsquo;s last publish date. This is not a hypothetical problem. It is the baseline every .NET team inherits the day they ship their first container, and hardening it is not optional for anything that touches user data.\nThis article is the security deep dive for the Deployment series. It complements the Hosting Docker article, which covered the runtime patterns, with the security-specific concerns that apply to both build and deployment: image scanning, SBOM generation, image signing, secrets handling, and supply chain attestation. The goal is not to turn you into a security engineer. It is to give a .NET team the handful of practices that eliminate the most common real-world risks with the least friction.\nWhy container security is different #Traditional .NET security thinking focuses on the application: OWASP Top 10, authentication, input validation, SQL injection, XSS. All of that is still necessary. But a container adds a second surface: the image itself. Three concrete things can go wrong at the container level even in an application with perfect code:\nThe base image contains a known vulnerability. A CVE in glibc, openssl, zlib, or any system library ships with every image built on top of the affected base. If the base image has not been rebuilt recently, the vulnerability travels into production. The running container has more privileges than it needs. Running as root, having write access to the root filesystem, mounting the Docker socket, and exposing host capabilities all widen the blast radius of any application-level compromise. The supply chain itself is compromised. The image pulled from the registry might not be the image the CI pipeline built, if an attacker has write access to the registry or can intercept the pull. Without signatures and provenance, there is no way to prove the image is authentic. These three risks have dedicated mitigations. The rest of this article covers each one.\nOverview: the layered defense # graph TD A[Source code] --\u003e B[SBOM generatedat build] B --\u003e C[Image scannedTrivy or Scout] C --\u003e D[Image signedcosign] D --\u003e E[Provenance attestationSLSA level 3] E --\u003e F[Registry] F --\u003e G[Runtime verificationsignature + policy] G --\u003e H[Non-root containerread-only FSno capabilities] The pipeline adds one security concern per stage. None of them replace the others, and skipping any one of them leaves a specific class of risk uncovered. The good news is that most of these can be added to an existing build pipeline in a day, not a quarter.\nZoom: the hardened runtime configuration #Before scanning and signing, the container itself should run with minimum privileges. Four settings do most of the work:\n# Kubernetes pod securityContext (also works on ACA with minor differences) 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 and runAsUser: 64198: forces the container to run as a non-root user. The chiseled .NET images already default to UID 64198, but declaring it at the pod level is a defense-in-depth measure that catches the case where someone swaps the image for one that still runs as root.\nallowPrivilegeEscalation: false: prevents the process from gaining more privileges than the parent, even if a setuid binary is present. This stops an entire class of privilege escalation exploits at the kernel level.\nreadOnlyRootFilesystem: true: mounts the root filesystem read-only. An attacker who gains code execution cannot write a web shell, modify a binary, or drop a persistent payload. ASP.NET Core does not need to write anywhere except /tmp, which is provided as a separate emptyDir volume.\ncapabilities: drop: [ALL] and seccompProfile: RuntimeDefault: removes all Linux capabilities (the fine-grained privileges underlying root) and restricts the system calls the container can make via the kernel\u0026rsquo;s seccomp filter. ASP.NET Core needs none of the special capabilities, so dropping them costs nothing and closes a large attack surface.\nTogether, these four settings turn a container from \u0026ldquo;has a foothold on the host if compromised\u0026rdquo; into \u0026ldquo;very constrained sandbox with no easy escalation path\u0026rdquo;. Most .NET applications work under them without modification.\n✅ Good practice : Put these settings in a shared Helm chart or Kustomize base that every service inherits. Standardizing them at the platform level is the only way to prevent drift across tens of services.\nZoom: image scanning in CI #Every image pushed to production should be scanned for known CVEs before deployment. The two widely-adopted open source tools are Trivy (Aqua Security) and Grype (Anchore). Microsoft also provides Docker Scout, integrated into Docker Desktop and Docker Hub.\nA typical CI step using 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 Three decisions to make explicit.\nseverity: HIGH,CRITICAL: most .NET images have dozens of LOW and MEDIUM CVEs at any given moment, and failing the build on those produces noise that trains the team to ignore the scanner. Fail on HIGH and CRITICAL only, triage the rest in a tracker.\nexit-code: 1: the scan must actually fail the build, not just log warnings. A scanner that does not block deployment is a compliance theater, not a security control.\nignore-unfixed: true: some CVEs have no fix available yet. Blocking the pipeline on CVEs you cannot fix punishes your team for something outside their control. Log them, track them, revisit weekly, but do not fail the build.\n💡 Info : The chiseled .NET images from Microsoft are rebuilt on base image updates, which means CVEs in glibc or similar libraries are patched faster than in the full Debian-based images. This is a significant advantage for teams that scan aggressively: a chiseled image typically has zero HIGH or CRITICAL CVEs on the day of release, while the full image has a handful.\nZoom: SBOMs and what they are for #A Software Bill of Materials (SBOM) is a machine-readable list of every package and version inside an image. It does not prevent any vulnerability by itself, but it enables three important workflows:\nRetroactive CVE response. When a new CVE is disclosed (log4shell, xz, spring4shell), an SBOM lets the team query \u0026ldquo;which of our 50 deployed images contains the affected package\u0026rdquo; in seconds, without re-scanning everything. Compliance and audit. Customers, regulators, and SOC 2 auditors increasingly ask for SBOMs as proof of what is actually in a shipped product. Supply chain verification. Pairing an SBOM with a signature creates an attestation that can be verified at pull time. BuildKit generates SBOMs natively:\ndocker buildx build \\ --sbom=true \\ --provenance=true \\ --tag myregistry.azurecr.io/shop-api:1.4.7 \\ --push \\ . The --sbom=true flag attaches an SBOM to the image manifest in SPDX format. The --provenance=true flag attaches a SLSA provenance attestation describing how the image was built: the source repo, the commit, the builder version, the build parameters. Both are stored as OCI artifacts alongside the image, and neither changes how the image runs.\nZoom: signing images with cosign #A signed image proves two things: who built it, and that it has not been modified since. The tool of choice in 2026 is cosign from the Sigstore project, which supports both keyless signing (via short-lived OIDC tokens from the CI provider) and traditional keypair signing.\nKeyless signing from a GitHub Actions workflow:\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 }} The signature is stored in the registry next to the image, referencing it by its content digest (not a mutable tag). At deployment time, a verification step fails the deploy if the signature does not match:\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 This policy says: \u0026ldquo;only accept this image if it was signed by a GitHub Actions workflow in my organization\u0026rsquo;s shop-api repository\u0026rdquo;. An attacker who pushes a modified image to the registry cannot produce a matching signature without also compromising GitHub\u0026rsquo;s OIDC issuer, which is a much higher bar than compromising the registry alone.\n⚠️ It works, but\u0026hellip; : Signing without verification is security theater. The signing step in CI is only half of the value; the verification step at deploy time (in Kubernetes with an admission controller like Kyverno or OPA Gatekeeper, or in ACA with an image validation policy) is what actually enforces the guarantee.\nZoom: secrets, revisited #The Hosting Docker article covered the rule: never bake secrets into the image. That rule has two corollaries that deserve explicit attention in a security context.\nBuild-time secrets must be passed via --secret, not ENV or ARG. If a package fetch during dotnet restore needs an authentication token, BuildKit provides a mount-based secret mechanism:\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 \\ ... The secret is mounted into the build container during the RUN step and is not baked into any layer. After the step, the secret is gone. Using ENV or ARG for the same thing leaks the value into the image history, where anyone with pull access can recover it.\nRuntime secrets should come from a secret store, not environment variables. Environment variables are visible in process listings, crash dumps, and any container introspection tool. For anything more sensitive than a feature flag, use Kubernetes Secrets mounted as files, Azure Key Vault references, or a sidecar like vault-agent that writes to a tmpfs. The application reads from the file at startup and never holds the value in an environment variable.\n❌ Never do this : Do not accept the argument \u0026ldquo;it is a private registry, so it is fine\u0026rdquo;. Private registries are compromised regularly through credential leaks, misconfigured access policies, or supply chain attacks on the registry itself. Defense-in-depth assumes every layer can be compromised.\nZoom: base image hygiene #The single most impactful security practice for .NET containers is staying current with base image updates. Microsoft rebuilds the .NET base images on every security update to the underlying OS, and the chiseled variants get patched especially fast because they have fewer packages to worry about.\nThe practical workflow:\nPin to the minor version (10.0-noble-chiseled), not to a patch version or a digest. This way, rebuilds automatically pick up the latest patched base image without manual tag bumps. Rebuild the image on a schedule, not only on code changes. A weekly scheduled CI run rebuilds the image with the same source, pulls whatever base image has been patched in the meantime, and pushes a new tag. Any deployed image is at most one week out of date. Monitor Microsoft security advisories for .NET and subscribe to the container image advisories. Microsoft releases security updates every second Tuesday of the month, and the base images are usually updated within 24 hours. # .github/workflows/weekly-rebuild.yml on: schedule: - cron: \u0026#39;0 2 * * 1\u0026#39; # Every Monday at 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 ✅ Good practice : Pair the weekly rebuild with a rolling deployment to pre-prod and a canary rollout to prod, gated by the baseline tests covered in the baseline load testing article. This turns base image hygiene from \u0026ldquo;a chore nobody does\u0026rdquo; into \u0026ldquo;the pipeline does it automatically\u0026rdquo;.\nWrap-up #Docker security for .NET in 2026 is not about perfect, it is about the handful of controls that close the biggest gaps: a hardened runtime securityContext with non-root, read-only filesystem, and dropped capabilities; image scanning with Trivy or Scout as a blocking CI step; SBOMs and provenance attestation via BuildKit flags; image signing with cosign and verification at deployment time; BuildKit --mount=type=secret for build-time secrets; runtime secrets from a store, never from environment variables; and a weekly rebuild schedule to keep the base image current. You can add all of this to an existing deployment pipeline in a day or two, and the result is a container posture that blocks the real attack classes without turning security into a full-time job.\nReady to level up your next project or share it with your team? See you in the next one, Kubernetes Primer is where we go next.\nRelated articles # Hosting ASP.NET Core with Docker: A Pragmatic Guide Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers Docker for .NET Deployment: Dockerfile and Compose in Practice References # Trivy documentation Docker Scout, Docker docs Sigstore cosign documentation SLSA framework Kubernetes Pod Security Standards .NET container security, Microsoft Learn BuildKit build secrets ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/deployment-docker-security/","section":"Posts","summary":"","title":"Docker Security for .NET: Hardening, Scanning, and Supply Chain"},{"content":"There are two ways to configure an EF Core model, and the one most tutorials show you is the one you outgrow first. Data annotations on entity properties are fine for demos. Real applications end up with column types that do not map one-to-one to CLR types, owned types, value converters, filtered indexes, check constraints, and seed data that must land in the database before the app starts. All of that lives in the Fluent API, and as of EF Core 9 the seeding story has been rewritten.\nThis article walks through how to configure a non-trivial EF Core model cleanly, where to put that configuration, and how to seed reference data using the new UseSeeding / UseAsyncSeeding hooks.\nWhy configuration matters more than it looks #EF Core builds a model at startup by reflecting over your DbContext and applying every piece of configuration it can find: conventions first, then data annotations, then Fluent API calls in OnModelCreating. The order matters because each step can override the previous one. When you leave everything to conventions, EF Core guesses column types, nullability, string length, and relationships from the CLR shape. Those guesses are reasonable for a demo and wrong for production the moment you care about decimal precision, SQL Server rowversion, PostgreSQL jsonb, or a composite unique index.\nData annotations ([Required], [MaxLength(200)], [Column(TypeName = \u0026quot;decimal(18,2)\u0026quot;)]) solve the simple cases and pollute your domain classes with persistence concerns. They also cannot express half of what you need: no way to configure a value converter, no way to declare a filtered index, no way to set a composite key with ordering, no way to seed. So in any project beyond CRUD-over-one-table, you move configuration to the Fluent API, and you put it in dedicated IEntityTypeConfiguration\u0026lt;T\u0026gt; classes instead of letting OnModelCreating become a 600-line wall.\nOverview: where configuration lives # 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[Reference data: Countries, Roles, Currencies] Three ideas to keep in mind:\nOne configuration class per aggregate root or entity. Colocate it with the entity or keep a dedicated Configurations/ folder. OnModelCreating becomes a single modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly) call. Seeding is no longer done via HasData for anything dynamic. Use UseSeeding for the sync path (used by EnsureCreated and migrations tooling) and UseAsyncSeeding for runtime. Zoom: a realistic DbContext #Let\u0026rsquo;s say we model a small e-commerce backend with orders, customers, and products.\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); } } That is the entire OnModelCreating. Every detail of every entity moves into its own file.\nZoom: IEntityTypeConfiguration in practice #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(); } } A few things are happening here that data annotations cannot express: owned type mapping with explicit column names, an enum stored as string for readability in the database, a value-generated-never primary key because we generate IDs in the domain, an explicit DeleteBehavior per relation, and a shadow RowVersion property for optimistic concurrency.\n💡 Info — HasConversion\u0026lt;string\u0026gt;() is the modern way to persist enums as text. It survives enum reordering and is much easier to read in SQL dumps than integer codes.\n✅ Good practice — Configure one entity per file. When the configuration grows, you extend the file, not OnModelCreating. The test for \u0026ldquo;is this clean?\u0026rdquo; is: can a new developer find the table definition of Order without a full-text search.\n⚠️ Works, but\u0026hellip; — Sprinkling [Column(TypeName = \u0026quot;...\u0026quot;)] on entity properties works, but it couples the domain to the database provider. Keep annotations for truly universal rules ([Required] when it reflects a domain invariant) and put provider-specific types in Fluent API.\nZoom: value converters for domain types #When your domain uses strongly-typed IDs or value objects, value converters are what keep the mapping honest.\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(); } } This keeps CustomerId as a first-class domain type and still maps to a plain uuid column. EF Core 8 introduced primitive collections and improved support for complex types, so value objects no longer require you to flatten them manually.\n💡 Info — Guid.CreateVersion7() is available since .NET 9 and produces time-ordered GUIDs, which index much better than random v4 GUIDs as clustered or primary keys.\nZoom: seeding with UseSeeding and UseAsyncSeeding #EF Core 9 introduced UseSeeding and UseAsyncSeeding, and they finally give you a place to put seed logic that runs both at design time (when you call dotnet ef database update) and at runtime (when your app calls context.Database.MigrateAsync()), without the limitations of HasData.\nHasData stores seed rows in migrations as literal INSERT statements. That works for truly static reference data, breaks the moment the seed row depends on anything dynamic (hashed password, computed foreign key, configuration), and makes every change to a seed row produce a migration. For anything beyond a fixed list of country codes, you want 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;Belgium\u0026#34; }, new Country { Code = \u0026#34;CH\u0026#34;, Name = \u0026#34;Switzerland\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;Belgium\u0026#34; }, new Country { Code = \u0026#34;CH\u0026#34;, Name = \u0026#34;Switzerland\u0026#34; }); await shop.SaveChangesAsync(ct); } }); }); Both hooks run when you call context.Database.EnsureCreatedAsync() or when the EF tooling bootstraps a database. At runtime, the async variant runs on the thread pool; at design time, the sync variant runs from the tooling. EF Core does not call them for you on every application startup: you trigger seeding explicitly, usually right after MigrateAsync() in your startup pipeline.\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(); } ✅ Good practice — Keep seeding idempotent. Every seed block must check \u0026ldquo;does this row already exist?\u0026rdquo; before inserting. Restarting the app, rerunning migrations in CI, or a pod restarting in Kubernetes should all be safe.\n❌ Never do — Do not seed user-facing data (customers, orders, test fixtures) through UseSeeding. That hook is for reference data only: countries, currencies, roles, permissions, lookup tables. Test data belongs to test fixtures, not to the DbContext configuration.\n⚠️ Works, but\u0026hellip; — If you still rely on HasData in a legacy codebase, it continues to work. The migration cost to move to UseSeeding is low and buys you the ability to seed anything computed, including rows that depend on environment variables or existing foreign keys.\nZoom: conventions you usually want to override #A few small changes at the model level pay off across the whole context:\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+) lets you set defaults that apply to every property of a given CLR type. It cuts the amount of per-entity boilerplate dramatically and makes the \u0026ldquo;I forgot to set decimal precision\u0026rdquo; warning disappear.\nWrap-up #You now have the configuration story that holds up in production: Fluent API over annotations, one IEntityTypeConfiguration\u0026lt;T\u0026gt; per entity, value converters for strongly-typed IDs and value objects, and the modern UseSeeding / UseAsyncSeeding hooks for reference data. You can drop the 600-line OnModelCreating and replace it with a single ApplyConfigurationsFromAssembly call, and you can stop bloating your migrations with HasData INSERT statements.\nThe next piece of the puzzle is migrations themselves: how to author them, how to review them, and how to avoid the classic traps when the database is already in production.\nRelated articles # Data Access in .NET: Repository Pattern, or Not? Application Layer: CQS and CQRS References # EF Core: Modeling EF Core: IEntityTypeConfiguration EF Core 9: UseSeeding and UseAsyncSeeding EF Core: Value Conversions EF Core: Pre-convention model configuration ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/database-efcore-config-seed/","section":"Posts","summary":"","title":"EF Core: Configuration and Seeding"},{"content":"Migrations look simple in the first demo: you change the model, you run dotnet ef migrations add, you run dotnet ef database update, and the schema follows. Then you hit the second developer, the first pull request conflict on the model snapshot, the first production table with 40 million rows, and the first column rename that silently drops data. At that point the tool is the same but the mental model has to change: a migration is a piece of versioned code that is going to run against a live database, on a machine that is not yours, at a moment you do not choose. Treat it like code, review it like code, and the surprises stop.\nThis article walks through authoring migrations for a team, reviewing them before they hit production, and the patterns that keep zero-downtime deployments possible.\nWhy migrations need their own discipline #EF Core generates migrations by diffing your current model against a snapshot of the previous model. The snapshot lives in ModelSnapshot.cs, checked into your repo alongside the migration files. Two developers adding migrations on two branches produce two snapshots, and when the branches merge you get a snapshot conflict that cannot be resolved by \u0026ldquo;accept both\u0026rdquo;. The tooling requires you to rebase and regenerate, which is the first reason migrations need coordination.\nThe second reason is that the C# migration code is only half the story. EF Core translates it into SQL at runtime based on the provider. That SQL depends on the database version, the collation, the existing data, the locks it takes, and whether your deployment runs the migration offline or on a live cluster. A migration that drops a column is instant on an empty dev database and a six-minute table lock on a 40-million-row production table. Neither EF Core nor your test suite will warn you: you have to read the generated SQL.\nOverview: the migration lifecycle # flowchart LR A[Change model] --\u003e B[dotnet ef migrations add Name] B --\u003e C[Review C# migration file] C --\u003e D[Review generated SQLdotnet ef migrations script] D --\u003e E{Safe?} E --\u003e|No| F[Split into multiple steps] F --\u003e B E --\u003e|Yes| G[Commit migrationand snapshot] G --\u003e H[CI applies in integration env] H --\u003e I[Production deploymentapplies migration] Two ideas that make this flow work:\nThe migration file is reviewed as source code. Up and Down are read by a human, not just generated and committed blindly. The generated SQL is scripted and read before merge. dotnet ef migrations script emits idempotent SQL you can attach to the pull request. Zoom: adding a migration #dotnet ef migrations add AddCustomerLoyaltyTier \\ --project src/Shop.Infrastructure \\ --startup-project src/Shop.Api \\ --output-dir Persistence/Migrations Three flags matter in a real repo. --project points to the assembly that contains the DbContext. --startup-project points to the executable that configures it, because EF Core needs to build a service provider to read the connection string and the design-time configuration. --output-dir keeps migrations in a dedicated folder.\nThe generated file looks like this:\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;); } } Read both methods. Up is what happens on deploy; Down is what happens on rollback, and a Down that drops a column you just filled with real data is a trap.\n💡 Info — The migration file is a partial class because EF Core generates a second .Designer.cs file alongside it that captures the model snapshot at the time of the migration. Both files belong to git. Do not delete one without the other.\nZoom: reading the generated SQL #The C# file is a description. The SQL is what actually runs. Generate it before merge:\ndotnet ef migrations script LastMigration AddCustomerLoyaltyTier \\ --project src/Shop.Infrastructure \\ --startup-project src/Shop.Api \\ --idempotent \\ --output migration.sql --idempotent makes the script check __EFMigrationsHistory before applying each step, so you can run the same script twice. The output is the exact SQL the production database will execute. Attach it to the pull request. Reviewing it takes a minute and catches the problems EF Core does not warn about: an ALTER TABLE with a default that locks the table, a DROP COLUMN that silently loses data, a new non-null column on a large table that fails because existing rows do not have a value.\n✅ Good practice — Make \u0026ldquo;the migration script is attached to the PR\u0026rdquo; a rule in the checklist. It is the cheapest defect filter you will ever add to your database workflow.\nZoom: the operations that need two migrations #Any change that cannot be applied as a single atomic step needs to be split into multiple migrations. The classic ones:\nRename a column. EF Core does not see a rename, it sees a drop-and-add, which loses data. You have to write it as two steps yourself: add the new column, copy the data in a deployment script, then in a later migration drop the old column. Alternatively, use migrationBuilder.RenameColumn by hand:\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 only generates RenameColumn when you rename the property with the exact same CLR type. If you rename and retype, you get a drop-and-add and you lose data.\nAdd a non-null column to a populated table. The naive migration fails on the first existing row. The fix is a two-phase approach: add it as nullable with a default, backfill the data, then in a later migration alter it to 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 (later release) 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;); Split a table. Never do it in one migration. Add the new table, dual-write from the application for one release, backfill historical data, then in a later release drop the columns from the old table.\n⚠️ Works, but\u0026hellip; — A single migration that does \u0026ldquo;add new structure, backfill, remove old structure\u0026rdquo; also works in a dev environment and on the day you deploy it to production it holds a lock for the duration of the backfill. On a large table, that is your outage.\n❌ Never do — Do not edit a migration that has already been applied in any shared environment. The migration history is the contract. If you need to fix something, add a new migration. Editing history makes the next developer\u0026rsquo;s dotnet ef database update fail with a confusing checksum mismatch.\nZoom: applying migrations at runtime or at deploy time #Two places can run migrations: the application itself at startup via context.Database.MigrateAsync(), or a dedicated step in the deployment pipeline via dotnet ef database update or a generated SQL bundle.\nRuntime migrations are the easiest for small apps. One instance boots, migrates, and starts serving. The failure mode is multi-instance deployments: ten pods all call MigrateAsync() at the same time, and EF Core\u0026rsquo;s migration lock (__EFMigrationsHistory) serializes them, but the ones that lose the race now take the extra startup time while the winner is applying a long migration. Worse, a migration that fails halfway leaves the database in a state where none of the pods can start.\nDeploy-time migrations decouple the schema change from the application start. The pipeline applies the migration once, then rolls out the application. The downside is that the schema and the code must be deploy-compatible for the overlap window where the new schema is live but some pods still run the old code. That is the whole point of the two-migration pattern: each deployment is backward-compatible with the previous version.\n# Option A: runtime (simple, single instance) app.Services.GetRequiredService\u0026lt;ShopDbContext\u0026gt;().Database.Migrate(); # Option B: deploy pipeline (recommended for 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 produces a self-contained executable that applies migrations without requiring the .NET SDK on the target machine. It is the modern replacement for shipping a SQL script, because it handles the __EFMigrationsHistory tracking for you while still running outside the application process.\n💡 Info — Since EF Core 7, migration bundles are the recommended way to apply migrations in CI/CD. They work with any provider and do not need the .NET SDK on the deployment target.\nZoom: the snapshot conflict #Two developers each add a migration on their own branch. Both commits modify ModelSnapshot.cs. When the second one merges, git reports a conflict in the snapshot file, and there is no sane way to \u0026ldquo;accept both\u0026rdquo;.\nThe fix is a rebase, not a merge edit:\ngit fetch origin git rebase origin/main # conflict in ModelSnapshot.cs and in one of the migrations dotnet ef migrations remove --project src/Shop.Infrastructure --startup-project src/Shop.Api # your model changes are still there, rebuild the migration dotnet ef migrations add YourMigrationName --project src/Shop.Infrastructure --startup-project src/Shop.Api git add . git rebase --continue migrations remove deletes the migration files and reverts the snapshot to the previous state. You then re-add the migration on top of the freshly rebased branch, and the snapshot becomes consistent again.\n✅ Good practice — Merge migrations to main one pull request at a time. Serialize the order. Parallel migration merges produce snapshot conflicts 100% of the time, and the fix is always a rebase for one of the two authors.\nWrap-up #Migrations are source code, not magic. You review the C# file, you generate and read the SQL before merge, you split risky operations into two deployments, and you decide consciously whether to apply them at runtime or from the pipeline. Once the team agrees on this flow, the migration bug disappears from the incident report for good.\nThe next step is making reads fast. Even on a perfectly migrated schema, a badly written LINQ query can bring the database down.\nRelated articles # EF Core: Configuration and Seeding Data Access in .NET: Repository Pattern, or Not? References # EF Core: Managing Migrations EF Core: Applying Migrations EF Core: Migration Bundles EF Core: Custom Migration Operations ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/database-efcore-migrations/","section":"Posts","summary":"","title":"EF Core: Migrations in Practice"},{"content":"Most EF Core performance problems are read problems, and most of them look the same: a page that loaded in 40 milliseconds with 20 rows in dev takes 3 seconds in staging with 20 000 rows. The developer reaches for Dapper, migrates five queries, and the rest of the codebase stays on LINQ-to-EF. The honest answer is that EF Core has been fast enough for 95% of reads since version 6, as long as you know which knobs to turn. This article is about those knobs: change tracking, projections, split queries, compiled queries, and the two traps (N+1 and cartesian explosion) that account for most \u0026ldquo;slow query\u0026rdquo; tickets.\nWhy EF Core reads can be slow by default #EF Core\u0026rsquo;s default mode is built for writing: every entity you load goes into the change tracker, every navigation property can be lazy-loaded, every query hydrates full entities. That default is correct for command handlers that load an aggregate, mutate it, and save. It is wasteful for queries that only read. The change tracker takes memory and CPU to snapshot every loaded entity. The hydration builds a full object graph when you only needed four columns. The included navigations join tables you do not need for that specific page. And when a single .Include() crosses two collection navigations, the result set explodes into a cartesian product that is slow to transfer and slow to materialize.\nFixing a slow read means deciding, per query, which of those defaults to turn off. The LINQ stays the same shape; you add one or two method calls.\nOverview: the read optimization toolbox # graph TD A[Slow query] --\u003e B{Diagnosis} B --\u003e C[N+1 pattern] B --\u003e D[Cartesian explosion] B --\u003e E[Tracking overhead] B --\u003e F[Over-fetching columns] B --\u003e G[Query compilation cost] C --\u003e H[.Include or projection] D --\u003e I[AsSplitQuery] E --\u003e J[AsNoTracking] F --\u003e K[Select projection to DTO] G --\u003e L[EF.CompileAsyncQuery] Five levers, five distinct problems. You do not apply them all at once: you diagnose first, then pick the one that matches.\nZoom: AsNoTracking, the cheapest win #Any query that does not mutate the result should skip the change tracker:\nvar customers = await _db.Customers .AsNoTracking() .Where(c =\u0026gt; c.LoyaltyTier == \u0026#34;gold\u0026#34;) .ToListAsync(ct); AsNoTracking() tells EF Core not to snapshot the loaded entities, which saves memory and CPU proportional to the result size. For a read-only list endpoint, it is usually a 20-40% speedup on the .NET side with no change to the SQL. If you have many such queries, set it as the context default:\nservices.AddDbContext\u0026lt;ShopDbContext\u0026gt;(options =\u0026gt; { options.UseNpgsql(conn) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); }); Commands that need to mutate then opt back in explicitly with .AsTracking(). This inverts the default in favor of reads, which is the right bet for most web applications where reads outnumber writes 10 to 1.\n💡 Info — AsNoTrackingWithIdentityResolution() is the middle ground. It skips change tracking but still deduplicates references inside the result, which matters when you load an object graph with shared parents. Use it when a plain AsNoTracking() gives you duplicate child rows.\nZoom: projections, the biggest win #The fastest query is the one that only asks for what the endpoint actually returns. If the API response has 5 fields, fetch 5 columns, not the 30-column entity.\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); Three things happen automatically when you project:\nEF Core generates a SELECT with only the projected columns. No unused joins, no BLOB columns dragged over the wire. The result type is not an entity, so there is nothing to track. AsNoTracking() becomes redundant on a projected query. EF Core can translate a projection across a navigation without needing an .Include(). That third point is the one most developers miss. You do not need .Include(c =\u0026gt; c.Orders) if you only want the latest order date:\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 translates that into a LEFT JOIN LATERAL (or an equivalent subquery depending on the provider) and returns one row per customer. No N+1, no extra round trip.\n✅ Good practice — For any query feeding a list view, project to a DTO. .Include() is for commands that load an aggregate, not for reads that feed a UI.\nZoom: the cartesian explosion #var orders = await _db.Orders .Include(o =\u0026gt; o.Lines) .Include(o =\u0026gt; o.Payments) .Where(o =\u0026gt; o.CreatedAt \u0026gt; since) .ToListAsync(ct); This looks innocent and is a classic pitfall. EF Core generates a single SQL query with two LEFT JOINs. An order with 5 lines and 2 payments produces 10 rows in the result set, one for every combination. Scale that to 1 000 orders and you are transferring tens of thousands of rows over the wire to hydrate what the domain sees as 1 000 objects. EF Core warns you at startup in the logs when it detects this, and the fix is one call:\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() makes EF Core emit three separate SELECTs (one for orders, one for lines, one for payments) and stitch them in memory. You pay three round trips instead of one, but you transfer N + L + P rows instead of N * L * P. For any collection that has more than a handful of children, split queries win.\nYou can also set the default at the context level:\noptions.UseNpgsql(conn, npgsql =\u0026gt; npgsql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); ⚠️ Works, but\u0026hellip; — Single-query mode is fine for 1:1 navigations and for small 1:N collections. The moment you chain two .Include() over collections, you are in cartesian explosion territory. When in doubt, switch to split query.\nZoom: the N+1 trap #The classic N+1 comes from iterating loaded entities and accessing a navigation on each one:\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, one SQL per order Console.WriteLine($\u0026#34;{order.Id} - {customer.Email}\u0026#34;); } Lazy loading fires a query per iteration. The fix is either an explicit .Include(o =\u0026gt; o.Customer), or, better, a projection that pulls the customer email directly:\nvar orderSummaries = await _db.Orders .Where(o =\u0026gt; o.CreatedAt \u0026gt; since) .Select(o =\u0026gt; new { o.Id, CustomerEmail = o.Customer.Email }) .ToListAsync(ct); ❌ Never do — Do not enable lazy loading globally on a CRUD API. It turns every forgotten .Include() into a silent N+1 that only shows up under production traffic. EF Core ships with lazy loading off by default for exactly this reason. If you need it, enable it per context, and audit every query that touches a navigation.\nZoom: compiled queries for hot paths #EF Core parses and translates the LINQ expression tree on every call. For most queries the cost is negligible compared to the database round trip, but for a hot path called thousands of times per second, the translation starts to show up in the profiler. Compiled queries cache the translation:\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 takes the LINQ expression, translates it once, and returns a delegate that reuses the compiled plan. On a query called 10 000 times, this typically shaves 20-30% of the .NET-side latency. Use it surgically: the top 5 queries in your telemetry, not every repository method.\n💡 Info — Since EF Core 6, query compilation results are also cached internally per distinct LINQ expression shape. EF.CompileAsyncQuery still wins on raw throughput, but the gap is narrower than it used to be.\nZoom: reading the generated SQL #Every optimization decision depends on reading the actual SQL. Enable the logger and log SQL at the debug level in development:\noptions.UseNpgsql(conn) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging(); In production, keep EF Core\u0026rsquo;s category at Warning to catch the cartesian explosion and multiple-collection warnings, but do not log every statement. Instead, rely on OpenTelemetry instrumentation, which emits each database query as a span with the SQL attached.\nservices.AddOpenTelemetry() .WithTracing(t =\u0026gt; t.AddEntityFrameworkCoreInstrumentation(o =\u0026gt; o.SetDbStatementForText = true)); That gives you a timeline of every query each request executes, which is usually enough to spot the one that is taking 400 ms when the other 19 are taking 2 ms each.\nWrap-up #The five moves cover most read performance problems. AsNoTracking for every read-only query. Project to a DTO for any list endpoint. AsSplitQuery the moment you chain .Include() over collections. Hunt lazy-loading N+1s by reading the generated SQL, not by inspecting the C#. And compile the three or four queries that actually show up in the hot path. Once those are in place, the remaining 5% of slow queries are the ones that genuinely need raw SQL, which is the topic of the next article.\nRelated articles # EF Core: Configuration and Seeding EF Core: Migrations in Practice References # EF Core: Performance EF Core: Efficient Querying EF Core: Split Queries EF Core: Compiled Queries EF Core: Tracking vs No-Tracking ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/database-efcore-read-optimization/","section":"Posts","summary":"","title":"EF Core: Read Optimization"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/efcore/","section":"Tags","summary":"","title":"Efcore"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/endpoints/","section":"Tags","summary":"","title":"Endpoints"},{"content":"Every ASP.NET Core project starts with the same decision. Do you scaffold a Controllers/ folder with [ApiController] classes, or do you map endpoints in Program.cs with app.MapPost(...) and a lambda? The two styles do the same job, they both live in the same framework, and both are fully supported long term. The differences show up in how much ceremony they impose, how they compose filters and middleware, and how they scale with team size and codebase maturity.\nThis article walks through both styles head to head, with realistic code, and explains when each one earns its place.\nWhy this choice exists at all #Controllers have been in ASP.NET since MVC 1 in 2009. Web API controllers arrived in 2012, and the two were unified under ASP.NET Core\u0026rsquo;s single controller model in 2016. For a decade, if you wrote an HTTP endpoint in .NET, you wrote a controller. The model binds, the filters compose, the attributes describe metadata, and the conventions are deep.\nMinimal APIs landed in .NET 6 in November 2021 as a response to a very specific observation: for small services and simple endpoints, the ceremony of a controller class, a base class, routing attributes, and an action method is a lot of typing for one line of real work. The team led by Damian Edwards and David Fowler started from the question \u0026ldquo;what is the absolute minimum code to map a URL to a handler?\u0026rdquo; and built from there.\nFor the first year, Minimal APIs were visibly missing pieces: no endpoint filters, no typed results, weak OpenAPI, no support for [FromServices] shortcuts. .NET 7 and .NET 8 closed most of those gaps. By .NET 9, the gap is narrow enough that the choice is genuinely a taste and architecture decision, not a capability one.\nOverview: the two mental models #Before the code, here is how each style lays out the same endpoint in your head.\ngraph LR subgraph Controllers direction TB C1[Program.csAddControllers] --\u003e C2[OrdersController] C2 --\u003e C3[Action method] C3 --\u003e C4[Filter pipeline] C4 --\u003e C5[Handler logic] end subgraph Minimal[\"Minimal API\"] direction TB M1[Program.cs] --\u003e M2[MapPost lambda] M2 --\u003e M3[Endpoint filters] M3 --\u003e M4[Handler logic] end Same request, same response, same middleware, same dependency injection. The difference is where the endpoint is declared and how metadata is attached to it. Controllers lean on attributes and conventions. Minimal APIs lean on fluent chaining.\nZoom: the same endpoint, written both ways #Let us implement a classic endpoint, POST /orders, in both styles, with validation, authorization, typed results, and OpenAPI metadata. This is what you would actually ship to production.\nController version #// 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); } } Wired in Program.cs with two lines:\nbuilder.Services.AddControllers(); // ... app.MapControllers(); Minimal API version #// 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; } } Wired in Program.cs with one line:\napp.MapOrders(); Two styles, same behaviour, same routing table, same OpenAPI document. The controller version is more declarative (attributes do the talking), the Minimal version is more explicit (the chain tells you exactly what applies to what).\n💡 Info — TypedResults (the typed variant of Results) is the recommended return type in Minimal APIs since .NET 7. It lets you express the response in the signature, which both improves testability and gives the OpenAPI generator enough information to describe each branch without extra attributes.\nZoom: filters, the moment both styles diverge #Cross-cutting concerns (logging, validation, tenant resolution, idempotency) are where the two models feel most different.\nControllers use action filters, which have existed since MVC 1 and form a rich pipeline: authorization filters, resource filters, action filters, exception filters, result filters. They can short-circuit the pipeline, mutate the action arguments, and wrap the result. They are powerful and well understood.\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;Idempotency-Key header required.\u0026#34;); return; } if (await _store.HasSeenAsync(key)) { context.Result = new StatusCodeResult(StatusCodes.Status409Conflict); return; } await next(); await _store.MarkAsync(key); } } // Usage [ServiceFilter(typeof(IdempotencyFilter))] [HttpPost] public async Task\u0026lt;ActionResult\u0026lt;CreateOrderResponse\u0026gt;\u0026gt; Create(...) { } Minimal APIs use endpoint filters, introduced in .NET 7. They are simpler (one interface, one delegate), they compose by chaining .AddEndpointFilter(...), and they run after model binding but before the 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;Idempotency-Key header required.\u0026#34;); if (await _store.HasSeenAsync(key)) return TypedResults.Conflict(); var result = await next(ctx); await _store.MarkAsync(key); return result; } } // Usage group.MapPost(\u0026#34;/\u0026#34;, Handler) .AddEndpointFilter\u0026lt;IdempotencyFilter\u0026gt;(); The endpoint filter is smaller, but it also does less. There is no separation between authorization, resource, action, and exception stages. For a big app with dozens of cross-cutting rules at different stages, the richer controller pipeline can be an advantage. For most apps, a handful of endpoint filters is simpler to reason about.\n✅ Good practice — In Minimal APIs, attach filters at the route group level, not the individual endpoint. One .AddEndpointFilter\u0026lt;IdempotencyFilter\u0026gt;() on the /orders group applies it to every order endpoint, which is both less noisy and less error-prone than remembering to add it to each MapPost.\nZoom: model binding and validation #Controllers have a long history with model binding. [FromBody], [FromQuery], [FromRoute], [FromForm], [FromHeader], plus implicit binding based on type and source. Combined with [ApiController], you get automatic 400 responses on invalid models and automatic ProblemDetails formatting.\nMinimal APIs bind by convention: a parameter of a complex type is read from the body (unless decorated with [FromServices]), primitives are read from the route or query, IFormFile comes from the form, services are resolved from DI automatically. You can still use the [From...] attributes when the convention is ambiguous.\nValidation is where both styles leave a gap. Neither integrates with FluentValidation out of the box. The canonical answer in both worlds today is to push validation into a MediatR pipeline behavior, or into an endpoint filter for Minimal APIs. That way the validator lives with the command, and the endpoint definition stays clean.\n⚠️ It works, but\u0026hellip; — Data annotations ([Required], [StringLength]) technically work in Minimal APIs via ValidationFilter or similar middleware, but they leak validation rules into your request records and do not compose well with conditional rules. For anything beyond toy validation, use FluentValidation in a filter.\nZoom: testing, OpenAPI, and AOT #For unit testing, controllers have a small advantage: an action method is just a method on a class. You new up the controller, call the method, assert on the ActionResult. Minimal API handlers are usually lambdas inside MapPost, which makes them harder to test directly. The fix is to extract the handler into a named static method or a MediatR handler, which is a good practice either way.\nFor integration testing, both styles work identically with WebApplicationFactory. The HTTP surface is what you test, and it does not care how the endpoints were declared.\nFor OpenAPI, .NET 9 introduced the new Microsoft.AspNetCore.OpenApi package which replaces Swashbuckle as the default. It works equally well with both models. Minimal APIs get slightly richer metadata for free when you use TypedResults, because the result type is part of the signature.\nFor AOT (Ahead of Time) compilation, Minimal APIs are the recommended path. The ASP.NET Core team has invested heavily in making RequestDelegateFactory source-generated and trim-safe. Controllers rely on more reflection at startup and have a harder time becoming AOT-friendly.\n💡 Info — For the deep dive on why AOT matters and what it buys you in .NET, see AOT Compilation in .NET.\nThe honest decision matrix #Neither style is objectively better. Here is how I choose in practice:\nPick Minimal APIs when:\nThe project is a small or medium service, a microservice, a function-like workload. You want AOT compilation, fast cold starts, or a small container image. You use a feature-organized codebase (each feature maps its own endpoints) and want the endpoint declaration to sit next to the handler. Your team prefers explicit wiring over convention-based wiring. Pick Controllers when:\nThe app has dozens of endpoints sharing rich filter pipelines and metadata attributes. The team has deep MVC conventions already (model binding, view-model mapping, complex filter ordering). You need the full action filter taxonomy (authorization, resource, action, result, exception) with short-circuit behaviour between stages. You rely on tooling or libraries that still expect ControllerBase (some older analyzers, legacy scaffolders, a few third-party extensions). Pick \u0026ldquo;both in the same project\u0026rdquo; when:\nA greenfield area ships with Minimal APIs, a legacy area keeps its controllers. They coexist peacefully. AddControllers() and MapControllers() do not interfere with MapPost(...). The only rule is to be consistent within a feature, not within the whole solution. ❌ Never do this — Do not copy-paste entire controllers into Minimal API lambdas or vice versa as a \u0026ldquo;migration\u0026rdquo;. A style change is not a feature, it adds risk for no user-visible value. Migrate a module when you are already touching it for another reason, and leave the rest alone. Consistency inside a module matters more than consistency across the whole codebase.\nWrap-up #You now know how Controllers and Minimal APIs actually compare in modern ASP.NET Core: controllers give you a rich filter pipeline, deep conventions, and a decade of tooling; Minimal APIs give you less ceremony, first-class AOT support, typed results, and endpoint filters that are simpler to compose. You can write the same endpoint in both styles and ship the same HTTP surface, and you can mix the two in one project when it makes sense. Pick the one that fits the shape of your codebase, not the one that is newer or older.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nReferences # Minimal APIs overview, Microsoft Learn Choose between controller-based APIs and Minimal APIs, Microsoft Learn Filters in Minimal API apps, Microsoft Learn Handle errors in Minimal APIs, Microsoft Learn OpenAPI support in ASP.NET Core API apps, Microsoft Learn Native AOT deployment, Microsoft Learn ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/layer-focused-controllers-vs-minimal-api/","section":"Posts","summary":"","title":"Endpoints in .NET: Controllers vs Minimal API, the Honest Comparison"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/kubernetes/","section":"Tags","summary":"","title":"Kubernetes"},{"content":"The Hosting series Kubernetes article covered the primitives you need to run an ASP.NET Core application on Kubernetes: Deployments, Services, Ingress, probes, resource limits. This article covers the other half: how to actually work with Kubernetes day to day as a .NET developer. What kubectl commands matter, how to organize manifests so that dev, staging, and production do not drift, how Kustomize and Helm fit together, and the concrete workflow a team uses to go from \u0026ldquo;I wrote a change\u0026rdquo; to \u0026ldquo;it is deployed\u0026rdquo; without hand-crafting YAML for every environment.\nThe assumption here is that you have read the Hosting article, you understand what a Deployment and a Service are, and you now need to ship the thing. The goal of this primer is to give you the minimum viable tooling to do that, and the judgment to know when to reach for more.\nWhy Kubernetes deployment workflow matters #Kubernetes itself is declarative, which is wonderful in theory and unforgiving in practice. A single manifest file with hardcoded values works great for one environment and drifts within a week across three. The gap between \u0026ldquo;I have a Deployment manifest\u0026rdquo; and \u0026ldquo;my team ships reliably to dev, staging, and prod with the same pipeline\u0026rdquo; is bigger than most introductory tutorials admit.\nThe workflow this article covers answers four concrete questions:\nHow do I actually talk to the cluster? kubectl has hundreds of subcommands; maybe fifteen matter for daily work. How do I keep manifests DRY across environments? A production deployment needs different resource limits, different replica counts, different secrets, and different ingress hostnames than a dev deployment. Copy-pasting YAML across three folders is the problem that Kustomize and Helm solve. How do I package and version a release? A release is not just an image tag. It is a Deployment, a Service, an Ingress, a ConfigMap, a Secret, a HorizontalPodAutoscaler, and whatever else the application needs. All of that should move together. How do I deploy without touching kubectl in production? Pipelines, GitOps, and reviewable changes are the alternative to \u0026ldquo;someone typed a command on their laptop\u0026rdquo;. Overview: the deployment workflow # graph LR A[Source code+ manifests] --\u003e B[CI build] B --\u003e C[Image pushedto registry] B --\u003e D[Manifests renderedKustomize or Helm] D --\u003e E[kubectl applyor GitOps sync] E --\u003e F[Cluster reconcilesdesired state] F --\u003e G[Running pods] Every Kubernetes deployment follows the same basic shape. CI builds the image, pushes it to a registry, renders the manifests for the target environment, and applies them to the cluster. The cluster reconciles its running state with the declared state and reports back. The variations between teams are mostly in how manifests are rendered and how they are applied.\nZoom: the kubectl commands that matter #Out of everything kubectl can do, twelve commands cover 95% of day-to-day work. Learn these first.\n# Where am I? kubectl config current-context # which cluster kubectl config use-context prod # switch cluster kubectl get nodes # nodes and their status # What is running? kubectl get pods -n shop # pods in a namespace kubectl get deployments,svc,ingress -n shop # all common resources at once kubectl describe pod shop-api-abc123 -n shop # detailed state of a pod # Logs and debugging kubectl logs shop-api-abc123 -n shop --tail=100 --follow kubectl logs -l app=shop-api -n shop --tail=100 # all pods matching a label kubectl exec -it shop-api-abc123 -n shop -- /bin/sh # shell into a pod # Apply and rollback kubectl apply -f deployment.yaml # create or update kubectl rollout status deployment/shop-api -n shop # wait for rollout to complete kubectl rollout undo deployment/shop-api -n shop # rollback to previous revision # Port-forward for local debugging kubectl port-forward svc/shop-api 8080:80 -n shop Two habits save real time. First, set a default namespace so you do not type -n shop on every command: kubectl config set-context --current --namespace=shop. Second, use kubectl get with label selectors (-l app=shop-api) to operate on groups of resources, not individual ones.\n💡 Info : kubectl logs -l app=shop-api --follow is the command to remember for production log tailing. It aggregates logs from every matching pod in real time, which is what you want when debugging why a specific endpoint is slow across replicas.\nZoom: manifest layout with Kustomize #A naive approach puts all the manifests in one folder and edits them by hand for each environment. It works for a week and collapses after that. Kustomize solves it with a base + overlays pattern that is native to kubectl since 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 The base contains the manifests as they would look in a \u0026ldquo;default\u0026rdquo; environment: one replica, minimal resources, no environment-specific values. The overlays contain only the differences from the 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 Three things Kustomize gives you for free. Namespace substitution: the overlay declares namespace: shop-prod and every resource in the overlay gets deployed there, without editing the base. Patch-based overrides: the replica count and resource limits live in small patch files that only describe the delta from the base. ConfigMap generation with merge semantics: environment-specific values are layered on top of base values without duplicating the full ConfigMap.\nRendering and applying is a single command:\n# Preview what will be applied kubectl kustomize k8s/overlays/prod # Actually apply it kubectl apply -k k8s/overlays/prod ✅ Good practice : Always kubectl kustomize before kubectl apply in a new environment. Diffing the rendered output against the current cluster state with kubectl diff -k ... shows exactly what will change, which is the closest thing to a dry run Kubernetes offers.\nZoom: Helm for packaging and reuse #Kustomize is excellent for a team\u0026rsquo;s own manifests. Helm solves a different problem: packaging manifests as a reusable artifact that can be versioned, shared, and deployed with parameters. If Kustomize is \u0026ldquo;my team\u0026rsquo;s manifests, per environment\u0026rdquo;, Helm is \u0026ldquo;a packaged unit I can install, upgrade, and uninstall like a library\u0026rdquo;.\nThe practical use cases where Helm wins:\nInstalling third-party components. NGINX Ingress Controller, cert-manager, Prometheus, Grafana, external-secrets: all of them ship as Helm charts and installing them is a one-line command. Packaging your own application for multiple consumers. A .NET service that multiple teams deploy (a shared auth service, a shared observability agent) is easier as a chart with parameters than as a set of manifests each team has to copy. Upgrades and rollbacks as first-class operations. helm upgrade and helm rollback track the release history in the cluster itself, which is cleaner than manually tracking Git commits. A minimal chart for the .NET API:\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 # back to previous revision helm rollback shop-api 3 --namespace shop-prod # back to revision 3 specifically ⚠️ It works, but\u0026hellip; : Helm templates are Go text/template over YAML, which is a combination that does not always degrade gracefully. A misplaced indent in a template can produce valid-looking but semantically wrong YAML. helm template ./chart -f values-prod.yaml | kubectl apply --dry-run=server -f - is the standard way to catch these before they reach the cluster.\nZoom: Kustomize or Helm, which one #The choice is not either/or. Most mature Kubernetes setups use both.\nUse Helm for third-party components, shared services, and anything you publish to a chart repository. Its strength is packaging and upgrade semantics.\nUse Kustomize for your own team\u0026rsquo;s services, where you control both the base manifests and the overlays. Its strength is simplicity: no templating language, no helpers, just YAML patches.\nCombine them by using Kustomize to post-process Helm output. Helm renders a chart with base values, Kustomize applies team-specific overrides on top. This is the pattern most production clusters land on after a year or two of experimentation.\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 ships with Kustomize built in, but not Helm. Installing Helm is a one-line script (curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash), and most Kubernetes environments already have it.\nZoom: GitOps with Flux or ArgoCD #Once the manifests are organized and the rendering works, the next step is removing humans from the deployment path entirely. GitOps is the pattern where the cluster continuously reconciles itself with a Git repository: the repository is the source of truth, and the cluster polls it for changes and applies them automatically.\nThe two widely-used tools are Flux and ArgoCD. Both work the same way: you install a controller in the cluster, point it at a Git repository, and every change merged to the main branch of that repository is applied to the cluster within seconds. Rollback is a git revert.\nMinimal ArgoCD Application:\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 After this is applied once, ArgoCD watches the overlays/prod folder of the manifests repository. Any merge to main triggers an automatic sync. The selfHeal: true option means the cluster auto-corrects drift: if someone manually edits a resource with kubectl, ArgoCD reverts it to match Git.\nThe benefits are concrete: every deployment is a pull request with reviewers, every rollback is a Git revert, and every environment is auditable by looking at Git history.\n✅ Good practice : Keep application source code and manifests in separate repositories. shop-api has the C# code; shop-manifests has the YAML. This separation lets the CI pipeline push manifest updates (new image tag) without polluting the code repository history, and it gives the operations team a clear boundary.\nWhen this is overkill #Everything in this article assumes the team actually runs on Kubernetes and intends to keep doing so. If the current setup is a single container on Azure Web App, jumping to Kustomize + Helm + GitOps is overkill. Start with the hosting option that fits the size of the team and the workload, and adopt this toolchain when the scale justifies it.\nRough thresholds:\nOne or two services, one team: kubectl apply -f with plain manifests is fine. A handful of services, one environment other than dev: add Kustomize. Many services, multiple environments, multiple teams: add Helm (for shared components) and GitOps (for the deployment pipeline). Wrap-up #Deploying .NET applications on Kubernetes as a day-to-day workflow comes down to a small set of tools and habits: fifteen kubectl commands for everything operational, Kustomize for base-plus-overlays manifest management, Helm for packaging and third-party charts, and GitOps with Flux or ArgoCD when the scale justifies removing humans from the deployment path. You can adopt these incrementally, start with just Kustomize, add Helm when it pays off, and reach GitOps when \u0026ldquo;who deployed what when\u0026rdquo; becomes a real question. You can avoid the common failure mode of copy-pasted YAML across environments, and you can give your team a deployment workflow that is reviewable, auditable, and reversible.\nReady to level up your next project or share it with your team? See you in the next one, .NET Aspire is where we go next.\nRelated articles # Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers Docker for .NET Deployment: Dockerfile and Compose in Practice Docker Security for .NET: Hardening, Scanning, and Supply Chain References # kubectl cheat sheet, Kubernetes docs Kustomize documentation Helm documentation ArgoCD documentation Flux documentation Deploy a .NET application to Kubernetes, Microsoft Learn ","date":"9 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/deployment-kubernetes-primer/","section":"Posts","summary":"","title":"Kubernetes Primer for .NET Developers: From kubectl to Helm"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/migrations/","section":"Tags","summary":"","title":"Migrations"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/minimal-api/","section":"Tags","summary":"","title":"Minimal-Api"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/performance/","section":"Tags","summary":"","title":"Performance"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/security/","section":"Tags","summary":"","title":"Security"},{"content":"15+ years building and teaching .NET. #I am Mohamed Mustapha, a fullstack engineer and trainer. Fifteen-plus years in, my stack today is focused: .NET (Core and beyond) on the backend, Angular on the frontend. SharePoint and .NET Framework Web Forms were part of my early career, but I moved off that world a good while ago and have been shipping modern .NET and Angular ever since.\nWhere I have built #Over the years I worked inside Microsoft France and delivered for clients like Natixis, Banques Populaires, Rexel, and Thales. Banking, energy distribution, defense, enterprise IT: each of those environments teaches you something different about what \u0026ldquo;production\u0026rdquo; actually means, how architecture decisions survive contact with reality, and how to keep a codebase healthy across teams that change over time. Today I work at the French Ministry of Economy (Direction Générale du Trésor) on administrative application modernisation, but the most important part of my background is not any single title, it is the accumulation of contexts.\nWhy I write #I have watched .NET evolve through every major shift: ASP.NET MVC, OWIN middleware, .NET Core, software craftsmanship, N-Layered, Clean Architecture, CQRS, Vertical Slicing, microservices, modular monoliths, containers, Kubernetes, observability, .NET Aspire. I had to understand every change, not because I collect buzzwords, but because trainers cannot fake it: when you explain a pattern to a room of mid-level developers, you need to know why it exists, what problem it actually solved, and what trade-off it introduced.\nThis blog is that knowledge, written down. The same mental map I use when I teach a team over a week-long workshop, available to anyone who wants to grow from mid-level to senior on their own time.\nI have spent years doing the work most tutorials skip: migrating legacy authentication to Keycloak without rewriting the app, wiring distributed tracing across real infrastructures, designing modular monoliths teams can actually maintain, and explaining to non-technical stakeholders why architecture decisions have concrete consequences.\nWho this is for #You are a .NET developer with two to five years of experience. You ship features. You know C#. But you feel like something is missing between where you are and where senior developers operate.\nThat gap is rarely about syntax. It is about knowing why an architecture decision is made, not just how to implement it. It is about understanding the trade-offs before you are in the middle of a production incident.\nThat is exactly what this blog addresses.\nWhat you will find here #Every series is built around a concrete theme that separates mid-level from senior developers: code structure, observability, authentication, deployment, error handling, performance, and testing. Each article follows the same format: the real-world problem first, a clear technical breakdown, and honest notes on what works, what does not, and what to never do.\nAll content is published in both English and French. The French version is not a translation. It is written in the same voice I use when training teams internally.\nAvailable for training #I am available for in-house training sessions and workshops for .NET and Angular teams. Topics covered include everything published on this blog: Clean Architecture, Docker and Kubernetes, Keycloak integration, observability with OpenTelemetry, testing strategies, and more.\nIf your team is moving toward modern .NET and Angular practices and needs structured, hands-on guidance from someone who has shipped these patterns for real clients, get in touch.\nContact: LinkedIn | GitHub\n","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/about/","section":"Road to Senior .NET Developer","summary":"","title":"About"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/aot/","section":"Tags","summary":"","title":"Aot"},{"content":"For twenty years, .NET ran through a JIT compiler. Managed code was loaded, verified, compiled to machine instructions on first call, and executed. The JIT produced excellent steady-state performance because it could observe actual runtime behavior and optimize accordingly. The cost was paid at startup: a cold process spent its first few seconds compiling hot paths before reaching peak speed, and the runtime itself had to ship inside every deployment. For a long-running web server, that was a trivial tax. For a serverless function invoked once and destroyed, a CLI tool, a container scaled from zero, or any workload where startup is the dominant cost, it was a problem.\nNative AOT in .NET 7 (2022) solved that problem by producing a fully compiled, self-contained native binary at build time, with no JIT at runtime. The result is a .NET application that starts in tens of milliseconds, takes less memory, and ships without a runtime. The constraints, however, are significant, and a team that adopts Native AOT without understanding them will discover the hard way that reflection-heavy libraries, dynamic code generation, and parts of the BCL do not work the same way.\nThis article covers both sides: what AOT buys, what it costs, when to use it, and when zero-allocation techniques or plain JIT are the better choice.\nWhy AOT exists #The problem AOT solves is not raw throughput. On long-running workloads, a warm JIT-compiled process is often faster than an AOT-compiled one, because the JIT has profile-guided optimization, tiered compilation, and method inlining informed by actual call patterns. What AOT solves is the shape of the latency curve at process start and the size of what ships in the container.\nThree concrete pain points drive AOT adoption:\nCold start latency. A standard .NET 10 web app takes several hundred milliseconds to start, even with ReadyToRun. An AOT-compiled version of the same app starts in 30 to 80 milliseconds. For a Lambda, an Azure Function, a Kubernetes pod that scales from zero, or a CLI tool that runs and exits, that difference is everything. Binary size. A self-contained .NET app ships with a runtime that weighs 70 to 100 MB after trimming. A Native AOT binary for the same app can weigh 10 to 20 MB. In a container registry that hosts 500 images, or a CI pipeline that pulls images hundreds of times per day, the difference compounds quickly. Memory footprint. A JIT-compiled process needs memory for the runtime, the compiler itself, and the compiled code. An AOT process needs none of that. Peak working set drops by 30 to 50% on typical workloads. There is also a fourth, less talked-about reason: deployment simplicity. A native binary is a single file that runs on the target OS without any runtime installed. No \u0026ldquo;is this the right .NET version?\u0026rdquo;, no \u0026ldquo;did the base image get patched?\u0026rdquo;, no \u0026ldquo;why does this work on my machine and not on the server?\u0026rdquo;. It runs or it does not, and if it runs once, it runs everywhere that shares the same OS and architecture.\nOverview: the AOT landscape # graph TD A[.NET compilation options] --\u003e B[JITdefault] A --\u003e C[ReadyToRunsince .NET Core 3.0] A --\u003e D[Native AOTsince .NET 7] B --\u003e B1[Best steady-state perfSlowest startup] C --\u003e C1[Pre-JITted methodsStill needs runtime~30% faster startup] D --\u003e D1[No runtimeSmallest sizeFastest startupReflection limits] Three compilation models are available in .NET 10, each with a different trade-off.\nJIT is the default and the right choice for the vast majority of workloads: web servers that stay up for hours or days, background workers, anything where steady-state performance matters more than startup. The JIT has profile-guided optimization (PGO) since .NET 6, tiered compilation since .NET Core 2.1, and produces code that can, in many benchmarks, beat the equivalent AOT output after warmup.\nReadyToRun (R2R) is the middle ground. It pre-compiles methods to native code at build time, then still uses the JIT at runtime for re-optimization and any methods that were not pre-compiled. R2R has been available since .NET Core 3.0 and is enabled by adding \u0026lt;PublishReadyToRun\u0026gt;true\u0026lt;/PublishReadyToRun\u0026gt; to the csproj. It reduces startup latency by roughly 30% with minimal other changes. It is the low-risk first step for teams whose startup is painful but who cannot afford the AOT constraints.\nNative AOT is the full commitment. It produces a single native binary with no runtime, no JIT, and no dynamic code generation. The cost is a set of constraints (covered below) that rule out entire categories of libraries. The benefit is the fastest startup, smallest size, and lowest memory footprint .NET can produce.\n💡 Info : Native AOT was released in .NET 7 (2022) as a supported feature for ASP.NET Core minimal APIs, worker services, and console apps. Support expanded in .NET 8, 9, and 10 to cover more libraries and scenarios. In .NET 10, most of ASP.NET Core\u0026rsquo;s common middleware and System.Text.Json work correctly under AOT with source generators.\nZoom: enabling Native AOT #Switching a minimal API to Native AOT is a two-line change in the csproj and a handful of code adjustments:\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 { } Two things to notice. CreateSlimBuilder replaces CreateBuilder and produces a trimmer-friendly host without the full MVC stack. JsonSerializerContext replaces runtime reflection-based JSON serialization with a compile-time source generator, which is the standard AOT-compatible approach.\ndotnet publish -c Release -r linux-x64 The output is a single binary in bin/Release/net10.0/linux-x64/publish/, weighing typically 12 to 18 MB, with no runtime needed to run it.\n✅ Good practice : Use CreateSlimBuilder from day one if AOT is on the roadmap. It makes the migration gradual instead of a single painful flip, and it pushes the team toward patterns that work under both JIT and AOT.\nZoom: the constraints #This is the section that often gets skipped in AOT marketing. The trade-offs are real and they disqualify AOT for a meaningful portion of real-world codebases.\nNo runtime reflection over arbitrary types. The linker (IL trimmer) removes code it cannot statically prove is used. Any library that uses Type.GetType(string), Activator.CreateInstance(type), or Assembly.Load at runtime on types the linker did not see at build time will fail. This affects many popular libraries: older versions of AutoMapper, some IoC containers with runtime type discovery, certain serialization libraries. Newer versions of most of these have adopted source generators, but the migration is not complete across the ecosystem.\nNo dynamic code generation. System.Reflection.Emit, Expression.Compile(), and any library built on top of them (dynamic proxies, some ORMs, some mocking frameworks) do not work under AOT. Entity Framework Core has AOT support in .NET 8+, but with limitations on some query shapes. Moq and NSubstitute do not work, because they rely on runtime proxy generation.\nSource generators for JSON and others. System.Text.Json reflection-based serialization does not work under AOT. Source-generated serialization does. Same for Regex (use source-generated regex via the [GeneratedRegex] attribute) and for logging (use source-generated logger methods).\nInvariant globalization. Native AOT binaries default to invariant mode unless you explicitly ship ICU data. For many workloads this is fine. For any application that formats dates, numbers, or currency according to the user\u0026rsquo;s locale, it is a constraint that has to be addressed.\nLonger build times. A Native AOT build can take 30 seconds to several minutes per publish, compared to under 10 seconds for a JIT build. For inner-loop development, JIT is still the target; AOT is for the release pipeline.\n❌ Never do this : Do not enable PublishAot on a mature codebase and run the publish as the first test. The build will fail in ways that are difficult to map back to the offending library. Instead, add AOT warnings at build time (\u0026lt;IsAotCompatible\u0026gt;true\u0026lt;/IsAotCompatible\u0026gt;), fix them iteratively, and only switch PublishAot on once the warnings are clean.\nZoom: ReadyToRun as the low-risk alternative #For teams that want startup improvement without AOT\u0026rsquo;s constraints, ReadyToRun is often the right answer. It requires one property in the csproj and no code changes:\n\u0026lt;PropertyGroup\u0026gt; \u0026lt;PublishReadyToRun\u0026gt;true\u0026lt;/PublishReadyToRun\u0026gt; \u0026lt;PublishReadyToRunComposite\u0026gt;true\u0026lt;/PublishReadyToRunComposite\u0026gt; \u0026lt;/PropertyGroup\u0026gt; The composite option bundles the framework and application into a single R2R image, improving warmup further. Startup improves by roughly 30%, size increases by 10 to 20% (because the binary now contains both IL and native code), and nothing else changes. The JIT still runs at runtime for tier-1 recompilation, which means steady-state performance is preserved.\nReadyToRun is the sensible first step for any team where startup is a noticed problem but where the codebase depends on reflection, dynamic proxies, or anything else that AOT would reject.\n⚠️ It works, but\u0026hellip; : R2R improves cold start on methods it pre-compiled, which is typically the framework and the hot paths declared in the application. It does not help for methods that are never marked cold-compilable, which is a category that grows as the codebase grows. For extreme startup targets (under 100 ms), Native AOT is still the answer.\nZoom: when AOT is the right call #Native AOT is the right choice for a specific set of workloads. Use it when at least two of the following are true:\nThe process is invoked frequently and short-lived. Lambdas, Azure Functions, serverless APIs, CLI tools, scheduled jobs. These pay the cold-start tax on every invocation. The container image size matters. Multi-tenant systems that run thousands of images, CI pipelines that pull images often, edge deployments with bandwidth constraints. The memory footprint per process is a cost driver. Running hundreds of small instances per host, where every 20 MB saved per process is worth the migration effort. The deployment target has no runtime installed. Bare Linux containers, minimal distroless images, embedded systems. The codebase is greenfield or small enough to migrate in a sprint. AOT migration is much easier for a 50-file minimal API than for a 500-file monolith with twenty years of reflection-based patterns. Do not use AOT when:\nThe workload is a long-running web server that stays up for days. JIT with PGO will outperform AOT on steady state, and startup time is amortized over the process lifetime. The codebase depends heavily on reflection, dynamic proxies, or runtime-generated code. Either migrate those dependencies first, or accept that AOT is not the right tool for this codebase. The team has no appetite for breaking changes during migration. AOT exposes warnings and errors that a JIT build tolerates silently, and each warning is real work. Zoom: measuring the gain #Any AOT adoption should be backed by before/after measurements, not hope. Three numbers matter:\nCold start time, measured from process launch to first successful request. A simple harness that spawns the process, polls a health endpoint, and records the elapsed time. Repeat ten times and report the median.\nPeak resident memory, measured during a steady 100 RPS run. Use dotnet-counters or ps -o rss and capture the max.\nBinary size, measured on the published output directory (du -sh on Linux, Get-ChildItem | Measure-Object -Property Length -Sum on Windows).\n# Before, JIT $ time curl -s http://localhost:5000/health real 0m0.480s # ~480 ms cold start $ du -sh ./publish 82M # After, Native AOT $ time curl -s http://localhost:5000/health real 0m0.052s # ~52 ms cold start $ du -sh ./publish 14M Numbers like these justify the migration. Numbers that show only a 10% improvement do not, because the constraints that AOT imposes have a long tail of downstream cost. The decision should be data-driven.\n✅ Good practice : Put the measurement harness in the repo as a dedicated script. When the runtime ships a new version, re-run the measurements. AOT is improving with every .NET release, and a codebase that was \u0026ldquo;not AOT-ready\u0026rdquo; in .NET 8 may well be ready in .NET 10 or 11.\nWrap-up #Native AOT is the right answer for a specific class of .NET workloads where cold start latency, binary size, and memory footprint dominate, and where the codebase can accept the constraints on reflection and dynamic code generation. You can enable it with \u0026lt;PublishAot\u0026gt;true\u0026lt;/PublishAot\u0026gt; and CreateSlimBuilder, adopt source generators for JSON, regex, and logging, fix AOT warnings iteratively before flipping the publish switch, and measure cold start, peak memory, and binary size before and after. You can fall back to ReadyToRun as a lower-risk intermediate, or stick with standard JIT when steady-state throughput is the actual metric that matters.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # Zero Allocation in .NET: When the GC Becomes the Bottleneck Load Testing for .NET: An Overview of the Four Types That Matter Spike Testing in .NET: Surviving the Sudden Burst References # Native AOT deployment, Microsoft Learn ASP.NET Core support for Native AOT, Microsoft Learn ReadyToRun compilation, Microsoft Learn System.Text.Json source generation, Microsoft Learn Trimming options, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/performance-aot-compilation/","section":"Posts","summary":"","title":"AOT Compilation in .NET: Startup, Size, and Trade-offs"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/api/","section":"Tags","summary":"","title":"Api"},{"content":"Between unit tests and full browser-driven end-to-end tests sits a very productive middle layer: tests that spin up your real ASP.NET Core pipeline (routing, model binding, middleware, filters, DI, authentication) in the same process as the test, and drive it through an in-memory HttpClient. No Kestrel, no socket, no browser. Just your app, running for real, in milliseconds.\nWebApplicationFactory\u0026lt;TEntryPoint\u0026gt; was introduced in ASP.NET Core 2.1 in 2018 as part of the Microsoft.AspNetCore.Mvc.Testing package. It replaced a decade of hand-rolled solutions (TestServer directly, custom host builders, startup tricks) with one clean primitive. With .NET 6\u0026rsquo;s minimal APIs and the top-level Program.cs, the story got even simpler. If you have read the previous article on integration testing with TestContainers, you already have the database story. This article covers the HTTP story on top of it.\nWhy this pattern exists #Picture a team whose API has 40 endpoints. They have great unit tests for handlers and repositories, but the bugs they keep finding in production are:\nA filter that accidentally skipped authorization on one route. A model binder that silently coerced a null enum to 0. A Problem Details response that changed shape after a middleware upgrade. A route collision between /orders/{id} and /orders/export. None of these bugs live in a single class. They live in the pipeline: the interaction between routing, filters, DI, and serialization. Unit tests cannot see them. Running the app in CI and curling it works but is slow and fragile. What the team actually needs:\nThe real pipeline, not a simulation, so routing and filters behave as they will in production. Fast startup, so a test run covers 200 endpoints in under a minute. Service override hooks, so one test can swap a dependency (the payment gateway, the clock) without touching production code. WebApplicationFactory gives you all three.\nOverview: how it plugs in # graph TD A[Test] --\u003e B[WebApplicationFactory\u0026lt;Program\u0026gt;] B --\u003e C[TestServerin-memory] C --\u003e D[Your Program.csDI, middleware, endpoints] D --\u003e E[HttpClient] A --\u003e E D --\u003e F[(Postgres from TestContainers)] The factory boots your Program.cs with a TestServer instead of Kestrel. The HttpClient it hands back talks to the pipeline directly, skipping the network. Everything you care about (routing, filters, auth, serialization) runs for real.\n💡 Info : WebApplicationFactory\u0026lt;TEntryPoint\u0026gt; uses a type argument that points at any type in your startup assembly. The convention is WebApplicationFactory\u0026lt;Program\u0026gt;. If you use top-level statements, you need to add public partial class Program { } at the bottom of Program.cs so the test project can reference the type.\nThe invisible HTTP pipeline you are actually testing #When an endpoint is declared as a minimal API or a controller action, ASP.NET Core does a surprising amount of work between \u0026ldquo;an HTTP request arrived\u0026rdquo; and \u0026ldquo;your handler runs with C# arguments\u0026rdquo;. Most of that work is invisible in the source code, which is exactly why bugs hide there. A real WebApplicationFactory test exercises all of it at once:\nRoute matching and constraints: {id:guid} rejects a non-GUID and returns 404 before the handler ever runs. Model binding from the query string: ?status=active\u0026amp;page=2 is parsed into typed parameters, including nullable types, enums, and arrays. Model binding from the body: the JSON body is deserialized into a DTO via System.Text.Json, applying any custom converters, naming policies, or numeric formats configured in JsonSerializerOptions. Header binding: [FromHeader] parameters, Accept negotiation, If-None-Match, Authorization all feed the pipeline. Form binding and file uploads: multipart/form-data is split into fields and IFormFile instances. Model validation: data annotations and IValidatableObject fire, and validation failures return a ValidationProblemDetails response without the handler being called. Content negotiation and output serialization: the C# return value is converted back to JSON, Problem Details, or any other registered formatter, with the right Content-Type and charset. Status code selection: Results.Ok(...), Results.NotFound(), TypedResults.NoContent(), and unhandled exceptions are translated into proper HTTP status codes. None of this is written in your endpoint file. All of it runs for real in a WebApplicationFactory test. When a test fails because a query string parameter did not bind, a JSON property was renamed by a naming policy, a date format changed, or a validator now rejects a previously-valid payload, the failure is telling you something the source code alone cannot: the contract between HTTP and C# has shifted. That is exactly the category of bugs unit tests cannot catch, and it is the reason WebApplicationFactory earns its place in the pyramid.\nZoom: the minimum test #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_returns_200_with_list() { 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(); } } Five lines of setup, the rest is an actual HTTP assertion. factory.CreateClient() returns an HttpClient pre-wired to the test server. No ports, no hostname, no real socket.\n✅ Good practice : Assert on status codes and response bodies, not on internal state. A good API test should be replaceable with a curl command that proves the same behavior. Internal coupling makes tests brittle.\nZoom: overriding services #The most valuable feature of WebApplicationFactory is ConfigureWebHost, where you can replace any service registered in production. Stripe payment gateway? Swap for a fake. System clock? Inject a 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)); } Then consume it in 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_charges_and_returns_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;() comes from Microsoft.Extensions.DependencyInjection.Extensions. It is the idiomatic way to override a registration instead of appending a second one.\n❌ Never do this : Do not use Mock.Setup(...) to fake behavior inside ConfigureServices. Mocks belong in unit tests. For integration tests, a small hand-written Fake* class is easier to read and survives refactors better.\nZoom: combining with TestContainers #The real power is combining WebApplicationFactory with a Postgres container. Your tests drive the real pipeline against the real database. This is where 80% of the bugs actually hide.\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(); } } One fixture. Real pipeline. Real database. Tests that POST a command, commit a row, and verify the GET endpoint returns it. If an EF Core mapping is wrong, a filter forgets to run, or a middleware mangles the response body, this kind of test catches it.\n✅ Good practice : Seed your test data through the API whenever possible, not by inserting rows into the database directly. Tests that do POST /orders then GET /orders/{id} prove the whole flow works end to end. Tests that bypass the API prove only the pieces you remembered to exercise.\nZoom: authentication in tests #Real APIs are protected. You have two clean options:\n1. Test authentication handler : register a fake scheme that authenticates every request as a test user.\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;; }); TestAuthHandler just builds a ClaimsPrincipal from a configured test user. Simple, fast, deterministic.\n2. Real JWT flow : have the test call your actual /token endpoint with a test account, grab the token, attach it to the HttpClient. Slower but proves the auth flow works too.\n⚠️ It works, but\u0026hellip; : Option 1 is fine for most tests, but keep at least one test per auth-protected route that goes through option 2. Otherwise, the day your real JWT setup breaks, no test will notice.\nWhen not to use it #WebApplicationFactory is effective, but it remains in-process. If your production system depends on behavior that only shows up with real network, multiple processes, or a real reverse proxy (sticky sessions, SignalR over WebSockets through Nginx, client certificate auth), you will need a full E2E setup on top. That is the topic of the next article.\nWrap-up #You now know how to drive your real ASP.NET Core pipeline from tests: create a WebApplicationFactory\u0026lt;Program\u0026gt;, use ConfigureWebHost to swap production dependencies for fakes, combine it with a Postgres container for full integration coverage, and add a test authentication handler so your protected routes are reachable. You can write tests that post a command and verify state via a second request, proving the whole pipeline from routing to persistence works end to end.\nReady to level up your next project or share it with your team? See you in the next one, End-to-End testing with Playwright is where we go next.\nRelated articles # Unit Testing in .NET: Fast, Focused, and Actually Useful Integration Testing with TestContainers for .NET References # Integration tests in ASP.NET Core, Microsoft Learn WebApplicationFactory\u0026lt;TEntryPoint\u0026gt;, API docs Test authentication in ASP.NET Core, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/testing-webapplicationfactory/","section":"Posts","summary":"","title":"API Testing with WebApplicationFactory in ASP.NET Core"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/architecture/","section":"Tags","summary":"","title":"Architecture"},{"content":"Every .NET codebase has rules that live only in README files, onboarding docs, or the tribal memory of the senior engineer. \u0026ldquo;Domain never references Infrastructure.\u0026rdquo; \u0026ldquo;Handlers end with Handler.\u0026rdquo; \u0026ldquo;No using System.Data; inside the Application layer.\u0026rdquo; The compiler does not check any of them. Six months later, someone adds a using Shop.Infrastructure; inside a domain class because IntelliSense suggested it, the build passes, and the rule silently dies.\nArchitecture tests turn these rules into executable assertions. They are unit tests over your assembly graph: \u0026ldquo;assert that no type in Shop.Domain depends on Shop.Infrastructure\u0026rdquo;, \u0026ldquo;assert that every handler ends with Handler\u0026rdquo;, \u0026ldquo;assert that every type in Application.Orders.Commands implements IRequest\u0026rdquo;. The first widely-used library was ArchUnit on the JVM (2017). The .NET port, NetArchTest by Ben Morris, landed in 2018, followed by ArchUnitNET in 2020 with a richer fluent API. Both are mature, open source, and work with any test runner.\nIf you have followed this series from unit testing through E2E with Playwright, you already have the correctness story covered. Architecture tests cover a different axis: structural drift over time.\nWhy this pattern exists #Picture a team that adopted Clean Architecture eighteen months ago. On day one, the rules were written on a whiteboard and everyone knew them. By month three, a new joiner added [Table(\u0026quot;orders\u0026quot;)] to a domain entity because \u0026ldquo;it was quicker\u0026rdquo;. By month six, a shortcut during an incident added a using Microsoft.EntityFrameworkCore; inside Domain/Orders/Order.cs. By month twelve, the \u0026ldquo;Clean Architecture\u0026rdquo; project looks Clean on the diagram and is layered spaghetti in the code.\nNone of these changes caused a bug on the day they were merged. They caused slow decay. What the team actually needs:\nExecutable invariants: a test that fails the build when a rule is broken, not a line in a review checklist. One source of truth: the rule lives in code, next to the tests, readable by every engineer. Low ceremony: writing a new rule takes five minutes, not a sprint of yak-shaving. NetArchTest and ArchUnitNET both deliver.\nOverview: what you can enforce #Architecture tests cover three broad categories:\ngraph TD A[Architecture tests] --\u003e B[Dependency ruleswho can reference whom] A --\u003e C[Naming rulessuffixes, prefixes, namespaces] A --\u003e D[Structural rulessealed, public, abstract, interfaces] Dependency rules are the most valuable: they protect the shape of your application. Naming and structural rules are cheaper but add up to real consistency across a large codebase.\n💡 Info : Architecture tests run as regular xUnit / NUnit tests. No extra tooling, no SonarQube plugin, no custom Roslyn analyzer unless you want one. The assertions execute in milliseconds against your compiled assemblies.\nZoom: dependency rules with NetArchTest #NetArchTest has a fluent API that reads like prose. The classic \u0026ldquo;Domain references nothing\u0026rdquo; rule:\nusing NetArchTest.Rules; using Xunit; public class DomainDependencyTests { [Fact] public void Domain_should_not_depend_on_Application_Infrastructure_or_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 must be independent. Offending types: \u0026#34; + string.Join(\u0026#34;, \u0026#34;, result.FailingTypeNames ?? Array.Empty\u0026lt;string\u0026gt;())); } [Fact] public void Domain_should_not_depend_on_EntityFramework() { var result = Types.InAssembly(typeof(Shop.Domain.Orders.Order).Assembly) .ShouldNot() .HaveDependencyOn(\u0026#34;Microsoft.EntityFrameworkCore\u0026#34;) .GetResult(); result.IsSuccessful.Should().BeTrue(); } } The assertion message includes the failing type names when it fires, so the developer who broke the rule knows exactly which file to open.\n✅ Good practice : Add one dependency test per layer, not one giant test that checks everything. When a failure appears, the test name tells you which boundary was crossed.\nZoom: naming and structural rules #Consistent naming is mostly a code review job, but architecture tests catch the drift:\n[Fact] public void Every_IRequestHandler_should_be_named_with_Handler_suffix() { 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 Every_command_should_be_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(); } These rules are cheap, they catch real drift, and they educate new joiners faster than any style guide.\n⚠️ It works, but\u0026hellip; : Do not over-constrain. If you write 200 naming tests, every refactor becomes a negotiation. Pick the ten rules that genuinely matter to your team and skip the rest. Taste is part of the job.\nZoom: ArchUnitNET for richer assertions #For more complex rules, ArchUnitNET has a more expressive API:\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_should_only_be_used_by_the_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 is the kind of rule that is awkward to express by hand and trivial with ArchUnitNET. The rule says \u0026ldquo;handlers are a private implementation detail, only MediatR should reach them\u0026rdquo;, which is exactly how you want a CQRS codebase to behave.\n💡 Info : ArchUnitNET loads the assembly once into an in-memory model, so every rule runs against the same graph. For a codebase with 50 architecture tests, it is noticeably faster than NetArchTest, which re-walks types per assertion.\nZoom: what to enforce first #Start with the three rules that protect the most value:\n1. Dependency direction (Domain → nothing, Application → Domain, Infrastructure → Application + Domain). This is the only rule that stops a Clean Architecture project from collapsing into layered spaghetti.\n2. No framework leakage into the domain. Domain should not reference Microsoft.EntityFrameworkCore, Microsoft.AspNetCore.*, System.Data.*, Stripe.*. A single test with a list of forbidden namespaces.\n3. Naming of shared vocabulary. If your team says \u0026ldquo;commands end with Command\u0026rdquo;, \u0026ldquo;queries end with Query\u0026rdquo;, \u0026ldquo;handlers end with Handler\u0026rdquo;, enforce it. When the words in the code match the words in the meetings, onboarding accelerates measurably.\nEverything else is bonus. Add rules when a real incident taught you the lesson, not preemptively.\n✅ Good practice : Put architecture tests in a dedicated project, Shop.ArchitectureTests, that references every other assembly. They run in CI alongside unit tests and fail the build on any violation.\n❌ Never do this : Do not Skip an architecture test when someone breaks the rule \u0026ldquo;to unblock a release\u0026rdquo;. A skipped architecture test is a rule that no longer exists. Either fix the code, or delete the test and openly acknowledge that the rule is gone.\nWhen architecture tests are the wrong tool #Architecture tests work well for rules about assembly graphs, type names, and type structure. They are not the right tool for:\nLogical correctness: \u0026ldquo;the discount is 15% above 50 items\u0026rdquo;. That is a unit test. Runtime behavior: \u0026ldquo;the handler commits the transaction\u0026rdquo;. That is an integration test. Performance: \u0026ldquo;this query runs in under 100ms\u0026rdquo;. That is a benchmark or a load test. Security: \u0026ldquo;this endpoint requires the admin role\u0026rdquo;. That is a WebApplicationFactory test. If you catch yourself writing Types.InAssembly(...).Should().HaveMethodBody(\u0026quot;...\u0026quot;), stop and write a real test instead.\nWrap-up #You now know how to turn architectural decisions into executable invariants: pick NetArchTest for simple fluent rules or ArchUnitNET for richer graph assertions, start with dependency direction and framework leakage tests, add naming rules that mirror your team\u0026rsquo;s shared vocabulary, put them all in a dedicated architecture test project, and refuse to skip them under pressure. You can make your codebase resist the slow structural drift that kills every long-lived project.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # Clean Architecture in .NET: Dependencies Pointing the Right Way Unit Testing in .NET: Fast, Focused, and Actually Useful Integration Testing with TestContainers for .NET API Testing with WebApplicationFactory in ASP.NET Core End-to-End Testing with Playwright for .NET References # NetArchTest on GitHub ArchUnitNET on GitHub ArchUnit (Java original) Clean Architecture, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/testing-architecture-testing/","section":"Posts","summary":"","title":"Architecture Testing in .NET: Rules the Compiler Cannot Enforce"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/azure/","section":"Tags","summary":"","title":"Azure"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/baseline/","section":"Tags","summary":"","title":"Baseline"},{"content":"The first load test a team should run is almost never the most impressive one. It is the boring one: the system under the traffic it is expected to handle every day, for long enough to produce stable numbers, and nothing more. That is the baseline. Without it, every other load test is meaningless, because there is no reference to compare against. A p95 of 300 ms means nothing unless you know whether it is better or worse than last week.\nThe overview article in this series covered why load testing exists and what metrics matter. This article zooms into the first of the four test types and explains how to actually set up, run, and use a baseline in a .NET project.\nWhy baseline tests exist #A team ships a feature. The feature involves an EF Core query that looks innocent. The next deployment goes live. Two weeks later, a customer complains that the dashboard is slow. The team checks Grafana, sees that latency is indeed higher than usual, and asks the only question that matters: \u0026ldquo;higher than what, exactly?\u0026rdquo;. Without a baseline, the answer is \u0026ldquo;higher than my memory of what it felt like last month\u0026rdquo;, which is not an answer.\nBaseline tests solve four concrete problems:\nThey establish a reference. A stable number recorded under a known traffic profile, saved with the commit hash and the deployment date. Every subsequent run can be compared against it. They catch regressions before production notices. A pull request that doubles the database round trips of the checkout flow will fail its baseline comparison in CI, not at 2 AM on Monday. They validate the sizing assumptions. If the baseline p95 is close to the SLO at expected traffic, production has no margin, and the team knows it before the incident. They anchor every other load test. Soak, stress, and spike tests are always relative to the baseline. Without it, \u0026ldquo;the system degraded under stress\u0026rdquo; is a sentence with no denominator. Overview: the shape of a baseline run # graph LR A[Fresh pre-prodenvironment] --\u003e B[Warmup1-2 min] B --\u003e C[Steady-state5-10 min atexpected RPS] C --\u003e D[Metrics capture] D --\u003e E[Store referencewith commit hash] E --\u003e F[Compare withprevious baseline] A baseline run has four phases. Warmup exists because JIT compilation, cache population, and connection pool priming all distort the first minute of any .NET test. Steady-state is where the numbers are actually captured, long enough to average out noise from GC and background jobs. Capture produces a structured artifact, not just a log dump. Storage and comparison is the part most teams skip and regret.\nThe traffic profile during steady-state should mirror production as closely as possible. If production does 70% reads, 20% writes, and 10% search, the baseline does the same. A baseline that hits only GET /orders is not a baseline, it is a microbenchmark with delusions.\nZoom: a realistic baseline with 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 }, // warmup ramp { duration: \u0026#39;10m\u0026#39;, target: 50 }, // steady state { 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% error rate \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% read path 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% write path: full checkout flow 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% search path if (Math.random() \u0026lt; 0.1) { group(\u0026#39;search\u0026#39;, () =\u0026gt; { http.get(`${BASE}/api/search?q=jean`); }); } sleep(1); } Three traffic paths, weighted to match production. A warmup ramp, a 10-minute steady state, and a cooldown. Thresholds that fail the run in CI if any of them break. Custom metrics that track the business transaction (the full checkout flow), not only the individual endpoints. This is what a serious baseline looks like.\n✅ Good practice : Tag requests with group() so k6 reports metrics per path. A global p95 that mixes reads and writes is almost always useless. Per-group p95 tells you where the latency lives.\nZoom: the same baseline with NBomber #For teams that prefer to keep everything in 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(); Same idea, expressed in C#. The WithWeight option lets NBomber distribute virtual users across scenarios in the expected ratio. Reports land in ./reports/baseline/ and can be committed, archived, or pushed to a storage bucket for historical comparison.\nZoom: what to capture, and where to store it #A baseline is not useful as a pile of CSV files. It is useful as a structured record that can be diffed. At minimum, every baseline run should store:\nCommit hash and branch of the application under test Deployment timestamp and the environment identifier k6 or NBomber version and the scenario source file hash Per-group metrics: p50, p95, p99, p99.9 latency; RPS; error rate by status code Runtime signals: CPU, memory, GC pause times, thread pool queue length, database pool usage Pass / fail status against the configured thresholds A simple convention that works well: write a JSON summary to an S3 / blob bucket after each run, keyed by \u0026lt;env\u0026gt;/\u0026lt;yyyy-mm-dd\u0026gt;/\u0026lt;commit-hash\u0026gt;.json. A later job diffs the most recent run against the previous one and posts the delta as a comment on the pull request. This turns baseline testing into a living regression signal instead of a one-off exercise.\n💡 Info : k6 supports pushing results directly to Prometheus (k6 run --out experimental-prometheus-rw) and to Grafana Cloud. NBomber writes HTML, CSV, and Markdown reports natively and can plug into InfluxDB. Either path is enough to build the historical comparison.\nZoom: baseline against what, exactly #A question worth asking explicitly: what traffic level does \u0026ldquo;baseline\u0026rdquo; mean for your system? Three common definitions, each valid in context:\nAverage daily peak. The busiest hour of a typical weekday. Safest starting point for most teams, because it matches what the system actually handles on a normal day. Weekly peak. The traffic at the busiest hour of the busiest day of the week. Useful for systems with predictable weekly patterns (e.g., Monday morning dashboards, Friday evening e-commerce). Target SLO load. The traffic level the system is contracted to sustain, regardless of whether current production reaches it. Used when the SLO is above current real traffic and the team needs to prove the headroom exists. Pick one, write it down, and stick to it. Moving the baseline target silently between runs is how teams accidentally ship \u0026ldquo;improvements\u0026rdquo; that only look like improvements because the comparison shifted underneath them.\n❌ Never do this : Do not record the baseline from a cold system on a quiet Sunday morning and compare it against a test run on a warm system under normal load. The two are not comparable. Warmup matters, steady state matters, consistency of the reference environment matters. A baseline that moves every run is not a baseline.\nZoom: when to run it #Three cadences cover most teams:\nNightly, in CI. A scheduled job runs the baseline against pre-prod every night, stores the result, and notifies on regression. This is the highest-value automation most teams can add.\nBefore every significant release. Even with nightly runs, a dedicated pre-release run catches the issues that show up on the specific code path of the upcoming version.\nOn demand, before merging a performance-sensitive PR. Teams that practice this have a dotnet run --project LoadTests.Baseline or a k6 run baseline.js target that a developer can trigger locally against a shared pre-prod, before asking for review.\n✅ Good practice : Store the baseline reference artifact alongside release notes. When a customer reports \u0026ldquo;it used to be faster\u0026rdquo;, the team can pull the baseline from the last known good release and prove, or disprove, the claim with data.\nWrap-up #A baseline test is the cheapest load test and the one that pays off fastest. Running it gives the team a reference point against which every subsequent change, deployment, and soak / stress / spike test can be compared. You can set one up in k6 or NBomber in an afternoon, tag the traffic by business path so per-group metrics reflect real user flows, store structured artifacts with commit hashes, and schedule nightly runs against pre-prod to catch regression before production does.\nReady to level up your next project or share it with your team? See you in the next one, Soak Testing is where we go next.\nRelated articles # Load Testing for .NET: An Overview of the Four Types That Matter Integration Testing with TestContainers for .NET API Testing with WebApplicationFactory in ASP.NET Core References # k6 documentation k6 thresholds and metrics NBomber documentation ASP.NET Core metrics, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/load-testing-baseline/","section":"Posts","summary":"","title":"Baseline Load Testing in .NET: Knowing What Normal Looks Like"},{"content":"Clean Architecture has a branding problem. Many .NET codebases that advertise it on the README are closer to N-Layered with renamed folders, and others grow enough interfaces, mappers, and DTO hops that a simple product listing becomes a multi-file expedition. Both outcomes are understandable: the pattern is often introduced without its original framing, and teams fill the gap with ceremony. The actual idea, the one Robert Martin formalized in 2012 (building on earlier work like Alistair Cockburn\u0026rsquo;s Hexagonal Architecture from 2005 and Jeffrey Palermo\u0026rsquo;s Onion Architecture from 2008), is much smaller and much more useful: your business rules should not depend on your framework, your database, or your HTTP stack. Everything else is implementation detail.\nIf you have read the previous articles in this series, you already know the two patterns that came before: N-Layered Architecture with its physically separated projects, and UI / Repositories / Services with its pragmatic single-project layering. Clean Architecture is what you reach for when those patterns start to leak and you need the compiler to hold the line between your domain and the outside world.\nWhy this pattern exists #Picture a mature .NET codebase. The team has been shipping for three years. EF Core queries live inside service classes. Controllers build domain objects by hand from request bodies. A UserService references HttpContext to read the current user. A domain rule about discount eligibility lives half in a stored procedure, half in an if inside a controller, and half in a JavaScript file on the frontend. When a new payment provider shows up, nobody can change the OrderService without breaking two features, because the business logic is tangled with Stripe-specific calls.\nThis is the pain Clean Architecture fixes. Its contract:\nYour domain model knows nothing about EF Core, ASP.NET, MediatR, or Stripe. Your application layer orchestrates use cases using only abstractions. Infrastructure plugs in from the outside and can be swapped without touching the domain. The payoff is not theoretical. It is the ability to upgrade EF Core, change message brokers, or replace a payment provider without opening your domain project. It is also the ability to write fast unit tests against your business rules without spinning up a database.\nOverview: the layers and the rule #Before the code, here are the bricks of Clean Architecture as we will use them in .NET:\ngraph TD A[Api / PresentationControllers, Minimal APIs, SignalR] --\u003e B[ApplicationUse cases, commands, queries, ports] B --\u003e C[DomainEntities, value objects, domain services, invariants] D[InfrastructureEF Core, HTTP clients, file system, message bus] --\u003e B D --\u003e C A --\u003e D The arrows are the only thing that matters. Everything points toward Domain. Domain depends on nothing. Application depends only on Domain. Infrastructure implements interfaces declared in Application (or Domain). The Api project wires everything up at startup. If you get the arrows right, you have Clean Architecture. If you do not, you have four projects that share the cost of the split without sharing the benefit.\n💡 Info : The original diagram has four concentric circles (Entities, Use Cases, Interface Adapters, Frameworks). In practice, most .NET teams collapse this to four csproj files: Domain, Application, Infrastructure, Api. That mapping is good enough and I will use it throughout this article.\nZoom: Domain, the heart #The Domain project holds your business concepts and their invariants. It references nothing. Not EF Core, not MediatR, not Microsoft.Extensions.*. Just netstandard2.1 or net10.0 and your own 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;Cannot modify a submitted order.\u0026#34;); if (quantity \u0026lt;= 0) throw new DomainException(\u0026#34;Quantity must be positive.\u0026#34;); _lines.Add(new OrderLine(productId, quantity, unitPrice)); } public void Submit() { if (_lines.Count == 0) throw new DomainException(\u0026#34;An empty order cannot be submitted.\u0026#34;); Status = OrderStatus.Submitted; } } Notice what is not there: no [Table] attribute, no DbContext, no virtual keyword for lazy loading, no navigation property that assumes EF Core. The entity enforces its own invariants. Breaking a rule throws a domain exception, not an HTTP 400.\n✅ Good practice : Make constructors private or internal and expose factory methods (Order.Create(...)). This forces all callers through your invariant checks. There is no way to get a broken Order from the outside.\n❌ Never do this : Do not put [Column] or [Required] attributes on domain entities to \u0026ldquo;save time\u0026rdquo;. The moment you do, your Domain project gains a hard dependency on an ORM, and the invariant that Domain references nothing stops holding. The fluent API inside Infrastructure gives you the same mapping without leaking the framework into your entities.\nZoom: Application, the use cases #The Application layer describes what the system does, expressed as use cases. This is where commands and queries live, where transactions are coordinated, and where you declare the ports (interfaces) that Infrastructure will plug into. If the command/query split is new to you, the dedicated article Application Layer in .NET: CQS and CQRS Without the Hype covers the pattern in depth, this section just assumes the shape.\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;Order {cmd.OrderId} not found.\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(); } } The interfaces IOrderRepository, IUnitOfWork, and IPaymentGateway live in the Application project, right next to the use case that needs them. They describe what the application needs, not how it is implemented.\n// Application/Abstractions/IOrderRepository.cs public interface IOrderRepository { Task\u0026lt;Order?\u0026gt; GetByIdAsync(OrderId id, CancellationToken ct); Task AddAsync(Order order, CancellationToken ct); } 💡 Info : This is the Dependency Inversion Principle made physical. The high-level policy (Application) owns the abstraction. The low-level detail (Infrastructure) implements it. The arrow points from Infrastructure to Application, not the other way around.\n⚠️ It works, but\u0026hellip; : You will see teams put all their interfaces in a separate Application.Contracts project \u0026ldquo;for reuse\u0026rdquo;. Ninety percent of the time that project is imported only by Infrastructure and adds zero value. Keep interfaces with their use cases until you have a real second consumer.\nZoom: Infrastructure, the plugs #Infrastructure is where EF Core, HTTP clients, file writers, and message bus code finally appear. It references Application (to implement the ports) and Domain (to map to entities). Nothing in Application or Domain references 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); } The EF Core configuration that knows about tables and columns also lives here, not on the entity:\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;); }); } } ✅ Good practice : Mark your Infrastructure implementations internal. The only way the outside world should get an IOrderRepository is through DI. If a controller can new OrderRepository(...), something is wrong.\nZoom: Api, the composition root #The Api project is where everything gets wired. It references Application and Infrastructure, registers the services, and exposes endpoints. It stays thin: parse, delegate, map the result.\n// Api/Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddApplication(); // MediatR, validators, pipeline behaviors builder.Services.AddInfrastructure( // DbContext, repositories, HTTP clients 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(); }); } } No business logic here. The endpoint parses the route, dispatches the command, and translates the result. If you need a different transport tomorrow, a gRPC service or a background worker, you write a new composition root and reuse Application and Domain untouched.\n💡 Info : AddApplication and AddInfrastructure are extension methods that live in their respective projects. That keeps each layer in charge of its own registrations, and the Api project does not need to know what a DbContext is.\nThe rule the compiler must enforce #The point of splitting into four projects is that the compiler checks the arrows for you. Your csproj graph should look like this:\nApi -\u0026gt; Application, Infrastructure Infrastructure-\u0026gt; Application, Domain Application -\u0026gt; Domain Domain -\u0026gt; (nothing) If Domain gains a reference to anything, you have a bug in the pattern. A good practice is to add an architecture test so the rule becomes executable:\n[Fact] public void Domain_should_not_depend_on_any_other_project() { 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(); } ✅ Good practice : Add one architecture test per layer. It takes five minutes with NetArchTest or ArchUnitNET and it catches the accidental using Shop.Infrastructure; before it quietly becomes load-bearing.\nWhen Clean Architecture is the wrong call #Clean Architecture is overhead. Four projects, a dependency injection dance, handlers, ports, mappers. For a thirty-endpoint CRUD app where the \u0026ldquo;business rules\u0026rdquo; are \u0026ldquo;save this and return it\u0026rdquo;, the structure costs more than it buys. In those cases, UI / Repositories / Services is honestly better and shipping it will make you faster.\nReach for Clean Architecture when at least two of the following are true:\nYou have real business rules, not just CRUD. Invariants, state machines, calculations, cross-aggregate consistency. The app has to survive multiple frameworks, storage engines, or transports over its lifetime. Multiple teams or developers need enforced boundaries. You are going to write a non-trivial amount of unit tests against the domain and you do not want to drag a database into them. If none of these apply, you are paying the tax without getting the benefit.\nWrap-up #You now understand what Clean Architecture really is: one rule about dependency direction, enforced by project references and occasionally by architecture tests. You can set up the four projects, put your invariants in the Domain, keep your use cases in Application, plug Infrastructure from the outside, and keep the Api as a thin composition root. You can also tell when your codebase does not need this level of structure and pick a lighter option.\nReady to level up your next project or share it with your team? See you in the next one, Vertical Slicing is where we go next.\nRelated articles # N-Layered Architecture in .NET: The Foundation You Need to Master UI / Repositories / Services: The Pragmatic .NET Layering References # Common web application architectures, Microsoft Learn Ports and Adapters pattern, Microsoft Learn Dependency injection in ASP.NET Core, Microsoft Learn Entity Framework Core fluent configuration, Microsoft Learn NetArchTest on GitHub ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/code-structure-clean-architecture/","section":"Posts","summary":"","title":"Clean Architecture in .NET: Dependencies Pointing the Right Way"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/clean-architecture/","section":"Tags","summary":"","title":"Clean-Architecture"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/clean-code/","section":"Tags","summary":"","title":"Clean-Code"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/containers/","section":"Tags","summary":"","title":"Containers"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/e2e/","section":"Tags","summary":"","title":"E2e"},{"content":"End-to-end tests have acquired a difficult reputation over the years, and with good reason. Years of flaky Selenium suites, implicit waits that never quite wait long enough, XPath selectors that break every UI refresh, and CI runs that fail \u0026ldquo;sometimes\u0026rdquo; convinced many teams that E2E was not worth it. They were right about Selenium. They were wrong about E2E.\nPlaywright changed the equation. Microsoft released it in 2020 as a modern successor to Puppeteer, and the .NET bindings followed in early 2021. It bundles Chromium, Firefox, and WebKit, auto-waits for elements to be actionable, isolates each test in a fresh browser context, and ships with a code generator that records your actions into a test file. If you have read the previous articles on unit testing, integration testing with TestContainers, and API testing with WebApplicationFactory, you already have the fast, cheap, in-process layers. Playwright is the top of the pyramid: slower, but the level at which you can verify the application behaves as a user will experience it.\nWhy this pattern exists #Picture a team that ships a checkout flow. Unit tests are green. API tests are green. A manual QA pass reveals that the \u0026ldquo;Pay\u0026rdquo; button is disabled when the form has validation errors, so users never see the error message the API returns. A second pass reveals a race condition where the cart total updates after the submit handler fires, so users pay the old price. Neither bug is catchable at any layer below the browser. Both ship.\nWhat the team actually needs:\nA real browser rendering the real page, so DOM events, CSS, and JavaScript all behave exactly as a user sees them. Reliable selectors that survive minor UI changes, so one div reorder does not break 40 tests. Fast parallel execution and isolation, so the suite runs in minutes, not hours, and one flaky test does not poison the rest. Playwright delivers all three.\nOverview: the pieces # graph TD A[Playwright test] --\u003e B[Microsoft.Playwright.NUnitor MSTest / xUnit wrapper] B --\u003e C[BrowserChromium / Firefox / WebKit] C --\u003e D[Your running appKestrel on localhost:5000] D --\u003e E[(Postgres from TestContainers)] A --\u003e F[Page ObjectCheckoutPage] F --\u003e C The test drives a real browser. The browser talks to your running app, which in turn talks to a real database. The Page Object pattern keeps selectors in one place so UI refactors touch one file, not the whole suite.\n💡 Info : Playwright for .NET ships with its own test runner via the Microsoft.Playwright.NUnit / Microsoft.Playwright.MSTest packages. These give you parallel execution, fresh browser contexts per test, and trace recording out of the box. You can also use raw PlaywrightSharp inside xUnit, but the NUnit adapter is more mature.\nZoom: installing and the first test #Install the package and the browsers in one step:\ndotnet add package Microsoft.Playwright.NUnit dotnet build pwsh bin/Debug/net10.0/playwright.ps1 install The install script downloads Chromium, Firefox, and WebKit (roughly 500 MB). Commit the invocation to your CI script so agents get the browsers on first run.\nusing Microsoft.Playwright; using Microsoft.Playwright.NUnit; using NUnit.Framework; [Parallelizable(ParallelScope.Self)] public class CheckoutTests : PageTest { [Test] public async Task User_can_submit_an_order_from_the_cart() { await Page.GotoAsync(\u0026#34;http://localhost:5000\u0026#34;); await Page.GetByRole(AriaRole.Link, new() { Name = \u0026#34;Catalog\u0026#34; }).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Add to cart\u0026#34; }).First.ClickAsync(); await Page.GetByRole(AriaRole.Link, new() { Name = \u0026#34;Cart\u0026#34; }).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Checkout\u0026#34; }).ClickAsync(); await Expect(Page.GetByText(\u0026#34;Order confirmed\u0026#34;)).ToBeVisibleAsync(); } } PageTest gives you a fresh Page per test and auto-disposes everything at the end. No boilerplate.\n✅ Good practice : Prefer GetByRole, GetByLabel, GetByPlaceholder, and GetByText over CSS or XPath selectors. They match how users and assistive tech perceive the page, and they survive CSS class renames.\nZoom: locators and auto-wait #Playwright\u0026rsquo;s most distinctive feature is auto-wait. Every action (ClickAsync, FillAsync) waits for the element to be visible, enabled, and stable before acting. Every assertion (ToBeVisibleAsync, ToHaveTextAsync) retries until the condition holds or a timeout expires. You almost never write explicit waits.\n// Playwright waits for the button to exist, be enabled, and be visible. await Page.GetByRole(AriaRole.Button, new() { Name = \u0026#34;Checkout\u0026#34; }).ClickAsync(); // Playwright retries the assertion for up to 5 seconds by default. await Expect(Page.GetByTestId(\u0026#34;order-total\u0026#34;)).ToHaveTextAsync(\u0026#34;€199.98\u0026#34;); Compare this to the Selenium world where you wrote Thread.Sleep(2000) because the element loaded from an API. Those sleeps are gone.\n❌ Never do this : Do not add Task.Delay in Playwright tests. If a test fails intermittently, the answer is almost always a better locator (use getByTestId on a stable attribute) or a better assertion (let Playwright retry), not a longer sleep.\nZoom: the Page Object pattern #Keep selectors out of tests. One class per page or component, reused across 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;Checkout\u0026#34; }); public ILocator OrderTotal =\u0026gt; _page.GetByTestId(\u0026#34;order-total\u0026#34;); public ILocator Confirmation =\u0026gt; _page.GetByText(\u0026#34;Order confirmed\u0026#34;); public Task GotoAsync() =\u0026gt; _page.GotoAsync(\u0026#34;/cart\u0026#34;); public Task SubmitAsync() =\u0026gt; CheckoutButton.ClickAsync(); } [Test] public async Task Checkout_shows_confirmation() { var checkout = new CheckoutPage(Page); await checkout.GotoAsync(); await checkout.SubmitAsync(); await Expect(checkout.Confirmation).ToBeVisibleAsync(); } When the \u0026ldquo;Checkout\u0026rdquo; button becomes \u0026ldquo;Place order\u0026rdquo; next quarter, you change one line in CheckoutPage.cs and 40 tests keep passing.\n💡 Info : Add data-testid attributes in your Razor or React components for things that have no natural accessible name. GetByTestId(\u0026quot;cart-line-1-qty\u0026quot;) is stable and survives most UI refactors.\nZoom: hosting the app under test #You have two options for where \u0026ldquo;the app\u0026rdquo; runs during the test:\n1. Start it in-process : use WebApplicationFactory (covered in the previous article) to boot the app on a real Kestrel port inside the test process.\npublic sealed class AppFixture : IDisposable { private readonly WebApplication _app; public string BaseUrl { get; } public AppFixture() { var builder = WebApplication.CreateBuilder(); // ... same services as Program.cs ... _app = builder.Build(); _app.Urls.Add(\u0026#34;http://127.0.0.1:0\u0026#34;); // random free port _app.StartAsync().GetAwaiter().GetResult(); BaseUrl = _app.Urls.First(); } public void Dispose() =\u0026gt; _app.StopAsync().GetAwaiter().GetResult(); } Pros: no external dependency, test owns the lifecycle. Cons: you need the real Kestrel, not the in-memory TestServer, because Playwright drives a real browser that needs a real socket.\n2. Run it as a separate process : a CI job starts dotnet run in the background, waits for the health endpoint, then runs the Playwright suite. More realistic, closer to prod, but more moving parts.\nOption 1 is the sweet spot for most teams.\n⚠️ It works, but\u0026hellip; : Pairing Playwright with TestContainers for the database works, and it is the most representative E2E setup available without a full staging environment. The trade-off is startup time: a cold run (pulling Postgres and browser binaries) can take 30 seconds. A warm run is fast.\nZoom: traces, videos, and debugging #When a test fails in CI, Playwright\u0026rsquo;s trace viewer is invaluable. Enable it for failing tests only:\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 the traces/ folder as a CI artifact. Open the zip with pwsh bin/Debug/net10.0/playwright.ps1 show-trace trace.zip and you see the exact DOM snapshot at each action, with network calls and console logs.\n✅ Good practice : Keep the E2E suite small and high-value. Ten tests that cover the critical flows (sign up, add to cart, checkout, view order history, cancel) are worth a hundred tests that click around low-value pages. Flakiness scales with suite size.\nWhen not to use Playwright #E2E is the slowest, most expensive layer of your test pyramid. Use it for:\nUser-visible critical paths that must not break. Flows that touch multiple systems (auth + API + database + UI). Regression after a big refactor of the frontend or the deployment topology. Do not use it for:\nBusiness rules: those belong in unit tests. API contracts: those belong in WebApplicationFactory tests. Database behavior: those belong in TestContainers integration tests. Follow the pyramid: lots of unit tests, fewer integration tests, very few E2E tests. The ratio keeps the suite fast and trustworthy.\nWrap-up #You now know how to write end-to-end tests that actually survive: install Playwright and its browsers, use role and label-based locators so your tests match how users and accessibility tools see the page, lean on auto-wait instead of manual sleeps, organize selectors through the Page Object pattern, host the app under test with WebApplicationFactory on a real Kestrel port, and capture traces for failed runs in CI. You can keep the suite tight, focused on critical paths, and run it in minutes rather than hours.\nReady to level up your next project or share it with your team? See you in the next one, Architecture Testing is where we go next.\nRelated articles # Unit Testing in .NET: Fast, Focused, and Actually Useful Integration Testing with TestContainers for .NET API Testing with WebApplicationFactory in ASP.NET Core References # Playwright for .NET, official docs Playwright locators guide Playwright auto-waiting Playwright trace viewer ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/testing-e2e-playwright/","section":"Posts","summary":"","title":"End-to-End Testing with Playwright for .NET"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/hosting/","section":"Categories","summary":"","title":"Hosting"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/hosting/","section":"Tags","summary":"","title":"Hosting"},{"content":"Between hosting a single container on a VM and running a full Kubernetes cluster, there is a gap that teams kept falling into. They wanted Kubernetes\u0026rsquo;s guarantees (rolling updates, autoscaling, declarative config, workload isolation) without the operational weight (upgrading the cluster, maintaining an Ingress Controller, debugging CNI plugins, rotating certificates). The serverless container platforms were the answer, and Azure\u0026rsquo;s version, Azure Container Apps (ACA), reached general availability in May 2022. It is now a first-class target for ASP.NET Core workloads that live in the Azure ecosystem.\nThis article covers what ACA actually is under the hood, how to deploy an ASP.NET Core image to it, and when it is the right choice compared to plain Docker, Kubernetes, or the next-article-in-the-series Azure Web App.\nWhy Azure Container Apps #Azure Container Apps is a managed container hosting platform built on top of open-source components you already know: Kubernetes for orchestration, KEDA for autoscaling, Envoy for ingress, Dapr for service-to-service communication. Microsoft operates the Kubernetes layer for you, exposes a simplified API surface, and bills per second of usage. The result is a platform that gives you 80% of Kubernetes\u0026rsquo;s capabilities with about 20% of the operational cost.\nThe specific advantages that matter for a .NET team:\nScale to zero. An idle application consumes no resources and costs nothing. When the first request arrives, ACA wakes a new instance in a few seconds. Combined with Native AOT, cold start becomes genuinely fast. Event-driven autoscaling via KEDA. Scale by HTTP request count, queue depth on Azure Service Bus or Storage Queues, Kafka lag, custom Prometheus metrics, any of the 60+ KEDA scalers. Not just CPU. No cluster to manage. No kubectl, no node pools, no version upgrades, no Ingress Controller to maintain. Azure handles all of it. Revisions and traffic splitting. Every deployment creates a new revision. You can split traffic between revisions (80/20, canary, blue/green) with a single API call, and roll back by reassigning traffic to the previous revision. No rolling update orchestration to write. Dapr integration, optional. If you want service-to-service calls, state management, pub/sub, or secret stores abstracted from their underlying provider, Dapr is available with a flag in the container app definition. You do not have to use it, but it is there if the shape fits. Overview: the ACA hierarchy # graph TD A[Azure subscription] --\u003e B[Container Apps Environmentshared network, 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% traffic] C --\u003e C2[Revision v1.4.7100% traffic] C2 --\u003e C2P[Replica 1] C2 --\u003e C2Q[Replica 2] The hierarchy is three levels deep and worth understanding before touching any YAML or az commands.\nContainer Apps Environment is the isolation boundary. It corresponds roughly to a Kubernetes namespace with its own virtual network, its own Log Analytics workspace, and its own ingress domain. Apps inside the same environment can talk to each other over the internal network; apps in different environments cannot. A typical setup has one environment per stage (dev, staging, prod) or one per business domain.\nContainer App is the application itself. It has a name, an image reference, environment variables, secret references, ingress configuration, and scaling rules. Think of it as the equivalent of a Kubernetes Deployment plus Service plus Ingress combined into a single resource.\nRevision is an immutable snapshot of the Container App\u0026rsquo;s configuration. Every change to the image or any configuration marked as \u0026ldquo;revision-scoped\u0026rdquo; creates a new revision. Traffic between revisions can be split explicitly, which is the mechanism for canary and blue/green deployments.\nReplica is a running container. ACA decides how many replicas each active revision needs based on the scaling rules and the current load.\nZoom: deploying an ASP.NET Core image #The simplest path to a working deployment uses Azure Bicep or the Azure CLI. Here is a minimal Bicep template:\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 details that matter for a production deployment.\ningress.external: true exposes the app to the internet over HTTPS with an Azure-managed certificate on a *.azurecontainerapps.io subdomain. For a custom domain, bind it separately and configure a CNAME record.\ntargetPort: 8080 matches the port the ASP.NET Core app listens on inside the container. The default Kestrel HTTP port for mcr.microsoft.com/dotnet/aspnet is 8080 since .NET 8, which is what the Docker article recommends.\nsecrets references keep connection strings out of the template. The value can come from a parameter, from Key Vault via a keyVaultUrl, or from another source. Never inline production secrets into a committed Bicep file.\nprobes mirror the Kubernetes liveness and readiness probes, with the same semantics: liveness restarts the replica, readiness removes it from ingress temporarily.\nscale rules define autoscaling. Here, the app scales based on concurrent HTTP requests per replica: if each replica holds more than 50 concurrent requests, ACA adds a new one. You can combine multiple rules (HTTP concurrency + queue depth + CPU) and ACA picks the max.\nminReplicas: 1 means at least one replica is always running, avoiding cold start. Set it to 0 for cost savings on low-traffic workloads (scale to zero), accepting a cold start of 2-5 seconds on the first request after idle.\n💡 Info : minReplicas: 0 is the feature that truly differentiates ACA from Kubernetes. Scaling to zero means an idle dev environment costs cents per day. Production workloads with steady traffic usually keep minReplicas: 1 or higher to avoid any cold start latency.\nZoom: revisions and traffic splitting #Every time the image tag or revision-scoped configuration changes, ACA creates a new revision. By default, the new revision receives 100% of traffic and the previous one is deactivated. For canary or blue/green deployments, explicit traffic splitting is a single CLI call:\n# Deploy a new image. Creates 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 # Put 10% of traffic on the new revision, 90% on the old. az containerapp ingress traffic set \\ --name shop-api \\ --resource-group shop-rg \\ --revision-weight shop-api--v146=90 shop-api--v147=10 # Monitor metrics for 15 minutes. If green, shift to 100%. az containerapp ingress traffic set \\ --name shop-api \\ --resource-group shop-rg \\ --revision-weight shop-api--v147=100 Rollback is the inverse: shift traffic back to the previous revision with one command. No pod termination, no rolling update to wait for, no scripts.\n✅ Good practice : Automate traffic shifts in the deployment pipeline with an observability gate: split 10% to the new revision, wait 10 minutes, check error rate and latency against the baseline (covered in the baseline load testing article), and only proceed to 100% if the metrics hold. Roll back automatically if they do not.\nZoom: KEDA-powered scaling rules #The HTTP-concurrency scaler shown above is the simplest one. For workloads driven by queues, Kafka topics, or custom metrics, ACA exposes the full KEDA scaler library.\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; } ] } } ] } This scales the app based on the depth of an Azure Service Bus queue: if there are more than 5 messages per replica, ACA adds a replica, up to 30 total. When the queue empties, ACA scales back to zero, and the app stops consuming compute until the next message arrives. For event-driven workloads, this is a dramatic cost improvement compared to always-on hosting.\n⚠️ It works, but\u0026hellip; : Scale-to-zero plus HTTP workloads produces cold starts of 2-5 seconds for the first request after idle. For user-facing APIs, this is usually unacceptable, and minReplicas should stay at 1 or higher. For background workers triggered by queues, it is fine: the queue absorbs the latency, and the cost saving is real.\nZoom: configuration and secrets #ACA exposes two places for configuration. Regular environment variables for non-sensitive values, and a separate secrets section for anything sensitive. Secrets are referenced by name from the environment variable list:\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; } ] Using keyVaultUrl with a system-assigned managed identity is the canonical pattern: secrets live in Azure Key Vault, ACA pulls them at deployment time via its identity, and no plain value ever touches the Bicep template. If the secret in Key Vault rotates, ACA needs a new revision to pick up the change.\nFor values that change without a deployment (feature flags, rate limits), pair ACA with Azure App Configuration and the Microsoft.Extensions.Configuration.AzureAppConfiguration package. The app reloads the values without a restart.\nWhen ACA is the right choice #Azure Container Apps is the right host for:\nContainer-native workloads inside Azure that would otherwise go on Kubernetes but do not need the full control or complexity. Event-driven services (queue consumers, background workers, Kafka processors) that benefit from scale-to-zero. Microservices where you want service-to-service calls, pub/sub, or state management to be abstracted via Dapr. Teams that have container expertise but no Kubernetes operations budget. Traffic-splitting-heavy release strategies: canary, blue/green, A/B, where the built-in revision system removes the need for custom rollout tooling. It is not the right choice when:\nYou need full Kubernetes control: custom CRDs, operators, NetworkPolicies, cluster-wide customization. Go to AKS (the Kubernetes article). You run a single small web app with steady traffic and no containers: Azure Web App is simpler still and often cheaper. Your team is not on Azure: porting ACA\u0026rsquo;s model to AWS or GCP is non-trivial. If multi-cloud is required, Kubernetes is a better portability layer. Cold start matters and you cannot afford minReplicas: 1: ACA\u0026rsquo;s cold start is 2-5 seconds, which is great for a queue worker and too slow for a user-facing API without always-on replicas. Wrap-up #Azure Container Apps gives you the benefits of Kubernetes-class container hosting without the operational weight: revisions, traffic splitting, KEDA autoscaling, ingress with managed certificates, Key Vault-backed secrets, and scale-to-zero for workloads that tolerate cold start. You can deploy an ASP.NET Core image with a Bicep template in an afternoon, combine it with queue-based autoscaling for event-driven workers, split traffic between revisions for canary deployments, and recognize when the workload would be better served by plain Kubernetes or Azure Web App.\nReady to level up your next project or share it with your team? See you in the next one, Azure Web App is where we go next.\nRelated articles # Hosting ASP.NET Core with Docker: A Pragmatic Guide Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers AOT Compilation in .NET: Startup, Size, and Trade-offs Spike Testing in .NET: Surviving the Sudden Burst References # Azure Container Apps documentation, Microsoft Learn Scale an application in Azure Container Apps, Microsoft Learn Manage revisions, Microsoft Learn KEDA scalers documentation Dapr on Azure Container Apps, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/hosting-azure-container-apps/","section":"Posts","summary":"","title":"Hosting ASP.NET Core on Azure Container Apps"},{"content":"Not every .NET application needs containers, Kubernetes, or a service mesh. A surprising number of production workloads are best served by the simplest Azure hosting option available: a Web App on Azure App Service. It has been the default for Microsoft-first .NET teams since 2012, it ships with built-in HTTPS and deployment slots and autoscaling, and for a wide class of applications it is the right answer precisely because it is simpler than the container-first alternatives covered earlier in this series.\nThis article closes the Hosting series with Azure Web App: what it is, how to deploy an ASP.NET Core application to it, what the built-in features actually do, and when it wins against containers on ACA or Kubernetes.\nWhy Azure Web App #Azure App Service launched in 2012 and has been evolving continuously since. Web App is its HTTP workload variant, running ASP.NET, ASP.NET Core, Node.js, Python, Java, and PHP applications on Microsoft-managed infrastructure. For .NET specifically, it has native support: no Dockerfile to write, no image to build, no container registry to manage. The application is published directly, the platform runs it, and all the common concerns (TLS, scaling, monitoring, authentication) are available as flipped switches.\nThe concrete advantages that still matter in 2026:\nSimplicity. A dotnet publish output is all it takes to deploy. No container image, no orchestration, no YAML, no Dockerfile. For a team whose primary skill is writing .NET code, that matches the skill set without imposing a new one. Built-in deployment slots. Every Standard tier or higher Web App comes with staging slots. Deploy to a slot, validate, then swap slots atomically. The swap is instant and reversible, which makes blue/green deployments a native feature rather than something you have to orchestrate. Managed TLS certificates. App Service Managed Certificates are free, auto-renewing, and wired into the custom domain with one click. No cert-manager, no Let\u0026rsquo;s Encrypt cron job, no expiration alerts. Autoscaling and Always On. Scale out rules based on CPU, memory, or custom metrics. The Always On setting prevents the worker from going idle during quiet periods, which eliminates the cold start that plagues serverless alternatives for user-facing workloads. Integration with the Azure ecosystem. Managed identity, Key Vault references, Application Insights, App Configuration, private endpoints, VNet integration. All of them are configuration settings, not packages to install. None of this is unique to Web App. It is all available elsewhere. The value is that it is all in one place and accessible without extra tooling.\nOverview: the App Service model # graph TD A[App Service Plancompute + pricing tier] --\u003e B[Web Appshop-api] A --\u003e C[Web Appshop-admin] B --\u003e B1[Production slot] B --\u003e B2[Staging slot] B2 --\u003e B2D[Deployment] B1 --\u003e B1T[100% traffic] The hierarchy is straightforward and has been stable for a decade.\nApp Service Plan is the underlying compute resource: CPU cores, memory, pricing tier (Basic, Standard, Premium v3, Isolated). Multiple Web Apps can share a single plan, which is the standard way to host related applications on the same compute without needing separate billing lines.\nWeb App is the application. It has a name (used in the default URL \u0026lt;name\u0026gt;.azurewebsites.net), a runtime stack (.NET 10 (LTS)), a deployment source, configuration settings, and optional features (custom domains, identity, scaling rules).\nDeployment slot is a separate clone of the Web App with its own URL, its own configuration, and its own deployed code. Non-production slots share the App Service Plan\u0026rsquo;s compute but run independently. The value is the ability to swap slot contents atomically: deploy to staging, warm it up with a few requests, run smoke tests, and swap it into production in seconds.\nZoom: deploying an ASP.NET Core application #The three most common deployment paths, in order of maturity:\n1. Publish profile from Visual Studio or CLI. Simplest for a single developer. dotnet publish produces the output, az webapp deploy (or the Visual Studio publish wizard) pushes it. Good for prototypes, not for teams.\n2. GitHub Actions or Azure DevOps pipeline with azure/webapps-deploy. The standard CI path. The pipeline builds, tests, publishes, and deploys, with a single YAML workflow.\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 Four steps: publish the output, deploy to the staging slot, verify health on the staging slot, swap slots. The production traffic flips to the new version at the moment of the swap, without cold start, because Azure warms up the staging slot before the swap completes.\n3. Container deployment. If the team already builds Docker images for other hosts, Web App can run a custom container from any registry. Configure the Web App to point at the image, and it becomes a managed container host. This loses the \u0026ldquo;no Dockerfile to write\u0026rdquo; benefit but keeps the slot and scaling features.\n✅ Good practice : Always deploy to a staging slot first and swap. Direct deployment to production is a habit from the 2000s. With slots, you pay essentially nothing extra for a pre-production validation step and the ability to instantly roll back.\nZoom: configuration without rebuilding #Web App exposes its configuration through three layers, in order of precedence:\nSlot-specific application settings. Environment variables defined on the Web App itself, which become IConfiguration entries in ASP.NET Core. The double underscore convention maps to nested keys: ConnectionStrings__Default becomes ConnectionStrings:Default.\nKey Vault references. An app setting can contain a reference to a secret in Azure Key Vault, and App Service resolves it at startup using the Web App\u0026rsquo;s managed identity. The actual secret never appears in any configuration file or deployment artifact.\nConnectionStrings__Default = @Microsoft.KeyVault(SecretUri=https://shop-kv.vault.azure.net/secrets/db-connection/) App Configuration integration via the Microsoft.Extensions.Configuration.AzureAppConfiguration package, for values that should reload without restart (feature flags, rate limits, toggles). This pairs especially well with Key Vault for the sensitive values and App Configuration for the dynamic ones.\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 : A \u0026ldquo;slot-specific\u0026rdquo; app setting stays with the slot during a swap, while a regular setting swaps with the code. This distinction lets you keep ASPNETCORE_ENVIRONMENT=Staging on the staging slot permanently, so the same deployment can be tested in staging mode and flipped to production mode by simply swapping.\nZoom: scaling #Web App offers two scaling dimensions:\nScale up changes the size of the App Service Plan (more CPU, more memory). It is an operation that affects all Web Apps on the plan and takes a minute or two. Used when the current tier is too small for the peak load.\nScale out adds more instances of the plan, running copies of the same Web Apps in parallel. Azure load-balances traffic across the instances automatically. Scale out rules can be configured based on CPU, memory, queue length, or custom metrics, with cooldown windows to avoid 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 // required for 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; } } ] } ] } } A plan with 2 instances minimum, 10 maximum, adding one instance whenever the average CPU over 5 minutes exceeds 70%, with a 5-minute cooldown. This is the standard shape for autoscaling a steady-traffic Web App.\n⚠️ It works, but\u0026hellip; : Autoscale rules on Web App are reactive, not predictive. A burst that exceeds capacity in 30 seconds (see the spike testing article) is faster than the autoscaler\u0026rsquo;s reaction window. For spike-heavy workloads, either run a higher minimum instance count, or accept the queued latency at the start of each spike.\nZoom: the Always On setting #Web App puts an idle worker to sleep after 20 minutes of inactivity, exactly like IIS. This is fine for hobby sites and for dev environments, but for user-facing production workloads it introduces a cold start on every first request after idle, which breaks p99 latency targets.\nThe fix is a single toggle:\nGeneral settings → Always On → On This keeps the worker warm at all times. It is available on Basic tier and above (not on Free or Shared). For production traffic, it should always be enabled.\nPaired with Always On, the /health/live endpoint described in the Docker hosting article lets you configure the App Service health check ping to periodically hit the endpoint, ensuring the application stays responsive.\nsiteConfig: { alwaysOn: true healthCheckPath: \u0026#39;/health/live\u0026#39; // ... } Zoom: when Web App is the right choice #Web App is the right host for:\nA single .NET web application with steady traffic. The simplicity pays off: one resource, one deployment path, one set of settings. Teams whose expertise is .NET, not containers or Kubernetes. No Dockerfile, no kubectl, no orchestration knowledge needed. Applications that benefit from deployment slots: blue/green without extra tooling, A/B testing, gradual rollout with traffic routing percentages. Microsoft-integrated workloads: Entra ID authentication, managed identity, Key Vault, Application Insights. All of them plug in as configuration options. Workloads that need the Azure hybrid features: VNet integration, private endpoints, Hybrid Connections for on-prem integration. It is not the right choice when:\nContainer-native deployment is the requirement. If the application already ships as a Docker image and the team is on the container-first path, Azure Container Apps or Kubernetes fits better. Multi-cloud or on-prem portability matters. Web App is an Azure-only offering. Porting it elsewhere means rewriting the hosting layer. The workload is event-driven with low baseline traffic. Scale-to-zero is not a Web App feature (beyond the free tier). Azure Functions or Azure Container Apps with minReplicas: 0 serves that pattern better. Workload isolation across many small services is required. Running one Web App per microservice quickly becomes expensive and operationally heavy compared to sharing a cluster or a Container Apps Environment. Wrap-up #Azure Web App is the simplest way to run an ASP.NET Core application in Azure in 2026, and for a meaningful share of workloads it is also the best. You can deploy with a GitHub Actions pipeline in half an hour, use deployment slots for zero-downtime swaps, wire Key Vault references directly into configuration, turn on Always On for predictable latency, and configure CPU-based autoscaling with a Bicep template. You can recognize when the workload would benefit more from Kubernetes or Azure Container Apps and choose the right tool for the shape of the problem rather than applying the same hosting pattern to everything.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # Hosting ASP.NET Core on IIS: The Classic, Demystified Hosting ASP.NET Core with Docker: A Pragmatic Guide Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers Hosting ASP.NET Core on Azure Container Apps References # App Service overview, Microsoft Learn Deploy to App Service, Microsoft Learn Deployment slots, Microsoft Learn App Service Managed Certificates, Microsoft Learn Autoscale in Azure Monitor, Microsoft Learn Key Vault references for App Service, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/hosting-azure-web-app/","section":"Posts","summary":"","title":"Hosting ASP.NET Core on Azure Web App"},{"content":"For twenty years, IIS was the default answer to \u0026ldquo;where does this .NET application run\u0026rdquo;. System.Web, ASP.NET WebForms, MVC up to version 5, WCF, WebAPI 2: all of them were tightly coupled to the IIS pipeline and the HttpRuntime. When ASP.NET Core shipped in 2016, it was explicitly decoupled from IIS: it ran on its own cross-platform web server, Kestrel, and IIS became optional. Yet a decade later, IIS is still the production target for a meaningful share of .NET shops, usually because an existing on-prem Windows Server, a fleet of legacy applications, or a compliance requirement keeps it in the picture. This article is about hosting ASP.NET Core on IIS in 2026: what IIS actually does, what it does not do, and when this is still the right call.\nWhy IIS is still in the picture #The traditional story is \u0026ldquo;IIS is legacy, move to containers\u0026rdquo;. That story is half right. IIS is clearly not the future, and new greenfield projects rarely start there. But three situations make it the right pragmatic choice:\nExisting Windows Server infrastructure with an operations team that knows it. A company running fifty .NET applications on IIS, with monitoring, deployment pipelines, and runbooks built around IIS, does not benefit from moving one of them to a completely different stack. The integration cost outweighs the marginal hosting gain. Legacy applications mixed with modern ones. An ASP.NET Core application that needs to sit next to an ASP.NET WebForms application, share authentication, share SSL certificates, or respond under the same domain is much easier to host on the same IIS than to split across two hosting strategies. Compliance and policy constraints. Some environments require specific TLS configurations, HTTP.sys features, Windows Authentication via Kerberos, or integration with Active Directory that is significantly easier on IIS than on Kestrel alone. None of these make IIS \u0026ldquo;good\u0026rdquo;. They make IIS appropriate in context. The job is to host modern ASP.NET Core correctly on it, not to pretend the constraint does not exist.\nOverview: what IIS actually does # graph LR A[HTTP request] --\u003e B[HTTP.sys kernel driver] B --\u003e C[IIS worker processw3wp.exe] C --\u003e D[ASP.NET Core Moduleaspnetcore v2] D --\u003e E[Your Kestrel appdotnet.exe] E --\u003e F[Response] F --\u003e C C --\u003e B B --\u003e A The critical piece to understand is that IIS is no longer the web server for your ASP.NET Core application. It is a reverse proxy in front of a Kestrel process that runs your application. The component that makes this work is the ASP.NET Core Module (ANCM), a native IIS module installed with the .NET Hosting Bundle. ANCM has two modes, and the choice between them is the single most important hosting decision on IIS.\nIn-process mode (the default since .NET Core 2.2): ANCM loads the CLR inside the IIS worker process w3wp.exe directly. Kestrel runs in-process, and requests reach the application through a fast in-memory channel. This is roughly 2-3x faster than out-of-process mode and is the right default for most workloads.\nOut-of-process mode: ANCM launches a separate dotnet.exe child process that hosts Kestrel on a localhost port, then forwards IIS requests to it over HTTP. Slower, but necessary when the application needs to isolate itself from the worker process or use features that do not work in-process (for example, certain kinds of module interop).\n💡 Info : The ASP.NET Core Module was originally called aspnetcore (v1), rewritten as aspnetcorev2 in .NET Core 2.2 to add in-process hosting. Both are installed by the .NET Hosting Bundle; your application picks the active one through its web.config. New projects should always start with in-process.\nZoom: the minimal web.config #ASP.NET Core on IIS still uses a web.config file, but it is generated at publish time and its only job is to tell IIS which module to load and which executable to launch. For most applications, the file needs no manual editing:\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; The hostingModel=\u0026quot;inprocess\u0026quot; attribute is what activates in-process mode. The processPath points at the published executable, not at dotnet.exe, because a self-contained or framework-dependent publish produces a small native launcher for Windows.\n✅ Good practice : Never edit the generated web.config by hand to add environment variables or startup flags. Use the csproj \u0026lt;EnvironmentVariables\u0026gt; item group at publish time, or set them on the IIS application pool through the IIS Manager. A hand-edited web.config will be overwritten on the next publish.\nZoom: configuring the application pool #An IIS application pool is the worker process that hosts your application. For ASP.NET Core, a few settings matter more than the defaults.\n.NET CLR Version: set to No Managed Code. This is counter-intuitive, but correct. The setting refers to the legacy .NET CLR (System.Web), which ASP.NET Core does not use. Leaving it on \u0026ldquo;v4.0\u0026rdquo; loads legacy runtime code into the worker process for no reason.\nManaged Pipeline Mode: Integrated (the default). Classic mode is for legacy applications that depend on the old ISAPI pipeline.\nPipeline Mode and Identity: the default ApplicationPoolIdentity is usually correct. For applications that need access to a network share or a SQL Server with integrated authentication, switch to a domain service account dedicated to the pool.\nRecycling: the default recycles the pool every 1740 minutes (29 hours). For long-running ASP.NET Core applications that hold in-memory caches, this forces a cold start every day, which is disruptive. Either disable time-based recycling or schedule it during a low-traffic window. A modern ASP.NET Core application does not have the leaky legacy runtime behavior that made daily recycles necessary on System.Web.\nIdle timeout: the default 20 minutes shuts the worker process down when no traffic arrives. This is fine for intranet applications but will produce slow first-request responses after every idle period. For internet-facing applications with continuous traffic, either set it to 0 or configure the Application Initialization module to keep the process warm.\n⚠️ It works, but\u0026hellip; : The default recycling and idle timeout settings were designed for System.Web workloads from the 2000s. They are still the defaults in modern IIS. A team that publishes an ASP.NET Core application without reviewing these settings will pay the cold-start tax described in the spike testing article every morning, and will not understand why.\nZoom: deployment with Web Deploy #The traditional deployment mechanism for IIS is Web Deploy (MSDeploy), which understands how to stop the application pool, copy files, and restart cleanly. A typical release from a CI pipeline uses dotnet publish to produce the output, then msdeploy.exe to push it to the target server:\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 The AppOffline rule drops a App_Offline.htm file into the root during deployment, which causes IIS to stop forwarding to the application and display a maintenance page. Without it, in-flight requests can fail noisily during file replacement.\nFor teams that dislike MSDeploy, a simpler xcopy or robocopy to a shared folder followed by appcmd recycle apppool /apppool.name:ShopApi is also a perfectly valid deployment strategy. It is less sophisticated but more scriptable.\n💡 Info : The App_Offline.htm mechanism is a holdover from System.Web, but ASP.NET Core still respects it. Dropping a file named exactly App_Offline.htm into the application root causes the hosting module to unload the CLR and serve the HTML content as a 503.\nZoom: observability on IIS #ASP.NET Core applications hosted on IIS retain full access to standard .NET observability: ILogger, OpenTelemetry, Application Insights, and any metrics or traces the application emits. The only IIS-specific surfaces worth knowing about are:\nIIS log files under C:\\inetpub\\logs\\LogFiles\\W3SVC*. These record every HTTP request at the IIS level, including status code and response time, and they are useful for correlating with application logs when something goes wrong upstream of the application code.\nEvent Viewer → Windows Logs → Application: the ASP.NET Core Module writes startup errors and crashes here. The first place to check when the application fails to start after a deployment.\nstdoutLogEnabled in web.config: temporarily setting this to true redirects the Kestrel console output to a file. Only enable it while diagnosing a problem and disable it afterward, because the file is not rotated and grows without bound.\n// Program.cs: route ILogger to Event Log for IIS correlation builder.Logging.AddEventLog(new EventLogSettings { SourceName = \u0026#34;Shop.Api\u0026#34;, LogName = \u0026#34;Application\u0026#34; }); This writes log entries visible in Event Viewer, which an operations team familiar with IIS can read without needing a separate log aggregation tool. It is not a replacement for structured logging, but it is a convenient fallback.\nZoom: when IIS is not the right answer #IIS is not the right host when:\nContainer-native deployment is the target. If the deployment pipeline ships Docker images and the orchestrator is Kubernetes, Azure Container Apps, or similar, moving through IIS adds a Windows server that does not belong in the path. Cross-platform is a requirement. Linux production targets rule IIS out entirely. Autoscaling is needed. IIS scales by adding worker processes or worker hosts; it does not elastically scale by count of instances the way a container platform does. For variable load, a container-based platform is a better match. The team is already Linux-first. Running a Windows Server for one .NET application in an otherwise Linux environment is the most expensive kind of \u0026ldquo;hosting choice\u0026rdquo; because the operational cost is high and the skill base is different. For these cases, the next articles in this series cover Docker, Kubernetes, Azure Container Apps, and Azure Web App.\nWrap-up #IIS in 2026 is a reverse proxy that fronts a Kestrel process via the ASP.NET Core Module in in-process mode, and it is a perfectly reasonable host for an ASP.NET Core application in the right context: existing Windows Server infrastructure, a legacy application mix, or compliance constraints that make another path more expensive. You can set the app pool to \u0026ldquo;No Managed Code\u0026rdquo;, disable legacy daily recycling, keep the idle timeout sensible, deploy with Web Deploy and the App_Offline.htm rule, and wire the ASP.NET Core Module\u0026rsquo;s event log output into your existing operations workflow.\nReady to level up your next project or share it with your team? See you in the next one, Hosting with Docker is where we go next.\nReferences # Host ASP.NET Core on Windows with IIS, Microsoft Learn ASP.NET Core Module (ANCM) reference, Microsoft Learn Web Deploy documentation IIS application pool recycling settings, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/hosting-iis/","section":"Posts","summary":"","title":"Hosting ASP.NET Core on IIS: The Classic, Demystified"},{"content":"Kubernetes is the default orchestrator for container workloads in 2026, and every serious .NET shop eventually hosts at least one application on it. The learning curve has a reputation for being steep, and it is, but the subset that a .NET developer actually needs to understand to ship an ASP.NET Core application is much smaller than the full platform surface. This article covers exactly that subset: the handful of primitives (Deployment, Service, Ingress, probes, resource limits) that turn a Docker image into a production-ready workload on Kubernetes.\nWhy Kubernetes #Kubernetes gives you five things that are hard to build on plain Docker:\nDeclarative desired state. You describe what should run, and the control plane keeps reality in sync with the description. No scripts, no manual recovery. If a pod dies, a new one is started automatically. Horizontal scaling. You specify a replica count or an autoscaler rule, and the cluster maintains the right number of instances, distributing them across nodes. Rolling updates and rollbacks. Deploying a new version replaces pods one by one without downtime, and rolling back is a single command. Service discovery and load balancing. Pods do not need to know about each other\u0026rsquo;s IPs. They talk to named services, and the cluster routes traffic across the healthy instances. Resource isolation. Each pod gets CPU and memory limits, enforced by the kernel, so a misbehaving instance cannot starve its neighbors. These are the same guarantees that justify moving off a single Docker host to an orchestrator in the first place. The cost is a new vocabulary and a new operational model, which is what this article tries to make concrete.\nOverview: the minimum primitives # graph TD A[Deployment] --\u003e B[ReplicaSetmanages N pods] B --\u003e C[Pod 1your 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 For a typical ASP.NET Core web API, the minimum set of Kubernetes resources is:\nDeployment: declares what the application is (container image, environment variables, probes, resource limits) and how many replicas should run. The Deployment owns a ReplicaSet, which owns the actual pods.\nService: gives the pods a stable virtual IP and DNS name inside the cluster, load-balancing traffic across the healthy replicas. Other services talk to your application via the Service, not to individual pods.\nIngress: routes external HTTP traffic from outside the cluster to the Service. Handles TLS termination, host-based routing, and path-based routing via an Ingress Controller (NGINX, Traefik, Azure Application Gateway, etc.).\nConfigMap and Secret: externalize configuration and secrets from the image. ConfigMaps for non-sensitive values (log level, feature flags), Secrets for anything sensitive (connection strings, API keys).\nThese four resources cover 80% of what a .NET application on Kubernetes needs. The rest (HorizontalPodAutoscaler, NetworkPolicy, ServiceAccount, ResourceQuota) is built on top of them.\nZoom: the 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 details make this a production-ready Deployment instead of a toy one.\nstrategy.rollingUpdate with maxUnavailable: 0 guarantees that at no point during a deployment does the cluster have fewer than the target replica count available. A new pod is created first (maxSurge: 1), it passes its readiness probe, then an old pod is terminated. True zero-downtime rollout.\nresources.requests and resources.limits are both declared. Requests tell the scheduler how much room to find on a node. Limits are the hard ceiling enforced by the kernel. A pod without resource limits can eat all CPU on its node, starve other pods, and produce cascading failures. A pod without resource requests gets scheduled anywhere and ends up competing for resources unpredictably.\nlivenessProbe and readinessProbe pair cleanly with the health check endpoints from the Docker article. Liveness restarts the pod on failure; readiness removes it from the Service endpoints until it recovers. Never merge them into a single probe, because the consequences of failure are different.\nterminationGracePeriodSeconds: 45 extends the default 30-second window to give in-flight requests more time to complete. Must match the HostOptions.ShutdownTimeout configured in the application.\nsecurityContext runs the container as non-root with a read-only root filesystem and no privilege escalation. The chiseled .NET images already run as non-root by default, but declaring it at the pod level is a defense-in-depth measure that also works with full images.\nenv pulls secrets from a Kubernetes Secret instead of hardcoding connection strings. The Secret is defined separately and injected at runtime, so the Deployment YAML can be committed to source control without leaking credentials.\n💡 Info : Kubernetes resource requests for CPU are in \u0026ldquo;millicores\u0026rdquo; (m). 100m means 0.1 of a CPU core. 500m means half a core. A typical ASP.NET Core API needs 50-200m at idle and 300-500m under load, but only a load test (covered in the load testing series) tells you the real numbers for your application.\nZoom: the Service and 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 The Service type is ClusterIP, which means it is only reachable from inside the cluster. External traffic goes through the Ingress, which handles TLS termination with a certificate stored in the shop-api-tls Secret (typically managed by cert-manager with Let\u0026rsquo;s Encrypt).\nThe Ingress annotation proxy-body-size is NGINX-specific and increases the maximum upload size from the default 1 MB to 10 MB. Annotations like this are the main way to configure ingress controller behavior; each controller has its own set.\n✅ Good practice : Use a single Ingress resource per domain and a single Service per Deployment. Do not try to be clever with shared services or complex routing rules early. Start simple, and only add complexity when a concrete requirement demands it.\nZoom: rolling updates and pod lifecycle #When a new version of the application ships, a typical rolling update looks like this:\nThe image tag in the Deployment is updated (via kubectl set image, a Helm upgrade, an ArgoCD sync, or similar). Kubernetes creates a new ReplicaSet for the new version. One new pod is created and starts up. The container runs. The readiness probe begins polling. Once it returns 200, the pod is added to the Service endpoints and starts receiving traffic. One old pod is marked for termination. It receives SIGTERM. ASP.NET Core stops accepting new connections, drains in-flight requests (up to the grace period), flushes logs, and exits cleanly. Kubernetes removes it from the Service endpoints immediately and waits for the process to exit. Steps 3 and 4 repeat until all old pods are replaced. Three things can go wrong, and they all look similar from the outside but have different causes.\nThe new pod never passes readiness. The old pods stay in place, the rollout stalls. Usually means the application cannot start: bad configuration, a missing secret, a database migration that failed. kubectl describe pod and kubectl logs are the first places to look.\nThe new pod passes readiness, then crashes under traffic. Liveness probes start failing, the pod restarts, and the CrashLoopBackOff state kicks in. Usually means the application depends on something it did not need during readiness (for example, a downstream API that is only called under real traffic).\nIn-flight requests fail during rollout. Usually means the grace period is too short, or the application does not handle SIGTERM correctly (see the Docker article on signal handling). Requests get dropped when the old pod exits before they finish.\n⚠️ It works, but\u0026hellip; : The default ASP.NET Core behavior is to stop accepting connections on SIGTERM and finish pending requests. This works in most cases, but if your application holds long-running operations (large uploads, long-polling, WebSockets), tune terminationGracePeriodSeconds upward and configure Kestrel\u0026rsquo;s KeepAliveTimeout accordingly.\nZoom: ConfigMap and Secret #Production configuration should live outside the container image. Kubernetes provides two primitives for this.\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 for non-sensitive values, Secret for sensitive ones. The double underscore (__) convention in key names maps to nested configuration in ASP.NET Core: Logging__LogLevel__Default becomes Logging:LogLevel:Default in IConfiguration.\nSecrets in plain YAML are only base64-encoded, not encrypted. For real security, use one of:\nSealed Secrets (Bitnami) for committing encrypted secrets to Git. External Secrets Operator to pull secrets from Azure Key Vault, AWS Secrets Manager, HashiCorp Vault at runtime. Kubernetes Secrets with encryption at rest enabled on the cluster (most managed offerings do this by default). ❌ Never do this : Do not commit plain Secret YAML to Git, even in a private repository. Treat it the way you would treat a password file. Use one of the external secret management patterns instead.\nZoom: horizontal autoscaling #Once the application is running with manual replica counts, adding a HorizontalPodAutoscaler lets Kubernetes adjust the count automatically based on CPU or custom metrics.\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 The HPA scales the Deployment between 3 and 20 pods, aiming to keep average CPU utilization around 70%. The stabilizationWindowSeconds: 300 on scale-down prevents thrashing: the HPA waits 5 minutes of low CPU before removing a replica, which avoids flapping when load is spiky.\nThe spike testing article covers the failure mode where HPA reaction is too slow for sudden bursts. If spikes are a real concern for your workload, either run a higher minReplicas count, or move to predictive autoscaling via tools like KEDA.\n💡 Info : KEDA (Kubernetes Event-Driven Autoscaling) is the community-standard way to scale Kubernetes workloads based on external signals: queue depth (RabbitMQ, Azure Service Bus, Kafka), Prometheus metrics, HTTP request rate, and many others. For workloads whose load does not correlate with CPU, KEDA is usually the right answer.\nWhen Kubernetes is the wrong tool #Kubernetes is powerful, but it is also operationally heavy. Running a production-grade cluster means patching nodes, managing an Ingress Controller, maintaining observability, handling certificate rotation, and debugging issues that do not exist on simpler platforms. For small applications (one service, low traffic, one or two developers), this overhead is disproportionate.\nIf the workload fits one of these shapes, a lighter alternative is often better:\nSingle small service: Azure Web App or a plain Docker host. Container-native but low operational tolerance: Azure Container Apps, which gives you most of Kubernetes\u0026rsquo;s benefits without managing the cluster. Serverless / event-driven: Azure Functions or AWS Lambda, especially when paired with Native AOT from the performance series for fast cold start. Kubernetes pays off when you have multiple services, multiple teams, variable load that benefits from autoscaling, and enough operational capacity to run the cluster. For a single small API with steady traffic, it is overkill.\nWrap-up #Hosting ASP.NET Core on Kubernetes comes down to a small set of primitives: a Deployment with probes, resource limits, and a security context; a Service for stable internal routing; an Ingress for external traffic with TLS; ConfigMaps and Secrets for externalized configuration; and optionally a HorizontalPodAutoscaler when load varies. You can turn a Docker image into a production-ready Kubernetes workload by combining those, you can achieve true zero-downtime rolling updates with the right probe and grace period configuration, and you can recognize when the operational cost of Kubernetes is not paying off and a simpler platform would serve better.\nReady to level up your next project or share it with your team? See you in the next one, Azure Container Apps is where we go next.\nRelated articles # Hosting ASP.NET Core on IIS: The Classic, Demystified Hosting ASP.NET Core with Docker: A Pragmatic Guide Load Testing for .NET: An Overview of the Four Types That Matter Spike Testing in .NET: Surviving the Sudden Burst References # Kubernetes concepts, official docs Configure Liveness, Readiness and Startup Probes, Kubernetes docs Horizontal Pod Autoscaler, Kubernetes docs Deploy ASP.NET Core apps to Kubernetes, Microsoft Learn KEDA documentation ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/hosting-kubernetes/","section":"Posts","summary":"","title":"Hosting ASP.NET Core on Kubernetes: The Essentials for .NET Developers"},{"content":"Containers changed .NET hosting more than any other technology of the last decade. Before Docker, shipping a .NET application meant producing an MSI, a Web Deploy package, or a ZIP file, and hoping the target environment had the right runtime installed. After Docker, shipping a .NET application means producing an image, and that image contains everything needed to run: the runtime, the application, the trimmed dependencies, nothing else. The image runs identically on a developer laptop, a CI agent, a pre-prod cluster, and a production host.\nThis article is not about \u0026ldquo;Docker in general\u0026rdquo;. It is about hosting an ASP.NET Core application on Docker correctly in 2026, with the Microsoft base images that actually make sense, a multi-stage Dockerfile that produces a small and secure image, and the handful of configuration details that separate a working container from a production-ready one. If a previous article in this series covered IIS as the Windows-first option, this one covers the cross-platform default.\nWhy Docker hosting #The advantages of containerizing a .NET application are well-known, but worth stating plainly because \u0026ldquo;we always did it this way\u0026rdquo; is a surprisingly common reason to still not be on Docker:\nDeterministic deployment. The image built in CI is bit-for-bit identical to the image that runs in production. No \u0026ldquo;it worked on my machine\u0026rdquo;, no \u0026ldquo;the base image was patched between builds\u0026rdquo;, no \u0026ldquo;the runtime version drifted\u0026rdquo;. Decoupling from the host OS. The host needs a container runtime (containerd, Docker Engine, or a compatible alternative) and nothing else. No .NET Hosting Bundle, no IIS, no machine-wide dependency. A single deployment target. The same image runs on a developer laptop, Kubernetes, Azure Container Apps, AWS ECS, a bare Docker host. The orchestrator changes; the image does not. Fast, scriptable operations. Rolling updates, rollbacks, and blue/green deployments become simple orchestrator primitives instead of custom scripts. For a new .NET project in 2026, the default hosting strategy is a container. The question is not whether to use Docker; it is how to build the image well.\nOverview: the image pipeline # graph LR A[Source code] --\u003e B[SDK imagebuild stage] B --\u003e C[Restore + Publish] C --\u003e D[Runtime imagefinal stage] D --\u003e E[Application binary] D --\u003e F[Metadata:ports, user, entrypoint] E --\u003e G[Final image80-120 MB] F --\u003e G Every .NET Docker image worth shipping is built in two stages. The build stage uses a large SDK image (mcr.microsoft.com/dotnet/sdk) that contains the compiler, NuGet, and the tooling needed to produce a publish output. The runtime stage uses a much smaller image (mcr.microsoft.com/dotnet/aspnet or its chiseled variant) that contains only what is needed at runtime. The published output from the build stage is copied into the runtime stage, and the runtime stage is what ships.\nThis two-stage pattern is not optional. A single-stage image based on the SDK would be 700+ MB, which is fine for a developer playground and entirely wrong for production.\nZoom: the canonical multi-stage Dockerfile ## syntax=docker/dockerfile:1.9 ARG DOTNET_VERSION=10.0 # --- Build stage --- FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build WORKDIR /src # Copy only the csproj first, restore, then copy the rest. # This lets Docker cache the restore layer when nothing in csproj changes. 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 # --- Runtime stage --- FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled AS final WORKDIR /app # Copy the published output from the build stage. COPY --from=build /app/publish . # Non-root user is already set by the chiseled image. 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;] Three details make this a production-grade Dockerfile instead of a toy one.\nLayer caching on csproj first. Copying only the .csproj files before the rest of the source lets Docker skip the (slow) dotnet restore step on subsequent builds when only application code has changed, not the dependencies. On a large solution, this cuts build times by an order of magnitude.\nChiseled base image. The -noble-chiseled suffix refers to Ubuntu 24.04 \u0026ldquo;Noble\u0026rdquo; chiseled images, which Microsoft publishes alongside the full runtime images. Chiseled images are built from Canonical\u0026rsquo;s chisel tool, which slices Ubuntu packages to include only the files actually needed. A chiseled ASP.NET Core runtime image is around 100 MB instead of 220 MB for the full image, with no shell, no package manager, and a smaller attack surface.\nNon-root user by default. Chiseled images run as a non-root user ($APP_UID, UID 64198) out of the box, which is a security posture that used to require an explicit USER directive. Running as root inside a container is a common mistake and a real risk, and the chiseled images solve it for you.\n💡 Info : The full tag list for Microsoft\u0026rsquo;s .NET base images lives at mcr.microsoft.com/dotnet/aspnet. Pin to a specific version (e.g., 10.0.0-noble-chiseled) in production; use the major version tag (10.0) only in development.\nZoom: the chiseled vs full image decision #Microsoft ships three relevant variants of the ASP.NET Core runtime image:\nFull image (aspnet:10.0): Debian-based, with a shell, apt, and the common Linux userland. Around 220 MB. Use this when you need to install additional packages at build time or debug the container with a shell.\nAlpine image (aspnet:10.0-alpine): Alpine Linux base, around 100 MB. Smaller than Debian, uses musl libc instead of glibc. Some native libraries that assume glibc will not work; most .NET code does. Lowest size for a conventional image.\nChiseled image (aspnet:10.0-noble-chiseled): Ubuntu chiseled, around 100 MB, no shell, no package manager, non-root by default. The most secure option and the one most production systems should default to.\nThe trade-off is debuggability. A chiseled image has no shell, which means docker exec -it container bash will not work. For production, this is a feature, not a bug: you should not be debugging from inside a running container, you should be collecting logs, metrics, and traces. For local development where you actually need a shell, switch to the full image temporarily.\n✅ Good practice : Use the chiseled image by default and switch to the full image only when a specific scenario requires it (native dependency, debugging). Do not standardize on the full image \u0026ldquo;just in case\u0026rdquo;.\nZoom: health checks that actually work #An orchestrator (Docker Compose, Kubernetes, Azure Container Apps) uses health checks to decide whether a container is ready to receive traffic and whether it should be restarted. A broken or missing health check is how teams discover, in production, that their \u0026ldquo;zero downtime\u0026rdquo; rollout was not.\nASP.NET Core provides built-in health check support that pairs cleanly with container orchestration:\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;), }); Two endpoints, two different purposes.\n/health/live is the liveness check. It answers \u0026ldquo;is the process alive enough to respond to HTTP\u0026rdquo;. If it fails, the orchestrator kills and restarts the container. It should not check database connectivity, because a transient database outage should not trigger a container restart storm.\n/health/ready is the readiness check. It answers \u0026ldquo;is this instance ready to take traffic\u0026rdquo;. If it fails, the orchestrator removes the instance from the load balancer until it recovers. This check should verify database and cache dependencies, because an instance that cannot talk to its database should not be serving requests.\nIn the Dockerfile, add the HEALTHCHECK directive only when running on plain Docker or Docker Compose. Kubernetes ignores the Dockerfile directive and uses its own livenessProbe and readinessProbe.\nHEALTHCHECK --interval=10s --timeout=2s --start-period=15s --retries=3 \\ CMD curl --fail http://localhost:8080/health/live || exit 1 ⚠️ It works, but\u0026hellip; : curl is not installed in the chiseled image. For Dockerfile-level health checks on chiseled images, either add the ASP.NET Core health checks library\u0026rsquo;s ability to self-check via its own process, or switch the base image to one that includes a health check tool.\nZoom: signal handling and graceful shutdown #When Docker (or any orchestrator) wants to stop a container, it sends SIGTERM to the process, waits up to 30 seconds (the default stop grace period), and then sends SIGKILL if the process has not exited. ASP.NET Core handles SIGTERM correctly out of the box: it stops accepting new connections, drains in-flight requests, flushes logs, and exits cleanly. For this to work, two details matter.\nThe process must be PID 1 in the container. The ENTRYPOINT [\u0026quot;dotnet\u0026quot;, \u0026quot;Shop.Api.dll\u0026quot;] form runs the process directly as PID 1, which is what you want. The shell form (ENTRYPOINT dotnet Shop.Api.dll without the JSON array) runs it as a child of /bin/sh, which does not forward signals and breaks graceful shutdown.\nThe grace period must be long enough for in-flight requests to complete. For a web API, the default 30 seconds is usually fine. For long-running operations (file uploads, long-polling, WebSocket connections), configure the orchestrator to give more time, or implement a circuit breaker that stops accepting the long operations well before shutdown.\n// Program.cs: extend the graceful shutdown window to 45 seconds builder.Services.Configure\u0026lt;HostOptions\u0026gt;(options =\u0026gt; { options.ShutdownTimeout = TimeSpan.FromSeconds(45); }); Zoom: docker-compose for local development #A docker-compose file is the fastest path to a realistic local environment that mirrors production dependencies. It pairs especially well with the integration tests covered in the TestContainers article, where production-identical images run inside the test process.\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: Three details worth knowing. The depends_on with condition: service_healthy means Compose will wait for Postgres to pass its health check before starting the API, avoiding the race condition where the app starts before the database is ready. The volumes: declaration for pgdata persists the database between docker compose up and docker compose down; use docker compose down -v to reset. The ports: \u0026quot;8080:8080\u0026quot; exposes the API to the host, which is what you want locally but should never end up in a production Compose file.\nZoom: what not to put in the image #A production container image should contain only the application and its runtime dependencies. Things that should never be inside the image:\nSecrets. Connection strings, API keys, certificates, JWT signing keys. These belong in environment variables injected at runtime, or in a secret store (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets). Build tools. The compiler, NuGet, debuggers. The multi-stage pattern keeps these in the build stage. Test projects and test data. Tests run in CI before the image is built; they do not belong in the deployed image. Development configuration files. appsettings.Development.json should either be excluded or copied only in non-production images. Source code. The runtime stage should copy the publish output, not the source. Shipping source to production is a common mistake and a security liability. ❌ Never do this : Do not bake secrets into the image at build time, even as environment variables in the Dockerfile. Anyone who pulls the image (including an attacker with read access to the registry) can recover them. Secrets belong at runtime, never at build time.\nWrap-up #Hosting an ASP.NET Core application on Docker correctly in 2026 means a two-stage Dockerfile with aggressive layer caching, a chiseled base image for security and size, separate liveness and readiness health check endpoints, signal handling through PID 1 for graceful shutdown, and a docker-compose file that matches production dependencies for local development. You can ship a ~100 MB image, run as non-root, expose the right health endpoints for whatever orchestrator comes next, and keep secrets out of the image entirely.\nReady to level up your next project or share it with your team? See you in the next one, Hosting on Kubernetes is where we go next.\nRelated articles # Hosting ASP.NET Core on IIS: The Classic, Demystified Integration Testing with TestContainers for .NET AOT Compilation in .NET: Startup, Size, and Trade-offs References # .NET Docker images on MCR Chiseled Ubuntu images for .NET, Microsoft Learn Health checks in ASP.NET Core, Microsoft Learn Docker Compose specification Dockerfile reference ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/hosting-docker/","section":"Posts","summary":"","title":"Hosting ASP.NET Core with Docker: A Pragmatic Guide"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/iis/","section":"Tags","summary":"","title":"Iis"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/integration/","section":"Tags","summary":"","title":"Integration"},{"content":"Integration testing has long been the most compromised discipline in .NET delivery. Not because engineers did not care, but because the available tools forced a choice between fragile shared infrastructure and tests that quietly stopped being integration tests at all. If you have read the previous article on Unit Testing in .NET, you already know that mocking a DbContext cannot catch the bugs that live in the generated SQL. What the industry needed was a way to run integration tests against the real services they claim to integrate with, reproducibly, without coordinating a shared environment.\nTestContainers provides exactly that. The original Java library was released in 2015 by Richard North, and the .NET port landed in 2017 as Testcontainers for .NET. It is now the reference standard, maintained under the testcontainers GitHub organization, and .NET 10 treats it as a first-class integration testing tool. The principle is straightforward: your test code starts a real Postgres, Redis, RabbitMQ, Keycloak, or any other service inside an ephemeral Docker container, waits for it to become ready, exposes its connection details, and tears it down when the test fixture disposes.\nWhy this pattern exists #For most of the history of .NET, writing an honest integration test required accepting five structural problems, none of which had a clean solution.\n1. A shared dev or integration infrastructure. The database, the identity provider, the message broker, the object store: all of them lived on a central environment that every developer and every CI job pointed to. Running two test suites in parallel was a genuine risk: one engineer\u0026rsquo;s fixture data would collide with another\u0026rsquo;s, a cleanup script would wipe a row someone else depended on, and a flaky test would suddenly look like a real regression. Teams defended themselves with locking schemes, naming conventions, and implicit social contracts that broke the moment a new joiner arrived.\n2. CI/CD required network access to these shared services. The build agents needed routes to the dev database, credentials rotated by hand, and firewall rules maintained by another team. Every new pipeline meant a ticket. Every shared-infrastructure outage blocked every build. The test suite was only as available as the least reliable service it talked to.\n3. The setup was extraordinarily easy to break. A single ALTER TABLE applied by one engineer during debugging, a role change in Keycloak, an SSL certificate expiration on the SMTP relay, a stale Redis snapshot: any of these would silently invalidate the test suite for everyone. Mornings began with the question \u0026ldquo;is CI red because of my change, or because someone touched the test environment?\u0026rdquo;.\n4. It required continuous cleanup and maintenance from the developers themselves. Seed scripts drifted out of sync with migrations. Test users accumulated in the identity provider. Orphaned rows piled up in join tables. Someone on the team ended up being the unofficial custodian of the integration environment, and that person\u0026rsquo;s time was never accounted for in sprint planning.\n5. And most importantly: any dependency that did not have an in-memory NuGet package was mocked. This is the most damaging consequence, and the one nobody wants to admit. If your service talked to SQL Server, you had Microsoft.EntityFrameworkCore.InMemory and pretended that counted, even though it silently ignores foreign keys, case sensitivity, and every SQL-specific feature. If it talked to Keycloak, you mocked IAuthenticationService. If it talked to MinIO, you mocked IAmazonS3. If it talked to RabbitMQ, you mocked IBus. The suites were labelled \u0026ldquo;integration tests\u0026rdquo; and were, in practice, fake integration tests: they exercised your code against a fiction you had written yourself. The day the real dependency behaved differently, the tests were silent.\nTestContainers dismantles all five points at once. It replaces shared infrastructure with per-run containers, removes the CI dependency on external services (all the agent needs is Docker), makes setup reproducible from code instead of from a wiki page, moves cleanup from \u0026ldquo;developer discipline\u0026rdquo; to \u0026ldquo;container disposal\u0026rdquo;, and, crucially, removes the last excuse for mocking a dependency that has a Docker image: Postgres with pg_trgm, Keycloak with a full realm, MinIO for S3, RabbitMQ, Kafka, Mongo, Elasticsearch. If the tool has an image, you test against the real thing.\nThe rest of this article is about how to do that cleanly.\nOverview: how it plugs in #Before the code, here is how TestContainers sits in a .NET test project:\ngraph TD A[Test fixture] --\u003e B[Testcontainers library] B --\u003e C[Docker daemon] C --\u003e D[Postgres container] C --\u003e E[Redis container] A --\u003e F[Your SUTe.g. Repository + DbContext] F --\u003e D F --\u003e E The test fixture owns the container lifecycle. The SUT gets a real connection string and has no idea it is talking to a container that will be gone in 20 seconds.\n💡 Info : TestContainers needs Docker running on the machine (Docker Desktop on Windows/macOS, or rootless Docker on Linux). In CI, GitHub Actions and Azure DevOps both provide Docker-in-Docker runners out of the box.\nZoom: a Postgres fixture with xUnit #Here is the minimum setup to spin up Postgres, apply EF Core migrations, and make it available to 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 is xUnit\u0026rsquo;s hook for async setup and teardown. StartAsync() pulls the image (cached after the first run) and waits for Postgres to be ready. Then EF Core applies your real migrations against it.\n✅ Good practice : Pin the image tag (postgres:17-alpine, not postgres:latest). Reproducibility is the point. An unpinned latest that shifts under you silently invalidates every run that preceded it.\nZoom: a test that uses the 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_persists_order_with_lines() { // 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;)] tells xUnit to share the same fixture across all tests in the collection. One container, many tests, fast.\n💡 Info : xUnit v3 still uses collection fixtures for shared expensive resources. The collection guarantees tests inside it do not run in parallel, which is exactly what you want when they share a database.\nZoom: cleaning between tests #Sharing a container across tests means tests can see each other\u0026rsquo;s data. Two common strategies:\n1. Respawn (fastest) : the Respawn library (also by Jimmy Bogard) deletes all rows between tests, keeping the schema:\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); } Call ResetDatabaseAsync in a test constructor or an IAsyncLifetime on the test class.\n2. Transaction rollback : begin a transaction at the start of each test, let the test run, rollback at the end. Faster than Respawn but cannot test code that commits its own transaction.\n⚠️ It works, but\u0026hellip; : An in-memory provider like Microsoft.EntityFrameworkCore.InMemory is tempting because it is fast, but it silently ignores foreign keys, constraints, and SQL-specific behavior. It is fine for testing services with trivial EF logic and dangerous for anything that touches a real query. Prefer a real Postgres container.\n❌ Never do this : Do not point your integration tests at a shared dev database. Two engineers running the suite concurrently will corrupt each other\u0026rsquo;s state, and the failure will look like a flaky test instead of a shared-resource contention. TestContainers removes the underlying cause entirely.\nZoom: the scenarios you could not test before #This is where TestContainers shows its full value. Three concrete examples of things that were effectively impossible (or cost you a week of YAML) before, and that now fit in a fixture.\nPostgres-specific behavior: fuzzy search with pg_trgm #You have a search endpoint that finds customers by approximate name using the pg_trgm extension. No mock can reproduce the ranking of similarity(). The only way to test it is against real 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(); // Enable the extension, create the GIN index, seed the test data. 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_returns_fuzzy_matches_ranked_by_similarity() { 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;); } The test proves the extension is installed, the index is used, and the SQL you wrote ranks the results the way a real user expects. A mock of the repository would validate none of this, because the behavior under test lives inside Postgres, not inside your C# code.\nKeycloak with a real realm, users, roles, and clients #Role-based authorization is notoriously annoying to test. \u0026ldquo;Does /admin/users reject a non-admin?\u0026rdquo; used to require a shared Keycloak, a hand-curated realm, and a convention nobody documented. With TestContainers you import a realm JSON at container startup and you get the whole thing: users, passwords, roles, 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 lives next to the fixture. It contains alice (role admin), bob (role user), a confidential client, scopes, everything your production realm has, pinned as test data. Every run gets a clean Keycloak with the exact same state.\n[Fact] public async Task Admin_endpoint_rejects_non_admin_user() { var token = await GetTokenAsync(\u0026#34;bob\u0026#34;, \u0026#34;bob-password\u0026#34;); // plain user _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); } The test goes through real Keycloak, a real JWT, the real ASP.NET Core authorization pipeline, against the real policy. Nothing is mocked. When your role mapping changes in production, this test tells you before the deploy.\nMinIO for S3-compatible storage #Your code uses AmazonS3Client to upload invoices, generate presigned URLs, and set bucket policies. You want to verify the presigned URL actually downloads the file and expires when it should.\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(); } From here you test real multipart uploads, real presigned URLs, real expiry behavior. The exact same client code runs in production against AWS S3, and in tests against MinIO, because both speak the S3 protocol.\n💡 Info : The pattern generalizes. If a tool has an official Docker image, you can drive it from a fixture: RabbitMQ, Kafka, Mongo, Elasticsearch, Vault, Mailhog. The Testcontainers.* NuGet packages provide pre-built builders for the common ones, and ContainerBuilder handles everything else.\nZoom: composing multiple services #Real apps need more than one dependency. Postgres plus Keycloak plus MinIO plus Redis is a common shape. Compose them in one fixture and start them in parallel:\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 starts them in parallel, saving seconds per test run. The first run pulls images; subsequent runs reuse the Docker image cache and start in under two seconds each.\n✅ Good practice : Put the fixture in a shared testing project and reference it from IntegrationTests, ApiTests, and E2ETests. One source of truth for what your app depends on.\nWhen this is overkill #Not every project needs TestContainers. A service that has no database and talks only to stateless HTTP APIs can test everything with unit tests plus WebApplicationFactory. A prototype that will be rewritten in two months probably does not need the setup cost.\nReach for TestContainers when:\nYou have real EF Core queries whose generated SQL matters. Your tests must prove that a migration applies cleanly. You depend on Redis, a message broker, or an S3-compatible store whose real behavior matters. You have more than one developer and want \u0026ldquo;clone and test\u0026rdquo; to actually work on day one. Wrap-up #You now know how to stand up real databases and dependencies for your integration tests using TestContainers: pick a container builder, wire an xUnit fixture with IAsyncLifetime, apply EF Core migrations against it, share it across a test collection, and reset state between tests with Respawn or a transaction. You can compose Postgres, Redis, and other services in the same fixture and give your team a \u0026ldquo;clone and dotnet test\u0026rdquo; experience that actually works.\nReady to level up your next project or share it with your team? See you in the next one, API Testing with WebApplicationFactory is where we go next.\nRelated articles # Unit Testing in .NET: Fast, Focused, and Actually Useful References # Testcontainers for .NET, official docs Integration tests in ASP.NET Core, Microsoft Learn EF Core testing, Microsoft Learn Respawn on GitHub ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/testing-integration-testing-testcontainers/","section":"Posts","summary":"","title":"Integration Testing with TestContainers for .NET"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/layered/","section":"Tags","summary":"","title":"Layered"},{"content":"Unit tests, integration tests, API tests, and end-to-end tests all share one quiet assumption: they run one user at a time. That assumption is comfortable, productive, and completely blind to the question production will inevitably ask, \u0026ldquo;what happens when a thousand users arrive at once\u0026rdquo;. Load testing exists to answer that question before the answer is a 3 AM phone call.\nThe Testing series covered correctness across the pyramid, from unit tests through integration tests with TestContainers, API tests with WebApplicationFactory, and end-to-end tests with Playwright. Load testing is a different axis entirely. It does not ask \u0026ldquo;does the logic work\u0026rdquo;, it asks \u0026ldquo;does the logic hold up under concurrency, sustained traffic, sudden bursts, and beyond its designed capacity\u0026rdquo;. Four questions, four test types, four articles in this series. This article is the map.\nWhy load testing exists #The traditional excuse for skipping load tests was \u0026ldquo;we will scale when we need to\u0026rdquo;. That works until a marketing campaign, a viral moment, or an integration with a newly popular partner sends ten times the traffic in thirty seconds. At that point, the team discovers, all at once, that the database connection pool is capped at 100, that the cache does not rebuild gracefully under concurrent misses, that a log framework is holding a lock that serializes every request, and that the autoscaler takes four minutes to react to a burst that lasts two.\nLoad testing surfaces all of this before the incident. More concretely, it answers four specific questions that production will ask:\nWhat does \u0026ldquo;normal\u0026rdquo; look like? Without a reference point, there is no way to detect that a deployment made things worse. Does the system degrade gracefully over hours or days? Memory leaks, connection exhaustion, log rotation bugs, cache staleness: these only appear after sustained operation. Where does the system break, and how does it break? Understanding the failure mode matters as much as knowing the breaking point. How does the system react to sudden bursts? Autoscaling, backpressure, queue depth, and cold caches all behave differently under a gradual ramp-up than under a spike. Each question has a dedicated load test type. None of them replaces the others.\nOverview: the four types # graph TD A[Load testing] --\u003e B[BaselineEstablish normalsteady-state] A --\u003e C[SoakLong durationmoderate load] A --\u003e D[StressBeyond capacityfind the break] A --\u003e E[SpikeSudden burstfrom low to high] B --\u003e B1[Referencefor regression] C --\u003e C1[Leaks, pool exhaustion,log growth, cache drift] D --\u003e D1[Breaking point,capacity planning] E --\u003e E1[Autoscale response,cold cache, backpressure] Baseline runs the system under the traffic it is expected to handle every day, for long enough to produce stable numbers. The output is a set of reference metrics: requests per second, latency percentiles, error rate, CPU, memory, database pool usage. Every subsequent load test is compared against this reference.\nSoak runs the same moderate load for hours, often overnight, sometimes for days. Its job is not to measure peak throughput, it is to verify that the system does not degrade over time. Memory leaks, connection pool exhaustion, log file growth, cache invalidation drift, and background task pile-ups all show up here and nowhere else.\nStress pushes the system past its designed capacity and keeps pushing until something gives. The goal is not to prove the system can handle infinite load, it is to characterize the failure mode: does latency grow linearly, then explode? Does the error rate climb before latency does? Does the system recover cleanly when the stress is removed?\nSpike starts from a quiet state and ramps to a very high load within seconds. This is the test that exposes autoscaling lag, cold cache penalties, connection burst handling, and the warmup cost of JIT-compiled code paths. A system that handles a gradual ramp-up perfectly can still collapse under a spike.\nEach of these has its own article in this series. The rest of this overview covers the shared vocabulary and the toolchain choices that apply to all four.\nZoom: the metrics that matter #Every load test, regardless of type, should report the same set of numbers. If any of these is missing, the test is incomplete.\nThroughput measured in requests per second (RPS). The raw count of work the system handles in a unit of time. High RPS is only meaningful paired with the next metric.\nLatency percentiles: p50, p95, p99, and p99.9. The average latency is almost never useful, because a system where 90% of requests take 20 ms and 10% take 2 seconds has the same average as a system where every request takes 220 ms, and the two are not the same to a user. Report percentiles, always.\nError rate, broken down by status code. A test that holds a p95 under 100 ms while silently serving 4% of requests as 500s is not a passing test, it is a misleading one.\nSaturation signals from the .NET runtime and the infrastructure: CPU, memory, GC pause times (gen0/1/2), thread pool queue length, database connection pool wait time, HTTP client connection count. These tell you why the latency rose, which is the actionable half of the information.\nCorrelation with business transactions, not just HTTP endpoints. A test that reports \u0026ldquo;POST /orders p95 is 300 ms\u0026rdquo; is less useful than one that reports \u0026ldquo;the checkout flow (add to cart, apply discount, submit order, confirm payment) p95 is 1.2 seconds\u0026rdquo;. The user experience is the composition of the individual endpoints, not any single one.\n💡 Info : In modern .NET (8+), System.Diagnostics.Metrics and the built-in http.server.request.duration histogram expose these numbers natively. Feeding them to Prometheus and Grafana is a couple of lines of configuration and is the foundation for everything in this series.\nZoom: tools landscape in 2026 #Two .NET-friendly tools cover 90% of real use cases, and the choice between them is mostly about where the test code lives.\nk6 (Grafana Labs) is the current industry standard. Tests are written in JavaScript, run by a Go-based runner, and scale to hundreds of thousands of virtual users from a single machine. k6 integrates cleanly with Grafana for visualization, with Prometheus as a metrics sink, and with most CI systems. It is language-agnostic, which is a strength if your team ships more than one backend stack, and a neutral point if you ship only .NET.\n// k6: a baseline test, 50 virtual users for 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 is the .NET-native option. Tests are written in C# or F#, live in a regular .NET project, share types with the application under test, and run from dotnet test or a console host. The advantage is that the load test suite is code the team already knows how to read, review, and refactor.\n// NBomber: same baseline, written in 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(); Both are production-grade. For .NET teams that prefer to keep everything in C#, NBomber is the lower-friction choice. For teams that want the larger community and ecosystem, k6 is the safer bet. JMeter, Gatling, Artillery, and Locust all exist and have legitimate use cases, but for a greenfield .NET project in 2026, k6 or NBomber is the default recommendation.\n✅ Good practice : Write the load test code in the same repository as the application, next to the integration tests. Load tests are part of the codebase, not a separate folder on someone\u0026rsquo;s laptop.\nZoom: where load tests run #A load test against a developer laptop is almost always meaningless. The network, the local database, the shared CPU with every IDE and browser open, and the lack of realistic infrastructure all distort the result. The useful environments are:\nA dedicated pre-prod environment that mirrors production sizing and topology. This is the default target for baseline, soak, and spike tests. A clone of production, stood up for a scheduled test window. More expensive, more accurate, reserved for stress tests and capacity planning exercises. Production itself, with a controlled subset of traffic, for advanced teams practicing continuous load testing. This requires observability maturity that most teams do not have, and it is not the starting point. For most teams, the right answer is a pre-prod environment provisioned from the same Infrastructure-as-Code as production, with the same database size class, the same cache, and the same dependencies spun up through TestContainers where a real managed service is not available.\n⚠️ It works, but\u0026hellip; : Running load tests against a free-tier cloud database or a small dev container will produce numbers that look terrible compared to production, or worse, numbers that look great and are completely wrong. Pay attention to the sizing of the target, not only the sizing of the load generator.\nZoom: what load tests do not catch #Load tests are not a replacement for any other layer of the test pyramid. They do not catch:\nLogic bugs: the calculator can be wrong and still handle 10,000 RPS. That is a unit test problem. Authorization holes: a broken role check is fast. Fast and wrong is worse than slow and correct. That is a WebApplicationFactory test problem. Data migration correctness: load tests against a broken migration will simply fail with broken data. Run migrations in a real database first, via integration tests with TestContainers. UI-level race conditions: those belong in Playwright E2E tests. Load tests sit on top of a correct system, not instead of one. Running them before the rest of the pyramid is green is a waste of the load generator\u0026rsquo;s time and a source of false confidence.\nWrap-up #You now have a map of the four load test types that matter for a .NET system: baseline to establish what normal looks like, soak to verify the system holds up over time, stress to find the breaking point and its shape, and spike to validate autoscaling and burst handling. You can pick k6 or NBomber as a default runner, capture throughput, latency percentiles, error rate, and saturation signals for every test, and run against a pre-prod environment that actually mirrors production.\nReady to level up your next project or share it with your team? See you in the next one, Baseline Testing is where we go next.\nRelated articles # Unit Testing in .NET: Fast, Focused, and Actually Useful Integration Testing with TestContainers for .NET API Testing with WebApplicationFactory in ASP.NET Core End-to-End Testing with Playwright for .NET References # k6 documentation NBomber documentation ASP.NET Core metrics, Microsoft Learn .NET diagnostics and performance, Microsoft Learn Google SRE book: Handling Overload ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/load-testing-overview/","section":"Posts","summary":"","title":"Load Testing for .NET: An Overview of the Four Types That Matter"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/load-testing/","section":"Tags","summary":"","title":"Load-Testing"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/memory/","section":"Tags","summary":"","title":"Memory"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/n-layered/","section":"Tags","summary":"","title":"N-Layered"},{"content":"N-Layered architecture is probably the first pattern you encountered as a .NET developer. It is everywhere: legacy codebases, tutorials, enterprise projects. Before you move to Clean Architecture or Vertical Slicing, you need to truly understand this one. Not just follow it blindly, but know why it exists, where it holds up, and when it starts hurting you.\nA bit of history #Before diving into the theory, a bit of history matters. In the early 2000s, Microsoft\u0026rsquo;s own tutorials, the default Visual Studio project templates (WebForms, and later the first MVC scaffolding), and most of the official docs actively pushed developers toward mixing concerns. You would drop a SqlDataSource straight into your .aspx markup, wire business rules inside a button\u0026rsquo;s code-behind, and sprinkle raw ADO.NET calls inside what would later become controllers. It shipped fast, it demoed well, and it rotted just as fast the moment the app grew past a handful of screens. N-Layered architecture did not fall from an ivory tower of abstract theory: it emerged as the community\u0026rsquo;s pragmatic response to that mess, a way to draw clear lines between what the user sees, what the business decides, and what the database stores. Understanding that origin makes the rest of this article click.\nWhy it exists: the real problem it solves #Imagine you join a project. The codebase is a single Web project. Controllers query the database directly. Business logic lives inside if blocks in action methods. A bug in the billing calculation forces you to touch the same file that renders the invoice HTML. A new developer breaks payment logic while fixing a UI label.\nThat\u0026rsquo;s spaghetti. N-Layered architecture exists to prevent exactly this.\nThe idea is simple: split responsibilities into horizontal layers that can only talk to the layer directly below them. Each layer has one job.\nIt gives you:\nSeparation of concerns, so UI code never touches the database directly Testability, so business logic is isolated and can be unit tested without spinning up HTTP Replaceability, so you can swap Entity Framework for Dapper without touching your service layer Overview: the layers # graph TD A[Presentation LayerControllers / Minimal API] --\u003e B[Service LayerBusiness Logic] B --\u003e C[Repository LayerData Access] C --\u003e D[DatabaseSQL Server / PostgreSQL] E[Domain / ModelsEntities + DTOs] -.-\u003e A E -.-\u003e B E -.-\u003e C Layer Responsibility Typical contents Presentation Handle HTTP, map DTOs, return responses Controllers, Minimal API endpoints Service Orchestrate business rules OrderService, InvoiceService Repository Abstract data access IOrderRepository, EF Core / Dapper impl Domain/Models Shared contracts Entities, DTOs, Enums, Interfaces Each layer in detail #Domain / Models: the shared contract #This is not really a \u0026ldquo;layer\u0026rdquo; in the strict sense. It is a shared project that everyone references. Keep it lean: entities, DTOs, enums, and repository/service interfaces.\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 ); DTOs vs Entities: drawing the line at the boundary #A DTO (Data Transfer Object) and an Entity look similar on a UML diagram, but they live completely different lives. An Entity is the shape your persistence layer cares about: it mirrors your database schema, it is tracked by EF Core\u0026rsquo;s change tracker, it carries navigation properties, and its lifecycle is tied to a DbContext. A DTO is the shape your API contract cares about: it is a flat, serializable, intent-specific payload that crosses the HTTP boundary and nothing else. Same fields sometimes, never the same job.\nReturning EF Core entities directly from your controllers feels like a shortcut. It is actually four bugs waiting to happen:\nOver-posting: a client POSTs {\u0026quot;id\u0026quot;: 42, \u0026quot;isAdmin\u0026quot;: true, \u0026quot;total\u0026quot;: 0} and your model binder happily populates fields the caller should never be allowed to touch. Lazy-loading serialization: the JSON serializer walks a navigation property, triggers a query outside the original scope, and you either get an ObjectDisposedException or a surprise N+1 storm in production. Schema leaks: every column you add to the table instantly becomes part of your public API. Rename a field in the DB, break every client. Versioning hell: you cannot evolve the entity and the contract independently. A pure refactor on the data side becomes a breaking API change. The fix is boring and effective: accept a request DTO, map it to an entity inside the service, persist, then map the result back to a response DTO.\nflowchart LR Client([HTTP Client]) --\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[(Database)] DB --\u003e Ent2[OrderEntity] Ent2 --\u003e|map| Res[OrderResponseDTO] Res --\u003e Ctrl Ctrl --\u003e|200 OK| Client subgraph API_Boundary[API boundary: DTOs only] Req Res end subgraph Domain[Domain and persistence: entities only] 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 Explicit mapping, no AutoMapper, no reflection magic:\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()); } ✅ Good practice : keep DTOs per use case, not per entity. CreateOrderRequest, UpdateOrderStatusRequest, OrderSummaryResponse, and OrderDetailsResponse are four small, intention-revealing types. One giant OrderDto reused in six endpoints always ends up with half its fields nullable, half its fields ignored, and a comment that reads \u0026ldquo;do not set this field when calling X\u0026rdquo;. That is not a DTO, that is a trap.\nRepository Layer: data access only #The repository pattern wraps your data access technology. The interface lives in Domain, the implementation lives in 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\u0026lt;IReadOnlyList\u0026lt;Order\u0026gt;\u0026gt; GetByCustomerAsync( string customerId, CancellationToken ct = default) =\u0026gt; await _db.Orders .AsNoTracking() .Where(o =\u0026gt; o.CustomerId == customerId) .ToListAsync(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); } ✅ Good practice : Always use AsNoTracking() for read-only queries. EF Core won\u0026rsquo;t track the entity in the change tracker, which reduces memory overhead and speeds up reads.\n❌ Never do this : Don\u0026rsquo;t expose IQueryable\u0026lt;T\u0026gt; from your repository interface. It leaks your ORM abstraction upward and makes your service layer dependent on EF Core internals.\nService Layer: business logic lives here #This is where your rules live. Not in controllers, not in repositories. The service receives a request, validates it, applies business rules, calls the repository, and returns a result.\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;An order must have at least one line.\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;Order {OrderId} created for customer {CustomerId}\u0026#34;, order.Id, order.CustomerId); return order.Id; } } ⚠️ It works, but\u0026hellip; : Throwing ValidationException directly in the service is acceptable for simple cases. In larger codebases, consider the Result pattern to avoid using exceptions for control flow. See the Error Handling series for a deep dive.\nPresentation Layer: thin controllers only #Controllers should be thin. Their only job: receive the HTTP request, call the service, map the result to an HTTP response. Zero business logic here.\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); } } ✅ Good practice : Use CancellationToken in every async controller action and pass it all the way down to the database call. When a user cancels their request or a load balancer times out, EF Core will cancel the query rather than letting it run to completion for nothing.\nSolution structure #MyApp.sln ├── src/ │ ├── MyApp.Api/ ← Presentation (Controllers, Program.cs, DI setup) │ ├── MyApp.Application/ ← Services (business logic) │ ├── MyApp.Infrastructure/ ← Repositories, EF Core, external integrations │ └── MyApp.Domain/ ← Entities, DTOs, Interfaces (no dependencies) └── tests/ ├── MyApp.Application.Tests/ └── MyApp.Infrastructure.Tests/ 💡 Info : MyApp.Domain should have zero external NuGet dependencies. If you find yourself adding EF Core or any framework package to Domain, something is wrong with your dependency direction.\nWhere N-Layered starts hurting #This architecture works very well for small to medium applications. It starts showing cracks when your codebase grows:\nAnemic services: you end up with a ProductService that has 25 methods, one per use case. It becomes impossible to navigate. Fat repositories: repositories accumulate custom query methods until they become unmaintainable. Cross-feature coupling: adding a new feature requires touching every layer, every time. This is not a reason to avoid N-Layered, it is a signal to evolve. The natural next step is Clean Architecture (which enforces dependency direction) or Vertical Slicing (which organizes by feature instead of by layer).\nWrap-up #You now understand what N-Layered architecture is, how each layer relates to the others, and how to implement it correctly in a real .NET solution. You can structure a new project from scratch, keep controllers thin, isolate business logic in services, and abstract data access behind repository interfaces.\nReady to level up your next project or share it with your team? See you in the next one, Clean Architecture is waiting.\nReferences # Common web application architectures, Microsoft Learn N-tier architecture style, Azure Architecture Center ASP.NET Core fundamentals, Microsoft Learn Entity Framework Core, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/code-structure-n-layered/","section":"Posts","summary":"","title":"N-Layered Architecture in .NET: The Foundation You Need to Master"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/performance/","section":"Categories","summary":"","title":"Performance"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/playwright/","section":"Tags","summary":"","title":"Playwright"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/soak/","section":"Tags","summary":"","title":"Soak"},{"content":"A system can pass every unit test, every integration test, every API test, every Playwright E2E, and the baseline load test, and still fall over at 4 AM on the third day after deployment. The bugs that cause this have a common signature: they only appear after hours of sustained operation. Memory that leaks one kilobyte per request. A connection pool that creeps from 40 to 99 over the course of a weekend. A log file that reaches the disk quota on day six. A cache that drifts out of sync because an invalidation event is occasionally lost under load.\nNone of these show up in a 10-minute baseline. All of them show up in a soak test. That is the whole value proposition of soak testing: run the system at moderate, sustained load for long enough that time-dependent bugs have a chance to surface.\nWhy soak tests exist #The traditional story about sudden production incidents is wrong. Most production incidents are not sudden. They are slow failures that look sudden because nobody was watching the gradient. A 2% daily growth in memory usage is invisible on a graph that spans one hour, and unmissable on a graph that spans seven days. A background job that leaks one thread per run is fine at 1 run per hour and catastrophic at 10 runs per minute. These are the failure modes that a soak test is designed to catch.\nConcretely, soak tests answer four questions that no other test type answers:\nDoes memory stay stable under sustained load? A true memory leak produces a monotonically rising working set. A garbage collection that keeps up with allocations produces a sawtooth pattern that stays bounded. The difference is only visible over time. Do connection pools stay healthy? Database pools, HTTP client pools, gRPC channels, message broker connections, all of them have a max size. An occasional leak of one connection per hour is invisible at minute ten and fatal at hour eighteen. Does disk usage stay bounded? Logs, temporary files, dead-letter queues, failed job tables. Any of these can grow without bound if rotation, pruning, or cleanup is broken. Do caches, queues, and background state stay consistent? Cache invalidation under concurrent writes, queue depth under varying consumer speed, scheduled jobs that do not clean up after themselves, all of these drift over time and only reveal themselves after hours. Overview: the shape of a soak run # graph TD A[Moderate load50-70% of baseline] --\u003e B[Duration4 to 24 hours] B --\u003e C[Continuous metrics] C --\u003e D1[Working setgrowth rate] C --\u003e D2[GC heapgen0/1/2 sizes] C --\u003e D3[Pool wait timesDB, HTTP, threads] C --\u003e D4[Disk usagelog files, tmp] C --\u003e D5[Latencydrift over time] A soak test is not a peak-throughput test. The load is kept deliberately moderate, usually 50 to 70 percent of what the baseline establishes as normal, so that the system has headroom for real work and the test stresses duration, not intensity. The duration is the variable: four hours for a first run, overnight for a pre-release validation, multi-day for a platform-level change (EF Core upgrade, runtime upgrade, infrastructure migration).\nThe output of a soak test is not a single number. It is a set of time-series graphs showing how metrics evolve over the run. A run that reports \u0026ldquo;p95 was 120 ms average\u0026rdquo; and nothing else is a failed soak test, because the average tells you nothing about whether the latency climbed from 90 ms to 160 ms over the window, which is the actual question.\nZoom: k6 soak configuration #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 at 30 VUs (~60% of baseline) { duration: \u0026#39;1m\u0026#39;, target: 0 }, // cooldown ], thresholds: { // Notice the sliding window: the soak fails if *any* hour degrades. \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;], }, // Stream results to Prometheus so drift is visible live. 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); } Eight hours, thirty virtual users, moderate load. The sleep(2) between requests is deliberate: a soak is not meant to maximize throughput, it is meant to keep the system under continuous, realistic pressure for a long time.\n✅ Good practice : Run the soak with results streaming live to Grafana (or any dashboard). The most useful moment in a soak is not the end, it is the point where you notice the slope changing. Waiting for the final report defeats the purpose.\nZoom: what to watch during the run #The load generator captures request-level metrics. The real signal lives on the application side. For a .NET system, the minimum dashboard during a soak run shows:\nWorking set and GC heap over time. The process.runtime.dotnet.gc.heap.size metric, broken down by generation, plotted against wall-clock time. A healthy system shows a stable or sawtooth pattern. A leak shows a rising trend that never resets, even after gen2 collections.\nDatabase connection pool metrics. The pool_wait_time and pool_in_use counters from Npgsql, SqlClient, or whichever provider is in use. A pool that starts at 10 in-use and creeps to 90 over six hours has a connection leak somewhere, and the soak is the test that catches it.\nThread pool queue length. System.Runtime counters expose threadpool-queue-length and threadpool-thread-count. A queue that grows without bound means work is arriving faster than threads can handle it, usually because of a sync-over-async pattern that is only visible under sustained load.\nRequest latency distribution, over time, not averaged. A Grafana heatmap of http_server_request_duration per endpoint tells you whether p95 is stable or drifting upward. The drift, if it exists, is the bug.\nDisk usage on the host. A simple df check reported every minute catches log rotation failures, temporary file leaks, and dead-letter queue bloat before they take the process down.\n// Program.cs: expose the metrics soak tests need 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, lock contention .AddProcessInstrumentation() // CPU, memory, handles .AddPrometheusExporter(); }); 💡 Info : AddRuntimeInstrumentation comes from the OpenTelemetry.Instrumentation.Runtime NuGet package and is the single most useful line a .NET team can add to a soak-testable system. It exposes GC heap sizes, thread pool queue length, and lock contention with zero custom code.\nZoom: reading the results #A soak test produces three typical outcomes.\nFlat and stable. All metrics stay within their starting band for the whole duration. Latency sawtooths, GC recovers, pools stay stable, disk usage stays flat. The soak passes, and the team has evidence that the system can run for as long as the test duration.\nGradual drift. Latency climbs slowly, memory trends upward, or one pool grows. This is the diagnostic case the soak exists to catch. The team looks at the slope and asks, \u0026ldquo;at this rate, when do we hit the limit?\u0026rdquo;. A linear leak of 50 MB per hour, on a 16 GB machine, gives you about two weeks. A sublinear drift may still be acceptable. A super-linear drift is a red alert, because it will not simply take twice as long to fail at twice the load, it will fail much faster.\nCliff edge. Everything looks fine for six hours, then a pool exhausts, a circuit breaker trips, or the process OOMs. The timing of the cliff is useful information: it tells you where the hidden limit is and gives the team a concrete target to fix.\n⚠️ It works, but\u0026hellip; : A soak that shows no drift over 8 hours is not a proof that the system can run for 8 days. Duration coverage grows non-linearly: weekly cron jobs, monthly batch runs, and seasonal load patterns will only be stressed by longer runs. Soak is a confidence signal, not a guarantee.\n❌ Never do this : Do not run a soak test and only report the final number. Latency p95 averaged over 8 hours hides the entire story. The story is in the time-series graph. If the report does not include a graph, the report is incomplete.\nZoom: when to run a soak #Soak tests are expensive in elapsed time, even though they are cheap in compute. Three cadences cover most teams:\nBefore every platform upgrade. A .NET runtime upgrade, an EF Core major version bump, a Kubernetes cluster migration, a change of database engine version: any of these warrants a full overnight soak before rolling out to production. This is where the highest-value bugs hide.\nWeekly, scheduled. A once-a-week 8-hour soak, running Saturday night into Sunday morning, catches the regressions that accumulated during the week and establishes a rolling baseline for long-duration behavior.\nOn suspicion. When a production incident has \u0026ldquo;degraded slowly over hours\u0026rdquo; in its post-mortem, the follow-up is almost always a soak test designed to reproduce the degradation in pre-prod, with the offending component instrumented harder than usual.\nWhen soak is the wrong tool #Soak tests are the right answer for time-dependent failure modes. They are the wrong answer for:\nPeak throughput questions: that is a stress test. Burst handling: that is a spike test. Logic correctness under concurrency: that is an integration test with parallel workers, or a race-condition hunt, not a soak. Finding the breaking point: stress tests find it, soak tests do not push hard enough to reach it. Running a soak to answer a stress question means waiting eight hours for a conclusion a one-hour stress test would have delivered.\nWrap-up #A soak test is what reveals the bugs that live in the gap between a healthy ten-minute run and a multi-day production deployment. You can set one up in k6 or NBomber in an afternoon, keep the load moderate (50 to 70 percent of baseline), run it for four to twenty-four hours against a realistic pre-prod environment, and watch time-series metrics live rather than waiting for a final report. You can catch leaking connection pools, drifting caches, growing log files, and linear memory leaks before they become a production incident, and you can distinguish gradual drift, cliff-edge failure, and flat stable behavior from the shape of the graph.\nReady to level up your next project or share it with your team? See you in the next one, Stress Testing is where we go next.\nRelated articles # Load Testing for .NET: An Overview of the Four Types That Matter Baseline Load Testing in .NET: Knowing What Normal Looks Like API Testing with WebApplicationFactory in ASP.NET Core References # k6 stages and scenarios OpenTelemetry Runtime Instrumentation for .NET ASP.NET Core metrics, Microsoft Learn .NET garbage collection fundamentals, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/load-testing-soak/","section":"Posts","summary":"","title":"Soak Testing in .NET: The Bugs That Only Appear After Hours"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/spike/","section":"Tags","summary":"","title":"Spike"},{"content":"A system can pass a baseline, hold up in a soak, recover cleanly from a stress test, and still fail its most visible public moment: the exact second traffic goes from quiet to overwhelming, without warning. Black Friday at midnight, a viral tweet pointing at a landing page, a marketing email delivered to five hundred thousand inboxes at once, a partner integration whose cron job fires on the hour. These are the moments the team remembers, and they are not what a gradual stress ramp prepares for.\nSpike testing is the last of the four test types introduced in the overview article. It answers a single, specific question: when traffic goes from near-zero to very high in under ten seconds, does the system stay up, degrade gracefully, or collapse.\nWhy spike tests exist #A stress test with a smooth ramp gives a system every chance to adapt: CPU caches warm up, the JIT compiles hot paths, the database connection pool grows to meet demand, the autoscaler reacts and provisions new instances. A spike gives the system none of that. It starts quiet, and fifteen seconds later it is overwhelmed. The systems that die in spikes are the ones that needed the ramp.\nConcretely, spikes expose four distinct weaknesses that no other test type stresses as hard:\nCold cache penalty. Distributed caches are fine until every node misses at once. The database gets hit by the full traffic, amplified by a thundering herd of concurrent misses, and collapses before the cache has time to rehydrate. Autoscale lag. Kubernetes horizontal pod autoscalers, Azure Container Apps, AWS ECS, and every other autoscaler has a reaction time. That time is usually measured in minutes. A spike lasting ninety seconds is over before any new instance comes online. Connection pool startup cost. Database drivers, HTTP clients, and message broker connections take time to establish. An application that starts with a pool of 10 connections and needs 200 will spend the first thirty seconds of the spike timing out while the pool grows. JIT compilation and warmup. .NET JITs methods on first call. Tier-0 methods get re-JITted at tier-1 after they prove hot. A spike hits the system before the hot paths are tier-1 compiled, which can double the latency of the first thousand requests. None of these are visible in a steady-state test. All of them are visible in a spike test, and all of them are fixable, usually with configuration changes and warmup strategies that cost very little.\nOverview: the shape of a spike run # graph LR A[Idle or very low5-10 VUs] --\u003e B[Sudden jump10 -\u003e 500 VUsin under 30s] B --\u003e C[Hold at peak2-5 min] C --\u003e D[Drop backto idle] D --\u003e E[Observesecond spikeif needed] A spike test has four phases, each with a specific purpose.\nThe idle phase establishes that the system is quiet. Low or zero traffic, for a minute or two. This is the state the spike will interrupt.\nThe jump is the defining characteristic of the test. The ramp happens in seconds, not minutes. A spike is meant to catch the system unprepared. If the ramp is gradual, the test is a stress test, not a spike.\nThe peak hold keeps the high load for two to five minutes. Long enough for the autoscaler (if any) to react, the JIT to warm up, the cache to rehydrate, and the connection pools to grow. This phase answers the question \u0026ldquo;does the system recover while still under load\u0026rdquo;.\nThe drop returns to idle. Optionally, a second spike follows a minute later, to test whether the system is actually ready for the next burst or if it is still recovering from the first.\nZoom: a spike test with 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 }, // the spike: 10 -\u0026gt; 500 in 10s { duration: \u0026#39;3m\u0026#39;, target: 500 }, // hold at peak { duration: \u0026#39;10s\u0026#39;, target: 10 }, // drop back { duration: \u0026#39;30s\u0026#39;, target: 10 }, // recovery observation { duration: \u0026#39;10s\u0026#39;, target: 500 }, // second spike (optional) { duration: \u0026#39;1m\u0026#39;, target: 500 }, { duration: \u0026#39;10s\u0026#39;, target: 0 }, ], thresholds: { // Spike tests have looser thresholds: the goal is \u0026#34;still up\u0026#34;, not \u0026#34;baseline latency\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); // tight loop: spikes maximize pressure } Ten to five hundred virtual users in ten seconds, held for three minutes, dropped, held at low, then spiked again. The thresholds are deliberately looser than a baseline or a stress test, because the question is not \u0026ldquo;did performance stay at baseline\u0026rdquo; but \u0026ldquo;did the system stay available through the spike and the second spike\u0026rdquo;.\n✅ Good practice : Run the spike test against a system that has been idle for at least ten minutes before the test starts. A spike against a warm system is not a spike, it is a stress test. Coldness is the whole point.\nZoom: the same test with 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)), // The spike: ramp 10 -\u0026gt; 500 in 10 seconds Simulation.RampingConstant(copies: 500, during: TimeSpan.FromSeconds(10)), // Hold Simulation.KeepConstant(copies: 500, during: TimeSpan.FromMinutes(3)), // Drop Simulation.RampingConstant(copies: 10, during: TimeSpan.FromSeconds(10)), // Recovery 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 with a 10-second duration from 10 to 500 virtual users is the NBomber equivalent of k6\u0026rsquo;s spike stage. Everything else is a matter of phase sequencing, which NBomber expresses as an ordered list of LoadSimulation entries.\nZoom: what to watch during a spike #Five signals matter during a spike, and all of them need sub-second resolution in the dashboard to be readable at all.\nTime-to-first-response after the spike begins. How many seconds pass between the load jumping and the first 200 OK being served under the new load. This is often the single most useful number: it captures JIT warmup, connection pool growth, and cache rehydration in one metric.\nConnection pool growth curve. For Npgsql or SqlClient, plot pool_in_use over time. During a spike, the pool should grow quickly to match demand. If it plateaus early, the pool has reached its configured maximum and the team has found the first bottleneck.\nDatabase query latency distribution. During a spike with a cold cache, the database is the first place to feel the pain. Plot the per-second p95 of query duration. Look for the moment it peaks, then returns to baseline. The delta is the cold-cache cost.\nAutoscaler events. If the system runs on Kubernetes or a container orchestrator with autoscaling, log the pod count over time. Compare the scale-up moment to the start of the spike. The gap is the autoscale lag, and it is almost always longer than teams expect.\nError rate per endpoint. During a spike, certain endpoints fail before others. Plot error rate per endpoint to identify which one broke first. That is your next fix target.\n// Program.cs: expose the minimal metrics needed for a spike test builder.Services.AddOpenTelemetry() .WithMetrics(metrics =\u0026gt; metrics .AddMeter(\u0026#34;Microsoft.AspNetCore.Hosting\u0026#34;) .AddMeter(\u0026#34;Microsoft.EntityFrameworkCore\u0026#34;) // query duration .AddMeter(\u0026#34;Npgsql\u0026#34;) // pool_in_use .AddRuntimeInstrumentation() // GC, thread pool .AddPrometheusExporter()); 💡 Info : Grafana\u0026rsquo;s default time resolution is 15 or 30 seconds, which is too coarse for a 90-second spike. Set the scrape interval to 1 second and the dashboard refresh to 1 second during spike tests. Otherwise the graph will show two points on the entire spike and nothing will be diagnosable.\nZoom: the four common spike failures #Cold cache thundering herd. Every request hits the cache, every cache lookup misses, every miss hits the database, and the database sees 500 concurrent identical queries. The fix is request coalescing or a lock around cache rehydration, so only the first miss triggers a database query while the others wait.\nConnection pool exhaustion. The default Npgsql pool caps at 100 connections. An instance handling 400 concurrent requests during a spike will block 300 of them waiting for a connection. The fix is either a larger pool (if the database can handle it) or a concurrency limiter in front of the endpoint (to shed load rather than queue it).\nAutoscaler lag. The autoscaler is configured to add pods when CPU exceeds 70%. The spike drives CPU to 100% in 10 seconds, the autoscaler reacts in 60 seconds, and the first new pod is ready in another 45 seconds. The first 90 seconds of the spike run with half the needed capacity. The fix is pre-warming: run more idle capacity, or use predictive autoscaling, or pre-scale before an expected event (midnight sale).\nJIT warmup cost. The first thousand requests after a cold start are served by tier-0 JIT-compiled code, which is slower than tier-1. In a spike, those first thousand requests happen in a few seconds, and their latency is two to three times baseline. The fix is ReadyToRun (R2R) compilation, AOT, or a warmup endpoint that the orchestrator calls before declaring the pod healthy.\n⚠️ It works, but\u0026hellip; : A spike test that triggers none of these failures on the first run is usually a sign that the target system is not configured the way production will be. Check that the cache is actually empty, the database pool is at its production default, and the replica count matches production minimum. Otherwise the test is confirming the wrong thing.\n❌ Never do this : Do not run spike tests immediately after another load test. The system is warm, the pools are full, the caches are populated. A spike against a warm system tells you nothing. Either wait ten minutes for idle, or restart the target.\nZoom: when to run a spike #Spike tests are less routine than baselines but more targeted. Three triggers:\nBefore an expected traffic event. A product launch, a marketing campaign, a known external integration going live. If the team knows a spike is coming in production, rehearse it in pre-prod first.\nAfter a deployment topology change. New autoscaling rules, a different instance type, a new cache backend, a database migration. Any of these can change spike behavior without showing up in a baseline or a stress test.\nWhen a production incident says \u0026ldquo;traffic jumped and we fell over\u0026rdquo;. The follow-up is always a spike test in pre-prod, with the exact traffic shape from the incident, and the exact infrastructure configuration from the incident. The goal is to reproduce the failure, fix it, and prove the fix works.\nWrap-up #A spike test is the only test that measures how a system survives a sudden jump from quiet to overwhelmed. You can set one up in k6 or NBomber in an afternoon, start from an idle state (not a warm one), jump from low to high in under thirty seconds, hold at peak for a few minutes, optionally trigger a second spike to test recovery readiness, and watch time-to-first-response, pool growth, autoscale lag, and cold-cache cost with sub-second dashboard resolution. You can walk out knowing which of the four common spike failures your system would hit, and you can plan the fixes (request coalescing, larger pools, pre-warming, ReadyToRun compilation) before the next marketing campaign makes them urgent.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # Load Testing for .NET: An Overview of the Four Types That Matter Baseline Load Testing in .NET: Knowing What Normal Looks Like Soak Testing in .NET: The Bugs That Only Appear After Hours Stress Testing in .NET: Finding the Breaking Point and Its Shape References # k6 spike testing guide NBomber load simulations ReadyToRun compilation, Microsoft Learn Horizontal Pod Autoscaler in Kubernetes Google SRE book: Handling Overload ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/load-testing-spike/","section":"Posts","summary":"","title":"Spike Testing in .NET: Surviving the Sudden Burst"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/stress/","section":"Tags","summary":"","title":"Stress"},{"content":"A baseline tells you what normal looks like. A soak tells you whether the system holds up over time. Neither answers the question that production will eventually force you to care about: when does it break, and how. That is the job of a stress test. Not to prove the system can handle arbitrary load (no system can), but to characterize the exact shape of its failure so the team can design around it.\nThe overview article introduced the four test types. The baseline article covered the reference run. This article covers the one that deliberately breaks the system, learns something from the breakage, and walks away with a concrete capacity plan.\nWhy stress tests exist #Every system has a point past which more traffic makes things worse instead of better. Adding one more request per second starts queuing work faster than the workers can process it. Latency rises, then climbs steeply, then the error rate begins to grow. Eventually something gives: a connection pool saturates, a thread pool starves, a circuit breaker opens, or the process runs out of memory and restarts. The team that learns this in production pays for the lesson with an outage. The team that learns it in a stress test pays for the same lesson with a spreadsheet.\nStress tests answer four questions that no other test type answers:\nWhere is the breaking point? The load (in RPS or concurrent users) at which latency explodes, error rate spikes, or the process fails. The number itself is useful for capacity planning. What is the shape of the failure? Linear degradation, exponential degradation, cliff-edge collapse, and cascading failure all demand different remediations. The shape is more actionable than the raw number. Which component gives first? Is it the database connection pool, the thread pool, the memory, the downstream API, the rate limiter? The first bottleneck is the one worth fixing. Does the system recover? Once the stress is removed, does the system return to healthy latency and throughput, or does it stay degraded and require a restart? Recovery behavior matters as much as breaking point. Without a stress test, capacity planning is guesswork. With one, the team has a number, a shape, and a recovery profile.\nOverview: the shape of a stress run # graph LR A[Baseline load50 VUs] --\u003e B[Ramp up+50 VUs every 2 min] B --\u003e C[Observebreaking point] C --\u003e D[Hold past break1-2 min] D --\u003e E[Ramp downobserve recovery] A stress test is a controlled ramp, not a sudden burst. The system starts at baseline load, ramps up in measured steps, and the test captures the point at which the pre-defined service level objective is breached. That point is the breaking point. The ramp continues for a short while past it to characterize the failure mode, then ramps down to observe recovery.\nThree rules shape a useful stress run:\nRamp, do not jump. A sudden burst is a spike test, which is a different question. A stress test wants to see the slope of degradation, which requires a gradual, measured ramp.\nDefine failure before the run. \u0026ldquo;The system is broken\u0026rdquo; is not an objective statement. Decide in advance: for example, breaking point is reached when p95 exceeds 1 second or error rate exceeds 5%. Without this, the team will argue about the results after the fact.\nAlways ramp back down. Observing how the system recovers (or does not) is half the value of the test. A stress test that cuts traffic at peak and reports \u0026ldquo;we hit 5000 RPS\u0026rdquo; has learned nothing about whether production could actually sustain it.\nZoom: a stress run with 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 }, // baseline hold { 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 }, // hold at peak { duration: \u0026#39;3m\u0026#39;, target: 0 }, // ramp down, observe recovery ], thresholds: { // These thresholds are the failure definition. // A violated threshold fails the run, which is expected past breaking point. \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); } A 500-VU ceiling, reached in six steps of +50 to +100 VUs each. Each step holds for two minutes, which is long enough for the system to stabilize at that load level before the next step. The ramp-down is short and deliberate: three minutes from peak to zero, which is where the recovery behavior is captured.\n✅ Good practice : Pick the step size so that the entire ramp takes 15 to 25 minutes. Shorter runs miss steady-state behavior at each level. Longer runs burn budget and make the result hard to interpret.\nZoom: the same stress run with 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(); Same stair-step profile, expressed as a list of LoadSimulation stages. NBomber\u0026rsquo;s HTML report plots latency and throughput per step, which is exactly the shape a stress test is meant to produce.\nZoom: identifying the breaking point #The breaking point is not always obvious from a single graph. It is the intersection of three signals.\nLatency p95 curve. Plot p95 latency against VU count. In a healthy system, the curve is nearly flat, then begins to rise, then rises steeply. The breaking point is where the rise becomes super-linear, usually visible as an inflection point on the graph.\nError rate curve. Plot error rate against VU count. In most .NET systems, the error rate stays near zero until the breaking point, then rises fast. If the error rate starts rising before the latency does, the bottleneck is a hard limit (a connection pool, a rate limiter, a circuit breaker). If latency rises first, the bottleneck is a soft limit (CPU, memory, thread pool queuing).\nThroughput curve. Plot successful RPS against VU count. In a healthy system, throughput grows with VUs, then plateaus at the system\u0026rsquo;s maximum. In a failing system, throughput peaks, then drops as the system spends more time handling failures than real work. The drop is the most actionable signal: it means the system is doing worse under more load, not just handling less well.\nThe intersection of these three curves gives a defensible number: \u0026ldquo;the system supports 320 RPS before p95 exceeds 1 second and error rate exceeds 1%\u0026rdquo;. That number is usable in capacity planning, in contract negotiations, and in deployment sizing.\nZoom: the shape of failure #The curve itself matters as much as the number. Four failure shapes are common.\nLinear degradation. Latency rises smoothly, error rate stays near zero, throughput plateaus cleanly. The best shape possible, because it means the team can scale out linearly to match demand and predict behavior past the breaking point. Usually indicates a CPU-bound system with well-tuned pools.\nKnee curve. Latency is flat, then bends upward sharply at a specific load level. Indicates a hard resource limit: a connection pool reaching max, a cache miss storm, a thread pool saturating. The fix is usually a single configuration change, once the resource is identified.\nCliff edge. Latency is flat, everything looks fine, then the system collapses within 30 seconds: errors spike, throughput drops to zero. Indicates a cascading failure: a circuit breaker that opens and starves a dependent service, a deadlock that propagates across requests, an OOM that restarts the process. Cliff-edge failures are the most dangerous because there is no warning before the outage.\nDeath spiral. Latency climbs, then throughput drops, then latency climbs more because retries pile up on an already-overloaded system. The system gets worse the more traffic it receives, even if the traffic stops growing. The fix is usually backpressure or load shedding, not more capacity.\n💡 Info : The .NET runtime has a built-in concurrency limiter (Microsoft.AspNetCore.RateLimiting, available since .NET 7) specifically designed to prevent death spirals. Adding a queue-based rate limiter in front of sensitive endpoints turns a death spiral into controlled rejection, which is much easier to reason about.\nZoom: recovery #Once the ramp-down begins, the question changes from \u0026ldquo;how bad did it get\u0026rdquo; to \u0026ldquo;does the system come back\u0026rdquo;. Three outcomes.\nClean recovery. Within seconds of the load dropping, latency returns to baseline, error rate returns to zero, throughput matches demand. This is the expected outcome, and it confirms that the system can shed load without side effects.\nSlow recovery. Latency takes minutes to return to baseline even after the load drops. Usually indicates that something is still draining: a queue that accumulated backlog, a connection pool that is slowly releasing stuck connections, a cache that is rebuilding from cold after an invalidation storm. The recovery time is itself a metric, and it is often where the hidden cost of the failure lives.\nNo recovery. Latency stays elevated, or the system keeps returning errors, even at zero load. Indicates permanent damage: a leaked thread that holds a lock, a deadlocked async state machine, a circuit breaker stuck open, a cache that cannot rehydrate. The process needs a restart to return to health, which is information the team needs before the same failure happens in production.\n⚠️ It works, but\u0026hellip; : A stress test that only measures peak RPS without measuring recovery is reporting half the story. The team can hit a peak that production cannot, if recovery after that peak is impossible. Capacity planning must account for the margin needed to avoid the peak, not just the peak itself.\n❌ Never do this : Do not run stress tests against production without a strict blast radius and a pre-agreed abort condition. Stress tests are designed to break things, and the production database is not the place to discover what breaks.\nWrap-up #A stress test is the only test that produces a capacity number the team can actually defend. You can set one up in k6 or NBomber in an afternoon, use a stair-step ramp of 15 to 25 minutes, define the failure condition before the run to avoid arguing about results afterward, capture latency, error rate, and throughput curves side by side, identify the shape of the failure, and always include a ramp-down phase to measure recovery. You can walk out of a stress test with a defensible number for capacity planning, a named first bottleneck to fix, and confidence about how the system will behave when production traffic spikes past expected limits.\nReady to level up your next project or share it with your team? See you in the next one, Spike Testing is where we go next.\nRelated articles # Load Testing for .NET: An Overview of the Four Types That Matter Baseline Load Testing in .NET: Knowing What Normal Looks Like Soak Testing in .NET: The Bugs That Only Appear After Hours References # k6 stress testing patterns NBomber load simulations ASP.NET Core rate limiting middleware, Microsoft Learn Google SRE book: Addressing Cascading Failures ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/load-testing-stress/","section":"Posts","summary":"","title":"Stress Testing in .NET: Finding the Breaking Point and Its Shape"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/testcontainers/","section":"Tags","summary":"","title":"Testcontainers"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/categories/testing/","section":"Categories","summary":"","title":"Testing"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/testing/","section":"Tags","summary":"","title":"Testing"},{"content":"Before Clean Architecture became a buzzword and before Vertical Slicing showed up on every conference stage, there was a humbler pattern that quietly shipped thousands of .NET applications: UI, Services, Repositories. Three folders, three responsibilities, one project. If you have ever opened a mid-sized ASP.NET Core codebase, odds are this is what you found.\nIt is not the same thing as N-Layered architecture, even though people use the terms interchangeably. N-Layered is about physical separation into projects with enforced dependency rules. UI / Repos / Services is about logical separation inside a single project. It is lighter, faster to set up, and perfectly fine for a huge class of applications. It also has very specific failure modes you need to recognize before they rot your codebase.\nWhy this pattern exists #Picture a small team building an internal tool. A single ASP.NET Core project, maybe 30 endpoints, one database. Spinning up four csproj files, wiring solution references, and arguing about whether AutoMapper belongs in Infrastructure or Application is overkill. What the team actually needs is:\nA place for HTTP concerns so controllers stay readable. A place for business logic so you stop debugging across six files to understand one rule. A place for data access so swapping a query does not require touching the UI. Three folders. Three suffixes. Done. That is the whole pitch: just enough structure to stop the rot, not so much that it slows delivery.\nOverview: the three buckets # graph TD A[Controllers / EndpointsHTTP layer] --\u003e B[ServicesBusiness logic] B --\u003e C[RepositoriesData access] C --\u003e D[(Database)] A -.-\u003e|DTOs| E[Models / Contracts] B -.-\u003e|Entities + DTOs| E C -.-\u003e|Entities| E Folder Role Typical files Controllers/ Receive HTTP, validate shape, call a service OrdersController.cs Services/ Orchestrate business rules, call repositories OrderService.cs, IOrderService.cs Repositories/ Abstract the database OrderRepository.cs, IOrderRepository.cs Models/ Entities, DTOs, enums Order.cs, CreateOrderRequest.cs Everything lives in one project. No solution gymnastics, no circular reference headaches, no hour-long debates about where ILogger extensions belong.\nEach piece in detail #Solution layout inside a single project #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 : This is not \u0026ldquo;wrong because it is one project\u0026rdquo;. Thousands of production apps run this way. The rule you enforce with folders and interfaces, not with csproj boundaries.\nRepositories: the data access seam #A repository is a boring class that wraps EF Core (or Dapper) and exposes methods named after use cases, not SQL. The interface is what the rest of the app depends on.\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 Task\u0026lt;IReadOnlyList\u0026lt;Order\u0026gt;\u0026gt; GetPendingForCustomerAsync( string customerId, CancellationToken ct = default) =\u0026gt; _db.Orders .AsNoTracking() .Where(o =\u0026gt; o.CustomerId == customerId \u0026amp;\u0026amp; o.Status == OrderStatus.Pending) .ToListAsync(ct) .ContinueWith(t =\u0026gt; (IReadOnlyList\u0026lt;Order\u0026gt;)t.Result, 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); } ✅ Good practice : Name repository methods after intent, not after SQL. GetPendingForCustomerAsync tells the caller why. GetByCustomerIdAndStatusAsync leaks the filter into the name and grows a combinatorial explosion of overloads.\n❌ Never do this : Do not expose IQueryable\u0026lt;Order\u0026gt; from the interface. The moment a controller or service starts chaining .Where().Include().OrderBy(), your repository has stopped being a seam and become a thin wrapper around DbSet. You have lost every reason you introduced the abstraction for.\nServices: where the rules live #A service depends on one or more repositories and holds the business rules. No HTTP types (IActionResult, HttpContext), no EF Core types (IQueryable, DbSet). Just your domain and the 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;An order must have at least one line.\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;One or more products do not exist.\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;Order {OrderId} created for {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(); } } ✅ Good practice : A service can depend on multiple repositories. It is the natural place to coordinate a \u0026ldquo;check product stock, create the order, publish an event\u0026rdquo; workflow. The moment a service depends on another service, stop and ask whether that hidden collaboration is actually a single use case in disguise.\n⚠️ It works, but\u0026hellip; : Throwing ValidationException is fine for small apps. Once you have more than a handful of validation paths, the Result pattern becomes worth the ceremony. Covered in the Error Handling series.\nControllers: thin, boring, predictable #Controllers should be the least interesting files in the project. Receive, call, return.\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); } } ✅ Good practice : If a controller action is more than 5 to 10 lines, something leaked out of the service. Push it back down.\nWiring it up in Program.cs #Everything is registered once, usually scoped per request so the DbContext lifetime matches.\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 : Available since .NET 8, AddProblemDetails() plus UseExceptionHandler() gives you RFC 7807 error responses out of the box. No custom middleware needed for the common case.\nUI / Repos / Services vs N-Layered: what is actually different #They look the same on a diagram. The difference is physical:\nAspect UI / Repos / Services N-Layered Projects 1 3 to 4 (Api, Application, Infrastructure, Domain) Dependency rules Enforced by review Enforced by the compiler Setup time Minutes An afternoon Refactor cost Move files Move files plus fix csproj references Best for Small to mid apps, solo or small team Apps with multiple teams or strict boundaries The single-project version is not a lesser cousin. It is the right trade-off for a huge portion of the work we actually do. Reach for the multi-project N-Layered flavor only when the enforcement is paying for itself.\nWhere it starts to bite #The same failure modes as N-Layered, amplified by the lack of compiler enforcement:\nFat services: OrderService ends up with 30 methods because every new endpoint grows a new method rather than a new class. Repository leakage: someone adds a GetOrdersForAdminDashboardWithFiltersAndSortingAsync method and nobody dares split it. Cross-service coupling: OrderService starts calling InvoiceService, which calls NotificationService, which calls OrderService. Welcome to the cycle. DTO drift: without a Domain project to hold the line, DTOs and entities start referencing each other in both directions. When these show up, the answer is not \u0026ldquo;add more folders\u0026rdquo;. It is either a proper Clean Architecture split (to enforce direction) or Vertical Slicing (to stop organizing by technical role).\nWrap-up #You now know what the UI / Repositories / Services pattern actually is, how it differs from formal N-Layered architecture, and how to wire it up cleanly inside a single ASP.NET Core project. You can pick this pattern deliberately for small to mid-sized apps, keep your controllers thin, isolate your data access behind named repository methods, and recognize the exact moment it stops serving you.\nReady to level up your next project or share it with your team? See you in the next one, Clean Architecture is where we go next.\nRelated articles # N-Layered Architecture in .NET: The Foundation You Need to Master References # Common web application architectures, Microsoft Learn Dependency injection in ASP.NET Core, Microsoft Learn Problem Details for HTTP APIs in ASP.NET Core, Microsoft Learn Entity Framework Core, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/code-structure-ui-repos-services/","section":"Posts","summary":"","title":"UI / Repositories / Services: The Pragmatic .NET Layering"},{"content":"Unit tests are the cheapest test you can write and the first ones that rot when nobody maintains them. A test suite full of tests that break every time you rename a variable, that mock everything into meaninglessness, and that take four seconds to run a single assertion brings less value than having no tests at all. The goal of this article is to help you write the other kind: fast, focused, and the kind you actually rely on during a refactor.\nThe .NET unit testing story is mature. xUnit.net was started by James Newkirk in 2007 after he co-created NUnit, as a rewrite that removed a decade of accumulated habits. It became the default test framework in ASP.NET Core templates around 2016, and .NET 10 ships with xUnit v3 as the current major version. Around it, FluentAssertions (for readable asserts), NSubstitute or Moq (for mocks), and Bogus (for test data) make up the standard toolkit.\nWhy unit tests exist #Unit tests solve four concrete problems that no other layer of the test pyramid addresses as efficiently.\n1. They protect against regression over time. This is the primary reason they exist. A team ships a pricing engine in month one. In month two, a volume discount tier is added. In month four, a loyalty multiplier interacts with it. In month nine, a new joiner refactors a helper and unknowingly breaks the interaction between tiers and loyalty. Without unit tests, that regression reaches production, and the team discovers it from a customer complaint. With unit tests, the change never merges: the test that pinned the month-two behavior fails in under a second, on the laptop of the person who made the change.\n2. They detect god classes and god methods early. A method that is hard to unit-test is almost never a testing problem, it is a design problem. When a single test requires fifteen mocks, four pages of arrange code, and a dozen assertions to cover one call, the test is telling you that the method under test is doing too many things at once. The correct response is not to write the giant test. It is to split the method. Unit tests act as an early warning system for god classes and god methods, long before they show up in a code-quality report.\n3. They test the logic of methods, and nothing else. Unit tests are the right tool when the question is \u0026ldquo;does this piece of logic compute the right result for a given input\u0026rdquo;. Database queries, HTTP pipelines, middleware, serialization, authentication: those are not logic of a method, they are behaviors of infrastructure. They belong in integration tests, API tests, or E2E tests, not here. Keeping the scope to pure logic is what makes unit tests fast, stable, and trustworthy.\n4. Cherry on top for domain-driven designs. When the business logic lives inside a well-designed, non-anemic aggregate (that is, an entity that enforces its own invariants instead of exposing public setters for a service to manipulate), unit tests become exceptionally clean. The aggregate contains the rules, the tests contain the scenarios, and there is nothing else to wire up. This is the strongest argument for keeping logic inside the domain instead of scattering it across services, mappers, and validators. A dedicated article on DDD and aggregates will go deeper into this point.\nWhat should be tested #A reasonable default for every method that contains real logic: the happy path, the edge cases, and the failure cases. Those three categories cover almost every bug worth catching.\nHappy path: the normal, successful execution with valid inputs. One test per method, minimum. Edge cases: boundaries where behavior flips. Quantity of 0, 1, exactly the discount threshold, an empty collection, a null optional field, the maximum allowed value, the first day of a month, a leap year. Failure cases: what happens when an invariant is violated. A negative quantity, an already-submitted order being submitted again, a refund that exceeds the original amount. The test asserts that the right exception (or Result.Failure) comes back, not a half-corrupted state. On top of that baseline, two more rules earn their place in any serious team.\nEvery production bug should add one test. When a bug is found in production, the fix is incomplete until a test exists that would have caught it. This is the only durable way to make regression protection accumulate. A test suite that grows one test per incident becomes, over years, a map of everything that has ever gone wrong, and the team inherits that knowledge for free.\nGuard rails and authorization deserve their own tests. Defensive programming is not complete until it is verified. For every role-sensitive operation, write the pair: \u0026ldquo;as an admin, the action is allowed\u0026rdquo; and \u0026ldquo;as a regular user, the action is denied\u0026rdquo;. Same for tenant isolation, ownership checks, and rate limits. These are the rules that get silently broken during a refactor and discovered during an audit.\nFor CRUD-heavy applications, the same categorization still applies, but the focus shifts:\nWrite operations with business logic: validation rules, cross-field invariants, state transitions. Test these at the unit level, with the aggregate or service as the SUT. Read operations with transforms: projection from entity to DTO, aggregation, computed fields, formatting. Test the transformation itself. Pure pass-through CRUD (controller to repository to database, no logic in between) does not need a unit test. It needs an integration test that proves the round trip works. Unit tests deliver all of the above, but only if they stay scoped correctly. The moment a \u0026ldquo;unit test\u0026rdquo; spins up a database, a web host, or the file system, it stops being a unit test and becomes a slow integration test. That is a different tool, with a different job.\nOverview: the pieces #Before the code, here are the tools a .NET unit test suite actually uses in 2026:\ngraph TD A[Test projectxUnit v3] --\u003e B[AssertionsFluentAssertions or Shouldly] A --\u003e C[Mocks / FakesNSubstitute or Moq] A --\u003e D[Test dataBogus, AutoFixture] A --\u003e E[SUTSystem Under Test] B --\u003e E C --\u003e E D --\u003e E The SUT is the class you are testing. Everything else is scaffolding. Your job is to keep the ratio high: minimal scaffolding, maximum SUT.\nZoom: the AAA pattern #Every good unit test has three sections: Arrange, Act, Assert. Separated visually, they read like prose.\nusing FluentAssertions; using Xunit; public class PriceCalculatorTests { [Fact] public void Calculate_applies_volume_discount_above_10_items() { // 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% off above 10 items } } Three things make this test good: the name describes the behavior, not the method; the arrange is minimal; the assert checks one outcome. If someone changes PriceCalculator internals tomorrow, this test still passes as long as the rule holds.\n💡 Info : The [Fact] attribute marks a parameterless test. For multiple inputs, use [Theory] with [InlineData] or [MemberData]. It is not syntactic sugar, it is precisely what parameterized tests exist for.\n✅ Good practice : Name tests as MethodName_state_expectedOutcome or in plain sentences like applies_volume_discount_above_10_items. Your test runner output is documentation for future-you.\nZoom: Theory for input tables #When a method has multiple input branches, a theory beats ten copy-pasted facts:\n[Theory] [InlineData(1, 10.00, 0, 10.00)] // no discount [InlineData(10, 10.00, 0, 100.00)] // threshold exactly [InlineData(11, 10.00, 10, 99.00)] // 10% off [InlineData(50, 10.00, 15, 425.00)] // 15% tier public void Calculate_applies_tiered_discount( 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); } One test method, four test cases, four rows in the runner. Adding a new tier is one line.\nZoom: mocking, carefully #Mocking is the technique most often applied incorrectly in unit testing. The rule is simple: mock boundaries, not behavior. A boundary is an interface your SUT calls out to (repository, HTTP client, time provider). Everything else should be real.\n[Fact] public async Task Submit_charges_customer_and_marks_order_submitted() { // 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;()); } The Order domain entity is real, not mocked. Only IPaymentGateway and IOrderRepository are substituted because they talk to the outside world.\n⚠️ It works, but\u0026hellip; : If you find yourself mocking your own domain classes (Order, Invoice, Customer), step back. Either the class is a boundary in disguise (extract an interface) or the test is testing the mock, not the SUT.\n❌ Never do this : Do not write tests that assert mock.Received(1).InternalHelperMethod(). You are pinning the implementation, not the behavior. A refactor that keeps the same public contract will break your tests for no reason.\nZoom: what not to unit-test #Unit tests are the wrong tool for:\nDatabase queries: an EF Core Where expression is not unit-testable in a meaningful way. The bug hides in the generated SQL. Test it with a real database using integration tests. HTTP pipeline, middleware, filters: spin up the real pipeline with WebApplicationFactory instead. Serialization round-trips: use an end-to-end assertion on the actual JSON, not a mock. UI rendering: unit-testing a Blazor component for layout is the wrong level. E2E with Playwright catches the real bugs. If a test takes more than 50ms to run, it is probably not a unit test. That is fine, it just belongs in a different test project with a different lifecycle.\n✅ Good practice : Split your solution into MyApp.UnitTests, MyApp.IntegrationTests, and MyApp.E2ETests. CI can run unit tests on every commit and the slower suites less often, or in parallel stages.\nRunning fast and parallel #xUnit v3 runs test classes in parallel by default. That is great unless your tests share static state (cached singletons, DateTime.Now, environment variables). Two rules:\nNo shared mutable state between tests. Every test arranges its own world. Inject a clock instead of calling DateTime.UtcNow directly. In .NET 8+, TimeProvider is the canonical abstraction. public sealed class PromotionService(TimeProvider clock) { public bool IsActive(Promotion p) =\u0026gt; clock.GetUtcNow() \u0026lt; p.EndsAt; } // In 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 lives in the Microsoft.Extensions.TimeProvider.Testing NuGet package. No more DateTime.UtcNow in production code.\n💡 Info : TimeProvider was introduced in .NET 8. Before that, teams rolled their own IClock interface. If you are still on .NET 6/7, keep your own abstraction, the test pattern is identical.\nWrap-up #You now know how to write unit tests that are genuinely useful: scoped to a single behavior, using the AAA layout, mocking only at boundaries, running in milliseconds, and surviving refactors without rewriting. You can pick xUnit v3 plus FluentAssertions plus NSubstitute as a safe default, use [Theory] for input tables, inject TimeProvider instead of hitting the system clock, and recognize the cases where a unit test is not the right tool.\nReady to level up your next project or share it with your team? See you in the next one, Integration Testing with TestContainers is where we go next.\nReferences # xUnit.net documentation Unit testing in .NET, Microsoft Learn TimeProvider in .NET, Microsoft Learn NSubstitute documentation FluentAssertions documentation ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/testing-unit-testing/","section":"Posts","summary":"","title":"Unit Testing in .NET: Fast, Focused, and Actually Useful"},{"content":"Every layered pattern we have looked at in this series, N-Layered, UI / Repositories / Services, and Clean Architecture, shares the same core assumption: the right way to split code is by technical role. Controllers over here, services over there, repositories in the back, entities in the middle. It is so ingrained in the .NET community that most developers never question it.\nVertical Slicing questions it directly. Its claim is simple: features change together, so they should live together. When you ship \u0026ldquo;submit an order\u0026rdquo;, you touch a controller, a service, a validator, a query, a response DTO, and probably a database call. In a horizontal layout, those six pieces live in six different folders. In a vertical slice, they live in one. The idea was formalized by Jimmy Bogard around 2018, building on his experience with MediatR and CQRS in real .NET codebases, as a reaction to how much friction layered architectures added to everyday feature work.\nWhy this pattern exists #Picture a sprint planning. The team picks up four stories: \u0026ldquo;export invoices\u0026rdquo;, \u0026ldquo;refund an order\u0026rdquo;, \u0026ldquo;send a welcome email\u0026rdquo;, and \u0026ldquo;mark a product as featured\u0026rdquo;. In a horizontal architecture, each story crosses five folders. Two developers working on two stories end up editing OrderService.cs at the same time, resolving merge conflicts in a file neither of them fully owns. A third developer reuses a method in OrderRepository that was tuned for a different feature, and a subtle bug ships. Code review takes longer because reviewers have to jump between seven files to follow a single change. None of this is anyone\u0026rsquo;s fault: it is the cost of organizing code by technical role when the work arrives feature by feature.\nThe insight is that layered architectures optimize for the axis of reuse, which is rarely the axis of change. Features change. Layers do not. So why are we organizing around layers?\nVertical Slicing flips the default:\nEach feature owns its own folder with everything it needs: request, handler, validator, response. Cross-feature reuse is the exception, not the rule. Duplication is acceptable when it isolates changes. Abstractions emerge from patterns, not from preemptive interface gardening. Overview: concerns vs features, side by side #The fastest way to see what Vertical Slicing really changes is to compare the two mental models on the same feature set. Same three features, same three technical concerns, two completely different ways to lay them out on disk:\ngraph LR subgraph H[\"Horizontal slicing : by concern\"] direction TB HC[Controllers] HS[Services] HR[Repositories] HC --\u003e HS HS --\u003e HR end subgraph V[\"Vertical slicing : by 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 On the left, every feature has to cross three shared layers, so every sprint pulls multiple developers into the same Controllers/, Services/, and Repositories/ folders. On the right, each feature is a self-contained column: shipping RefundOrder never makes you open SubmitOrder. The technical concerns are still there, they just live inside the feature instead of being spread across the project.\nThe shape of a slice #Before the code, here is how a single vertical slice sits in a .NET project:\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[Domain entities] end C --\u003e J H --\u003e J C --\u003e K Two features, two folders, everything you need to ship one feature in one place. The only shared code is the DbContext and the domain entities, and that is on purpose.\n💡 Info : Vertical Slice Architecture was popularized by Jimmy Bogard, the creator of MediatR and AutoMapper. It is not a formal specification. It is a set of principles you apply with judgment, and the folder layout is just the visible part.\nZoom: a real slice, end to end #Let us write the SubmitOrder feature as a single self-contained slice using Minimal APIs, MediatR, and FluentValidation. Everything lives in 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;Order {cmd.OrderId} not found.\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;); } } Five files, one folder, one feature. If you need to understand the whole flow, open the folder and read top to bottom. If you need to change how an order is submitted, every line you will touch is within one directory. No grep tour.\n✅ Good practice : Keep the request, validator, handler, and response as sealed types scoped to the feature. Do not expose them outside. If another feature needs the same concept, it is often a sign you should not reuse: write a new command with the shape that fits the new use case.\nZoom: the query side is even simpler #Reads do not need to go through the domain model. A vertical slice query can project directly from EF Core (or Dapper) into the exact response shape. No repository, no mapper, no DTO assembly line.\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;Order {q.OrderId} not found.\u0026#34;); } } One SQL query. One projection. Zero repositories. The read side does not pretend to respect the domain model, because it does not need to: there are no invariants to enforce when you are just displaying data.\n💡 Info : This is the CQRS idea applied at slice level. Commands go through the domain (to enforce invariants). Queries bypass it (for speed and simplicity). You do not need separate databases or event sourcing to get the benefit. For the full picture, see Application Layer in .NET: CQS and CQRS Without the Hype.\n⚠️ It works, but\u0026hellip; : Resist the urge to introduce a ReadRepository interface for queries. It adds a layer of indirection that no other feature will ever reuse. If you want to mock it for tests, mock the DbContext or use an in-memory provider.\nZoom: where abstractions still live #Vertical Slicing is not \u0026ldquo;no shared code\u0026rdquo;. Some things are genuinely shared and belong outside the slices:\nDomain entities and value objects that carry invariants. Order.Submit() still lives in the domain. Slices call it. Cross-cutting infrastructure: the DbContext, the message bus, the email sender, the payment gateway interface. Pipeline behaviors (logging, validation, transaction) that run around every handler. A typical project layout ends up looking like this:\nsrc/ Shop.Api/ Features/ Orders/ SubmitOrder/ GetOrderDetails/ CancelOrder/ Customers/ Register/ UpdateProfile/ Domain/ (entities, value objects) Infrastructure/ (DbContext, EF configs, external clients) Common/ (pipeline behaviors, problem details, result types) Program.cs Notice the absence of Controllers/, Services/, and Repositories/ folders. Those shapes are emergent per feature, not enforced at the project level.\n✅ Good practice : Add a MediatR pipeline behavior for validation so every handler gets FluentValidation for free. One file in Common/, every slice benefits, no slice has to wire it up.\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(); } } The duplication question #Vertical Slicing will make you write code that looks duplicated. Two slices will both query orders by id. Two handlers will both read ShopDbContext. A junior developer will ask \u0026ldquo;should we extract this into a helper?\u0026rdquo; The answer, nine times out of ten, is no.\nDuplication is cheap. Coupling is expensive. The moment you extract a shared method used by two slices, the slices stop being independent: changing one without checking the other becomes impossible. A few duplicated lines of EF Core are a feature, not a bug. They let slice A evolve without breaking slice B.\nExtract only when:\nYou find the same pattern in three or more slices. The pattern is genuinely stable and has a clear name. Extracting removes real risk, not just lines. ❌ Never do this : Avoid building a BaseHandler\u0026lt;TCommand, TResponse\u0026gt; with protected helpers shared across features. It looks clean on day one, and by month six any change to the base class affects every slice at once, which is exactly the coupling Vertical Slicing is trying to avoid. Keep each slice independently deletable.\nWhere Vertical Slicing starts to bite #No pattern is free. The failure modes of Vertical Slicing are different from the layered patterns, and you should know them:\nNo enforced domain boundaries: nothing stops a handler from bypassing a domain method and mutating an entity directly. You need discipline or architecture tests to keep invariants where they belong. Discoverability for newcomers: a dev used to \u0026ldquo;I need to change the order service, I open OrderService.cs\u0026rdquo; has to learn a new mental model. \u0026ldquo;I need to change how orders are submitted, I open Features/Orders/SubmitOrder/.\u0026rdquo; Cross-slice coordination: when a business rule spans four features, you have four places to update. Good naming and domain events help, but it is real work. Weak affordance for very small apps: if you have fifteen endpoints and no real domain, a vertical slice layout feels like overkill. UI / Repos / Services is probably still the right call. Vertical Slicing shines on medium-to-large applications with active teams, where features change often and two developers on two features must not step on each other.\nWrap-up #You now know what Vertical Slicing actually is: organizing your codebase by feature so that everything needed to ship one change lives in one place. You can write self-contained slices with command, handler, validator, and endpoint; bypass the domain on read paths for simpler queries; keep genuinely shared concerns in a thin Common or Infrastructure folder; and resist the temptation to deduplicate prematurely. You can also tell when this pattern fits and when a more traditional layering is still the right answer.\nReady to level up your next project or share it with your team? See you in the next one, a++ 👋\nRelated articles # N-Layered Architecture in .NET: The Foundation You Need to Master UI / Repositories / Services: The Pragmatic .NET Layering Clean Architecture in .NET: Dependencies Pointing the Right Way References # Common web application architectures, Microsoft Learn Minimal APIs in ASP.NET Core, Microsoft Learn MediatR on GitHub FluentValidation documentation Entity Framework Core, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/code-structure-vertical-slicing/","section":"Posts","summary":"","title":"Vertical Slicing in .NET: Organize by Feature, Not by Layer"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/vertical-slicing/","section":"Tags","summary":"","title":"Vertical-Slicing"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/webapplicationfactory/","section":"Tags","summary":"","title":"Webapplicationfactory"},{"content":"","date":null,"permalink":"https://dotnet-senior-blog.pages.dev/tags/xunit/","section":"Tags","summary":"","title":"Xunit"},{"content":"For 95% of .NET code, the garbage collector is a quiet helper that nobody thinks about. Allocations happen, memory is reclaimed, and the program continues. For the other 5%, the hot paths of a high-throughput system, the garbage collector is the bottleneck, and every byte allocated per request becomes a request per second the system cannot handle. The difference between these two worlds is not code quality, it is frequency: at 100,000 requests per second, a single 1 KB allocation per request becomes 100 MB per second of heap pressure, and the GC starts running continuously to keep up.\nZero-allocation programming is the set of techniques that let performance-sensitive code avoid producing garbage on the hot path. It is not a style to apply everywhere. It is a toolbox to reach for when a stress test or a soak test shows that GC pause times, gen0 collection frequency, or heap pressure are limiting throughput. Used at the right places, it can double or triple a system\u0026rsquo;s capacity without changing anything else.\nWhy zero allocation matters #The .NET garbage collector is generational. Objects start in gen0, survive into gen1 if they live long enough, and reach gen2 if they survive two collections. Collecting gen0 is cheap (a few hundred microseconds), gen1 is more expensive, gen2 is the one that produces visible application pauses and can take tens of milliseconds. A well-behaved system keeps most allocations in gen0, where collection is nearly free.\nThe problem is that \u0026ldquo;nearly free\u0026rdquo; is not free. Every gen0 collection stops the managed threads (in server GC mode, briefly), measures the roots, compacts the young generation, and resumes. At 100,000 requests per second, if each request allocates 2 KB, gen0 fills in milliseconds, and the GC runs several times per second. Each run introduces jitter, latency spikes, and contention with real work.\nZero-allocation code changes this equation. Instead of allocating for every operation, it reuses buffers, uses the stack for temporary data, and keeps the managed heap quiet. The goals are concrete:\nStable tail latency, because fewer GC pauses mean fewer latency spikes at p99 and p99.9. Higher throughput, because the CPU spends less time collecting and more time running application code. Lower memory pressure, because the working set stays bounded and the system can pack more instances per host. Predictable behavior under load, because the GC is no longer one of the moving parts whose cost scales with traffic. Overview: the allocation pyramid # graph TD A[Value types on the stackfree] --\u003e B[Span\u0026lt;T\u0026gt; over stackallocfree] B --\u003e C[ArrayPool\u0026lt;T\u0026gt;reused, not allocated] C --\u003e D[Pooled objectsObjectPool\u0026lt;T\u0026gt;] D --\u003e E[Gen0 heapcheap, but not free] E --\u003e F[LOH / Gen2expensive, avoid] Not every allocation is equal. The pyramid above orders the options from cheapest to most expensive. The guiding principle is simple: on a hot path, try to stay as high on the pyramid as possible. If the data fits on the stack, put it on the stack. If it does not, rent from a pool. If neither works, at least keep the allocation in gen0 and out of the Large Object Heap.\nThis article covers four techniques in that pyramid, each of which applies to a specific situation.\nZoom: Span\u0026lt;T\u0026gt; and stackalloc #Span\u0026lt;T\u0026gt; was introduced in .NET Core 2.1 as the canonical abstraction over contiguous memory. It can point at a managed array, at a native pointer, at a portion of a string, or at stack-allocated memory, with the same API. Combined with stackalloc, it enables zero-allocation buffers for short-lived operations.\npublic static bool IsValidIban(ReadOnlySpan\u0026lt;char\u0026gt; iban) { if (iban.Length \u0026lt; 15 || iban.Length \u0026gt; 34) return false; // Stack-allocated buffer, no heap allocation at all. Span\u0026lt;char\u0026gt; rearranged = stackalloc char[iban.Length]; iban[4..].CopyTo(rearranged); iban[..4].CopyTo(rearranged[^4..]); // Convert to digits, validated mod 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; } This method validates an IBAN without allocating a single byte on the heap. The stackalloc buffers live in the current stack frame and are reclaimed automatically when the method returns. The caller passes a ReadOnlySpan\u0026lt;char\u0026gt;, which can come from a string, a parsed request body, or another span, at no allocation cost.\n💡 Info : stackalloc is safe inside a method that does not store the resulting span in a field or return it. The compiler enforces this via the ref struct rules of Span\u0026lt;T\u0026gt;. The stack buffer size should stay under roughly 1 KB to avoid risking a StackOverflowException. For larger buffers, use ArrayPool\u0026lt;T\u0026gt;.\n✅ Good practice : Accept ReadOnlySpan\u0026lt;char\u0026gt; or ReadOnlySpan\u0026lt;byte\u0026gt; as method parameters instead of string or byte[]. Callers can pass slices of existing data without copying, and the method gains zero-allocation behavior by default.\nZoom: ArrayPool\u0026lt;T\u0026gt; for rented buffers #When the required buffer is larger than a stack allocation should handle (say, 4 KB or more), ArrayPool\u0026lt;T\u0026gt;.Shared provides a managed pool of reusable arrays. Renting an array from the pool is much cheaper than allocating a new one, and returning it makes it available for the next caller.\npublic static async Task\u0026lt;int\u0026gt; ReadAllToCountAsync(Stream input, CancellationToken ct) { // Rent a 16 KB buffer from the shared pool. Zero heap allocation for this 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); } } The try/finally is non-negotiable. Renting without returning leaks a buffer from the pool, which silently reduces its effectiveness. The standard pattern is always rent → try → use → finally → return.\n⚠️ It works, but\u0026hellip; : If the buffer might contain sensitive data (tokens, personally identifiable information), call ArrayPool\u0026lt;T\u0026gt;.Shared.Return(buffer, clearArray: true) to zero the memory before it goes back to the pool. Otherwise the next rental sees the old contents. The cost of clearing a 16 KB buffer is negligible compared to the security consequences of not clearing it.\nZoom: ValueTask for the common case of \u0026ldquo;already complete\u0026rdquo; #Every async method that returns Task allocates at least one Task object, plus a state machine box if the method actually yields. For methods that frequently return synchronously (the cached value, the empty collection, the early return on a guard clause), that allocation is pure waste.\nValueTask\u0026lt;T\u0026gt; was added in .NET Core 2.0 specifically for this case. It is a value type that can represent either a completed result inline (zero allocation) or an underlying task (normal allocation). Used correctly, it eliminates allocations for the 80% of calls that complete synchronously.\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) { // Hot path: already in local cache, no async work, no allocation. if (_local.TryGetValue(sku, out var price)) return new ValueTask\u0026lt;decimal\u0026gt;(price); // Cold path: go to the slower cache, real await, real 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; } } In a typical pricing service where the local cache hits 95% of the time, this pattern eliminates 95% of the Task\u0026lt;decimal\u0026gt; allocations. At 100,000 requests per second, that is 95,000 saved allocations per second, compounded by the allocations the state machine box would have produced.\n❌ Never do this : Do not await a ValueTask twice, or store it in a field, or call .Result on a not-yet-completed one. ValueTask is optimized for single-await consumption, and misuse can corrupt the underlying object or cause hangs. The safe pattern is await ValueTaskMethod(); once, at the call site.\nZoom: pooled objects with ObjectPool\u0026lt;T\u0026gt; #For objects that are more complex than a buffer (a StringBuilder, a custom parser state, a request context), Microsoft.Extensions.ObjectPool provides a pool that applications can use directly. It is the same mechanism ASP.NET Core uses internally for things like StringBuilder reuse in the 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); // policy.Return clears the builder } } } The same rent → try → finally → return pattern as ArrayPool, with a dedicated policy that bounds the retained capacity. The MaximumRetainedCapacity setting matters: a pool that keeps arbitrarily large StringBuilder instances defeats its own purpose by retaining memory for the worst-case request forever.\n💡 Info : ObjectPoolProvider is registered by default in ASP.NET Core via services.AddSingleton\u0026lt;ObjectPoolProvider, DefaultObjectPoolProvider\u0026gt;() (which ASP.NET Core does automatically). For console applications or workers, register it explicitly.\nZoom: measuring the gain #Zero-allocation code only matters if it actually saves allocations. The only reliable way to verify this is BenchmarkDotNet with the [MemoryDiagnoser] attribute. It reports the allocations per operation, broken down by generation, alongside the 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; } } A typical BenchmarkDotNet output for this comparison looks like:\n| Method | Mean | Allocated | |----------- |----------:|------------:| | Naive | 412.7 ns | 216 B | | ZeroAlloc | 89.3 ns | 0 B | Four to five times faster, zero bytes allocated, and the difference is directly attributable to the GC pressure that is no longer happening. Without the benchmark, the optimization is speculation. With it, the optimization is a measured gain worth shipping.\n✅ Good practice : Run [MemoryDiagnoser] benchmarks as part of the repository, committed alongside the code they measure. When someone refactors the hot path six months later, the benchmark tells them immediately whether allocations crept back in.\nZoom: when zero allocation is the wrong goal #Zero-allocation code is harder to read, harder to debug, and easier to get wrong. Applying it to a method that runs twice a minute is pure negative return on investment. Reach for it when:\nA stress test shows GC time dominating the hot path. A soak test shows heap pressure climbing, with a large fraction of time spent in collections. A BenchmarkDotNet profile shows an inner loop allocating per iteration on a path called thousands of times per second. Latency percentiles show a long tail that aligns with gen2 collection events in the GC logs. Do not reach for it when:\nThe method is not on a hot path. CRUD endpoints, admin operations, and background jobs rarely need it. Readability is the bottleneck. Code that one engineer understands today is often more valuable than code that runs 2% faster and nobody can modify. The allocations are unavoidable by design (serializing to JSON, rendering a full HTML page). Optimize the allocations that are actually optional. Wrap-up #Zero-allocation .NET is a precision tool, not a lifestyle. You can reach for Span\u0026lt;T\u0026gt; and stackalloc on short-lived buffers, ArrayPool\u0026lt;T\u0026gt; on larger ones, ValueTask\u0026lt;T\u0026gt; on async methods that often complete synchronously, and ObjectPool\u0026lt;T\u0026gt; on complex reusable objects. You can measure every change with [MemoryDiagnoser] benchmarks so the gains are real and do not regress silently. You can apply these techniques where a stress test or a soak test proves the GC is the bottleneck, and leave the rest of the codebase alone.\nReady to level up your next project or share it with your team? See you in the next one, AOT Compilation is where we go next.\nRelated articles # Load Testing for .NET: An Overview of the Four Types That Matter Stress Testing in .NET: Finding the Breaking Point and Its Shape Soak Testing in .NET: The Bugs That Only Appear After Hours Unit Testing in .NET: Fast, Focused, and Actually Useful References # Memory and span-related types, Microsoft Learn ArrayPool\u0026lt;T\u0026gt;, Microsoft Learn ValueTask\u0026lt;T\u0026gt; guidance, Microsoft Learn Object pool design pattern, Microsoft Learn BenchmarkDotNet documentation Garbage collection fundamentals, Microsoft Learn ","date":"8 April 2026","permalink":"https://dotnet-senior-blog.pages.dev/posts/performance-zero-allocation/","section":"Posts","summary":"","title":"Zero Allocation in .NET: When the GC Becomes the Bottleneck"}]