.NET

Building Production-Ready .NET Microservices with Clean Architecture

DSi
DSi Team
· · 13 min read
Building Production-Ready .NET Microservices with Clean Architecture

Microservices architecture has matured past the hype cycle. The question is no longer whether microservices work but how to build them so they survive their first year in production without becoming the technical debt that slows your team down. The .NET ecosystem, particularly with .NET 7 and ASP.NET Core, has become one of the strongest platforms for building microservices that are both performant and maintainable.

The challenge is not the framework. It is the architecture. Most .NET microservices that fail in production do not fail because of C# or ASP.NET Core. They fail because the codebase turns into a tangled mess where business logic leaks into controllers, infrastructure concerns bleed into domain models, and every change requires modifying six projects to ship one feature.

This guide covers how to build .NET 7 microservices with Clean Architecture that hold up under real production pressure. We cover the project structure, CQRS with MediatR, domain-driven design, API versioning, health checks, distributed caching, gRPC for inter-service communication, and containerization with Docker Compose. This is the architecture our engineering teams use when building microservices for long-lived systems that need to scale.

Why Clean Architecture for Microservices

Clean Architecture, as defined by Robert C. Martin and adapted for .NET by Jason Taylor and Steve Smith, enforces a strict dependency rule: inner layers know nothing about outer layers. Your domain logic does not know about Entity Framework. Your application layer does not know about HTTP. Your business rules are testable without spinning up a web server or a database.

For microservices specifically, this matters for three reasons:

  • Independent deployability: Each microservice is a self-contained unit. Clean Architecture ensures the domain logic is portable across different hosting models, whether you deploy to Kubernetes, Azure Container Apps, or AWS ECS.
  • Technology flexibility: You can swap Entity Framework for Dapper, replace SQL Server with PostgreSQL, or switch from RabbitMQ to Azure Service Bus without touching your business logic. The infrastructure layer absorbs these changes.
  • Testability at every layer: Unit tests cover domain logic in isolation. Integration tests verify infrastructure adapters. End-to-end tests validate the full request pipeline. Each layer has a clear testing strategy.
The goal of Clean Architecture in microservices is not academic purity. It is practical survival. When you have 15 services in production and need to upgrade a database driver, you want that change isolated to the infrastructure layer of each service, not scattered across hundreds of files.

The Project Structure

A production-ready .NET microservice using Clean Architecture follows a four-layer project structure. Each layer is a separate C# project within the solution, with strictly enforced dependency directions.

Layer breakdown

Layer Project Name Depends On Responsibility
Domain OrderService.Domain Nothing Entities, value objects, domain events, aggregates, repository interfaces
Application OrderService.Application Domain Commands, queries, handlers, validators, DTOs, application interfaces
Infrastructure OrderService.Infrastructure Application, Domain EF Core DbContext, repository implementations, external service clients, caching
API OrderService.Api Application, Infrastructure Controllers, middleware, dependency injection composition root, health checks

The critical rule: the Domain project has zero NuGet package references beyond the .NET base class library. No Entity Framework attributes. No JSON serialization annotations. No MediatR interfaces. The domain is pure C# that expresses your business rules and nothing else.

Domain layer: entities and value objects

The domain layer contains your aggregate roots, entities, value objects, and domain events. In a properly modeled domain, your entities enforce their own invariants. An Order entity does not allow adding a line item with a negative quantity. A Money value object does not allow arithmetic between different currencies.

Domain events are a critical pattern here. When an order is placed, the Order aggregate raises an OrderPlacedEvent. This event is dispatched after the transaction commits, triggering side effects like sending a confirmation email or updating inventory, without coupling the Order aggregate to those concerns.

Application layer: CQRS with MediatR

The application layer is where CQRS (Command Query Responsibility Segregation) lives. Every operation in your microservice is either a command that changes state or a query that reads state. MediatR provides the in-process message bus that routes these to their handlers.

A command looks like this: PlaceOrderCommand implements IRequest<OrderResponse> and carries the data needed to execute the operation. Its handler, PlaceOrderCommandHandler, contains the application logic: validate the input, call domain methods on the aggregate, persist changes through the repository interface, and return the result. Queries follow the same pattern but bypass the domain layer entirely, querying optimized read models or database views directly.

MediatR pipeline behaviors handle cross-cutting concerns cleanly. A ValidationBehavior runs FluentValidation rules before any handler executes. A LoggingBehavior logs the start, end, and duration of every request. A TransactionBehavior wraps command handlers in database transactions. These behaviors compose as a pipeline, and you add or remove them without modifying any handler code.

Infrastructure layer: persistence and external services

The infrastructure layer implements the interfaces defined in the Domain and Application layers. Your IOrderRepository interface lives in the Domain project. Its Entity Framework implementation, OrderRepository, lives in Infrastructure. This inversion of control means your domain logic depends on abstractions, never on EF Core directly.

This layer also contains external service clients (HTTP clients for other microservices, gRPC clients, message broker publishers), caching implementations, and any third-party SDK integrations. Each of these implements an interface defined in the Application layer.

API layer: the composition root

The API project is the entry point and the composition root where dependency injection wires everything together. Controllers are thin. A controller action does one thing: construct a MediatR command or query from the HTTP request and send it. No business logic. No data access. No conditional branching based on domain rules.

This is also where you configure middleware, authentication, API versioning, health checks, and Swagger documentation. The API layer is the only layer that knows about the full dependency graph.

Domain-Driven Design in Practice

Domain-driven design (DDD) in .NET microservices is not about implementing every tactical pattern from Eric Evans' book. It is about choosing the right patterns for the complexity of each service. A microservice that manages user preferences does not need aggregates and domain events. A microservice that handles order processing, payment reconciliation, or inventory management probably does.

Bounded contexts as service boundaries

Each microservice should align with a single bounded context. The Order service has its own definition of a Customer, which might be just a CustomerId and a shipping address. The Customer service has the full Customer model with profile details, preferences, and communication history. These are different representations of the same real-world concept, and that is intentional. Trying to share a single Customer model across services creates the tight coupling that microservices are designed to eliminate.

Aggregates and consistency boundaries

An aggregate defines a consistency boundary. All changes within an aggregate are transactionally consistent. Changes across aggregates are eventually consistent. In the Order service, the Order aggregate includes its line items and shipping details. Inventory is a separate aggregate in a separate service. When an order is placed, the Order service publishes an event; the Inventory service subscribes and updates stock asynchronously.

The practical rule: if you find yourself locking multiple database tables across services in a single transaction, you have drawn your aggregate boundaries wrong.

API Versioning and Health Checks

Production microservices need versioned APIs from day one. Retrofitting versioning after clients are consuming your endpoints is painful and often impossible without breaking changes.

API versioning with Asp.Versioning

ASP.NET Core supports multiple versioning strategies through the Asp.Versioning.Http and Asp.Versioning.Mvc packages. URL segment versioning (/api/v1/orders) is the most explicit and the easiest for clients to understand. Header-based versioning is cleaner from a REST purist perspective but harder to test in a browser and debug in logs.

The key practices that keep versioning manageable: version the API contract, not the internal implementation. When you add v2 of an endpoint, the same MediatR handler can serve both versions with minor mapping differences. Deprecate old versions explicitly using the [ApiVersion("1.0", Deprecated = true)] attribute, and set a sunset date in the response headers.

Health checks for orchestrators

Kubernetes, Azure Container Apps, and AWS ECS all rely on health check endpoints to determine if a service instance is alive and ready to receive traffic. ASP.NET Core's health check middleware supports three types:

  • Liveness: Is the process running? Returns 200 if the application has not crashed. Mapped to /health/live.
  • Readiness: Can the service handle requests? Checks database connectivity, cache availability, and downstream service reachability. Mapped to /health/ready.
  • Startup: Has the service finished initializing? Prevents traffic from being routed before migrations run or caches warm up. Mapped to /health/startup.

Wire these into your container orchestrator's probe configuration. A readiness check that verifies your database connection will prevent Kubernetes from sending traffic to a pod that cannot serve it, which is the difference between a brief blip and a cascade of 500 errors during a database failover.

Distributed Caching with Redis

Microservices that query databases on every request do not scale. Distributed caching with Redis is the standard approach for .NET services that need to share cached data across multiple instances.

ASP.NET Core provides IDistributedCache as the abstraction, with Redis as the most common production implementation. However, the raw IDistributedCache interface is limited. For production use, consider these patterns:

  • Cache-aside pattern: The application checks the cache first. On a miss, it queries the database, stores the result in the cache, and returns it. This is the most common and most predictable pattern.
  • Cache invalidation via domain events: When an Order is updated, the OrderUpdatedEvent handler invalidates the corresponding cache entry. This keeps invalidation logic close to the domain change that triggers it.
  • Sliding expiration for read-heavy data: Product catalogs, configuration data, and reference data benefit from sliding expiration windows that keep frequently accessed data warm while allowing stale entries to expire naturally.

A critical production consideration: always set a maximum expiration, even with sliding windows. Without it, a cache entry that is read continuously will never expire, and you will serve stale data indefinitely after the source changes.

gRPC for Inter-Service Communication

REST over HTTP is fine for external-facing APIs. For internal service-to-service communication where latency and throughput matter, gRPC is the better choice in the .NET ecosystem.

gRPC over ASP.NET Core uses HTTP/2 for multiplexed connections, Protocol Buffers for efficient binary serialization, and code generation for strongly typed clients and servers. A gRPC call between two .NET services is typically 5 to 10 times faster than the equivalent JSON REST call for the same payload, because it avoids JSON serialization overhead, benefits from HTTP/2 connection reuse, and uses a compact binary wire format.

Defining service contracts

Protobuf .proto files define the service contract. Both the client and server projects reference the same proto file, and the .NET gRPC tooling generates the C# code at build time. This guarantees type safety across service boundaries. If the Inventory service changes its contract, the Order service will not compile until it updates its client code to match.

When to use gRPC vs. messaging

gRPC is synchronous request-response communication. Use it when the calling service needs an immediate answer: "Is this product in stock?" or "What is the current price for item X?" Use asynchronous messaging through RabbitMQ or Azure Service Bus when the caller does not need an immediate response and the operation can tolerate eventual consistency: "An order was placed, update inventory when you can."

Most production microservice systems use both. The key is choosing the right tool for each interaction, not forcing every communication through a single pattern.

Containerization with Docker Compose and Dapr

Running multiple microservices locally and in production requires a consistent orchestration approach. Docker Compose remains the standard for defining and running multi-container applications during local development, while Dapr (Distributed Application Runtime) provides the service mesh and infrastructure abstraction that makes microservices practical.

Docker Compose for local development

A well-structured Docker Compose file defines your entire application topology:

  • Add a PostgreSQL container and pass its connection string to the services that need it
  • Add a Redis container for distributed caching
  • Add a RabbitMQ container for messaging
  • Add your microservice containers with environment variables referencing the infrastructure they depend on

The key is making your Compose file mirror your production topology as closely as possible. Use the same container images, the same environment variable names, and the same network configuration. This reduces the "it works on my machine" problem that plagues microservice development.

Dapr for distributed infrastructure

Dapr runs as a sidecar container alongside each of your services and provides building blocks for service invocation, state management, pub/sub messaging, and secret management. The Dapr .NET SDK integrates naturally with ASP.NET Core's dependency injection. Your Order service does not hardcode the Inventory service URL — it calls Dapr's service invocation API with a logical service name that Dapr resolves at runtime, whether you are running locally or deploying to Kubernetes in production.

Production deployment

For production, your Docker container definitions translate directly to Kubernetes manifests or Azure Container Apps configurations. Dapr runs natively on Kubernetes as a sidecar injector, so the same service invocation, pub/sub, and state management patterns you use locally work identically in production. Helm charts or Kustomize overlays manage environment-specific configuration.

This approach is a practical investment for teams building on the full development lifecycle. The environment parity that Docker Compose plus Dapr provides means fewer surprises at deployment time and faster feedback during development. With .NET 8 arriving in November, the upcoming improvements to container support and native AOT compilation will make this workflow even more streamlined.

Cross-Cutting Concerns: Resilience, Logging, and Observability

A microservice is only production-ready when it handles failure gracefully. In a distributed system, network calls fail, services go down, and databases become temporarily unreachable. Your architecture must account for this.

Resilience with Polly

Polly integrates directly with ASP.NET Core's HttpClientFactory through the Microsoft.Extensions.Http.Polly package. For every outbound HTTP or gRPC call, configure:

  • Retry with exponential backoff: Transient failures (503, timeout) are retried with increasing delays. Three retries with jitter handles most transient network issues.
  • Circuit breaker: After a threshold of consecutive failures, stop calling the downstream service entirely for a cooldown period. This prevents cascading failures where a slow downstream service causes thread pool exhaustion in the calling service.
  • Timeout: Set explicit timeouts for every outbound call. A missing timeout on a gRPC call to a frozen service will hold your thread indefinitely.

Structured logging with Serilog

Structured logging is not optional in microservices. When you have 15 services writing logs, searching for "error" in plain text is useless. Serilog with a structured sink (Seq, Elasticsearch, or Application Insights) lets you query logs by correlation ID, service name, user ID, or any custom property. Every log entry should include the correlation ID from the incoming request, so you can trace a single user action across every service it touches.

Distributed tracing with OpenTelemetry

OpenTelemetry is the standard for distributed tracing in .NET. The OpenTelemetry.Extensions.Hosting package instruments ASP.NET Core, HttpClient, Entity Framework, and gRPC automatically. Traces flow across service boundaries through HTTP and gRPC headers, creating a complete picture of how a request propagates through your system. Export traces to Jaeger, Zipkin, or your cloud provider's tracing service. When a user reports that placing an order took 12 seconds, you can see exactly which service call in the chain was slow.

Getting your DevOps maturity to the point where observability is built in from day one, not bolted on after the first outage, is what separates production-ready services from prototypes.

Testing Strategy for Clean Architecture Microservices

Clean Architecture makes testing straightforward because each layer has clear boundaries and explicit dependencies.

  • Domain layer unit tests: Test entity invariants, value object equality, and domain event raising. No mocks needed because the domain has no external dependencies.
  • Application layer unit tests: Test command and query handlers with mocked repository interfaces. Verify that the handler calls the right domain methods and returns the expected result.
  • Infrastructure integration tests: Test repository implementations against a real database (use Testcontainers to spin up a PostgreSQL container per test run). Verify that Entity Framework mappings, queries, and migrations work correctly.
  • API integration tests: Use WebApplicationFactory to spin up the full ASP.NET Core pipeline in memory. Test the complete request flow from HTTP request through middleware, MediatR pipeline, handler, and back to HTTP response.
  • Contract tests: Verify that your gRPC and REST API contracts have not changed unintentionally. Tools like Pact or schema comparison in CI catch breaking changes before they reach production.

The testing pyramid for a Clean Architecture microservice should be roughly 50 percent unit tests (domain and application layers), 30 percent integration tests (infrastructure and API), and 20 percent end-to-end tests (multi-service scenarios). This ratio gives you fast feedback on most changes while still catching integration issues.

Common Pitfalls and How to Avoid Them

The distributed monolith

The most common failure mode is building microservices that must be deployed together. If changing the Order service requires simultaneously deploying the Inventory service and the Payment service, you have a distributed monolith with all the operational complexity of microservices and none of the benefits. The fix is strict adherence to bounded contexts, event-driven communication for cross-service state changes, and versioned APIs for synchronous calls.

Premature decomposition

Starting a greenfield project with 20 microservices is almost always wrong. Start with a well-structured modular monolith using Clean Architecture, and extract services only when you have a concrete reason: independent scaling requirements, different deployment cadences, or team ownership boundaries. The Clean Architecture structure makes extraction straightforward because the boundaries are already defined. This approach pairs well with staff augmentation strategies where specialized engineers can help identify the right extraction points.

Shared databases

Two microservices reading from the same database table is not a microservice architecture. It is a monolith with extra network hops. Each service owns its data store. If the Order service needs customer information, it calls the Customer service API or subscribes to customer events. The database is an implementation detail hidden behind the service boundary.

Ignoring the network

Every inter-service call can fail, be slow, or return unexpected data. Services that treat remote calls like local method invocations will fail in production. Use resilience patterns, set timeouts on every call, design for partial failure, and test what happens when downstream services are unavailable.

Putting It All Together

A production-ready .NET microservice built with Clean Architecture looks like this:

  1. Domain layer: Pure C# entities with enforced invariants, value objects for type safety, domain events for side-effect triggering, and repository interfaces for persistence abstraction.
  2. Application layer: CQRS commands and queries routed through MediatR, with pipeline behaviors for validation (FluentValidation), logging, and transaction management.
  3. Infrastructure layer: Entity Framework Core for persistence, Redis for distributed caching, gRPC clients for synchronous inter-service calls, and MassTransit with RabbitMQ for asynchronous messaging.
  4. API layer: Versioned REST endpoints for external consumers, gRPC endpoints for internal services, health check endpoints for orchestrators, and OpenTelemetry instrumentation for observability.
  5. Orchestration: Docker Compose for local development with Dapr for service discovery, and container deployment to Kubernetes or Azure Container Apps.

Each layer is independently testable. Each service is independently deployable. The domain logic survives infrastructure changes. And the entire system is observable from request ingress to response egress.

Conclusion

Building .NET microservices that survive production requires more than knowing C# and ASP.NET Core. It requires architectural discipline: Clean Architecture to enforce boundaries, CQRS to separate reads from writes, DDD to model complex domains, and production patterns like health checks, distributed caching, resilience policies, and structured observability.

The .NET 7 ecosystem, combined with Docker Compose and Dapr, has made the infrastructure side of microservices significantly more accessible. Service discovery, container orchestration, and distributed tracing are no longer weeks of configuration work. With .NET 8 arriving in November and promising native AOT improvements and enhanced container support, the platform continues to get stronger for microservice workloads.

Start with a single service. Get the Clean Architecture structure right. Build the CQRS pipeline with MediatR. Add health checks and observability from day one. Then extract additional services only when the domain complexity or scaling requirements demand it. The architecture patterns in this guide scale from one service to fifty, because the principles are the same at every level.

At DSi, our .NET engineers build microservices with these patterns daily across full-cycle product engagements. Whether you need to architect a new microservice platform or refactor an existing monolith, talk to our engineering team about building it right the first time.

FAQ

Frequently Asked
Questions

Clean Architecture is best suited for microservices with significant business logic, long expected lifespans, and teams larger than two or three developers. If your service is a simple CRUD wrapper around a database or a thin API gateway, the layered overhead adds complexity without proportional benefit. Use it when domain rules are complex enough to warrant isolation from infrastructure concerns, when you expect the service to evolve over years, or when multiple teams need to work on different layers independently.
CQRS with MediatR separates read and write operations into distinct pipelines, each optimized for its purpose. Write operations flow through command handlers with validation, authorization, and domain logic. Read operations bypass the domain layer and query optimized read models directly. MediatR provides the in-process messaging backbone that routes commands and queries to their handlers, while its pipeline behavior feature enables cross-cutting concerns like logging, validation, and caching without cluttering business logic. The result is cleaner code, easier testing, and the ability to scale reads and writes independently.
Dapr (Distributed Application Runtime) is a portable, event-driven runtime that simplifies microservice development. It provides building blocks for service invocation, state management, pub/sub messaging, and secret management through sidecar containers. For .NET developers, the Dapr SDK integrates naturally with ASP.NET Core dependency injection and middleware. Dapr abstracts infrastructure concerns so your service code stays clean — you can switch from RabbitMQ to Azure Service Bus by changing configuration rather than code. It works with any container orchestrator but pairs especially well with Kubernetes.
Use gRPC for internal service-to-service communication where performance matters. gRPC uses HTTP/2, Protocol Buffers for serialization, and supports streaming, making it significantly faster than JSON over REST for high-throughput internal calls. Use REST for external-facing APIs where broad client compatibility is important, since browsers and third-party integrations expect REST endpoints. Many production systems use both: gRPC internally between services and REST externally for public APIs, with ASP.NET Core supporting both protocols on the same service.
Avoid distributed transactions. Instead, use the Saga pattern or the Outbox pattern to maintain data consistency across services. The Saga pattern coordinates a sequence of local transactions across services, with compensating transactions to undo changes if a step fails. The Outbox pattern stores domain events in the same database transaction as the business data, then publishes them asynchronously to a message broker. Libraries like MassTransit provide built-in support for both patterns in .NET, including automatic retry, dead-letter handling, and saga state management.
DSi engineering team
LET'S CONNECT
Build production-ready
.NET microservices
Our .NET engineers bring Clean Architecture expertise and production-hardened patterns to your team.
Talk to the team