Software architecture is not about choosing the right framework. It is about deciding which parts of a system should be easy to change and which should be stable — then enforcing that decision structurally so it survives contact with deadlines, new developers, and evolving requirements.
Clean Architecture, as described by Robert C. Martin, gives us a dependency rule that makes this enforcement concrete. In .NET 8, the tooling has matured enough that implementing Clean Architecture is no longer an academic exercise. It is a practical approach that pays for itself within months on any codebase that will live longer than a single sprint.
This post walks through the full implementation — from domain modeling to testing — using patterns we have applied on production enterprise systems.
The Layers and the Dependency Rule
Clean Architecture organizes code into concentric layers. The fundamental constraint is simple: dependencies point inward only. Inner layers know nothing about outer layers.
┌─────────────────────────────────────────────┐
│ Presentation │
│ (Controllers, Minimal APIs, Middleware) │
├─────────────────────────────────────────────┤
│ Infrastructure │
│ (EF Core, External APIs, Caching, Email) │
├─────────────────────────────────────────────┤
│ Application │
│ (Use Cases, DTOs, Validators, Interfaces) │
├─────────────────────────────────────────────┤
│ Domain │
│ (Entities, Value Objects, Domain Events) │
└─────────────────────────────────────────────┘
← Dependencies point inward →
The Domain layer sits at the center. It has zero dependencies on any other layer or NuGet package (with rare exceptions like a base library for domain primitives). The Application layer depends only on the Domain. Infrastructure and Presentation depend on Application (and transitively on Domain), but never the reverse.
Why the Dependency Rule Matters at Scale
When an inner layer references an outer layer, you create a coupling that fans out through the codebase. A change to your database schema forces changes in your business logic. A swap from REST to gRPC requires touching domain code. These couplings compound — what starts as "just a quick reference" becomes a web of cross-layer dependencies that makes every change risky and every test slow.
Violating the dependency rule typically manifests as:
- ✓Domain entities decorated with
[JsonProperty]or[Column]attributes - ✓Use case classes directly instantiating
HttpClientorDbContext - ✓Business logic that references
Microsoft.AspNetCore.*namespaces - ✓Domain models that serialize themselves
Each violation is a crack. Enough cracks and the architecture collapses into a ball of mud that nobody can reason about.
The Domain Layer
The domain layer contains your business rules in their purest form. No framework code, no I/O, no serialization concerns. If you printed the domain layer and showed it to a domain expert who cannot read code, the type names and method signatures should still make sense.
Entities
Entities have identity. Two Order objects with the same OrderId are the same order regardless of their other properties. Identity is the defining characteristic — if you change every property on an entity except its ID, it is still the same entity. This distinction drives design decisions: entities get private setters, factory methods for construction, and behavior methods that enforce invariants before mutating state.
public abstract class Entity<TId> where TId : notnull
{
public TId Id { get; protected init; }
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents() => _domainEvents.Clear();
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other) return false;
if (ReferenceEquals(this, other)) return true;
return EqualityComparer<TId>.Default.Equals(Id, other.Id);
}
public override int GetHashCode() => EqualityComparer<TId>.Default.GetHashCode(Id);
}
Value Objects
Value objects have no identity. They are defined entirely by their properties. Two Money objects with the same amount and currency are interchangeable.
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new DomainException("Amount cannot be negative.");
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new DomainException("Currency must be a 3-letter ISO code.");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException($"Cannot add {Currency} to {other.Currency}.");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new DomainException($"Cannot subtract {other.Currency} from {Currency}.");
return new Money(Amount - other.Amount, Currency);
}
}
Using C# records for value objects is practical — you get structural equality and immutability with minimal boilerplate.
Domain Events
Domain events capture things that happened within the domain that other parts of the system might care about. They are past-tense facts, not commands. A domain event should carry enough data for consumers to act on it without querying back into the aggregate that published it. Keep events immutable — once raised, they represent a historical fact that cannot be changed.
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
public sealed record StockAdjustedEvent(
Guid InventoryItemId,
int QuantityChange,
string Reason,
DateTime OccurredOn) : IDomainEvent;
Aggregates and Boundaries
An aggregate is a cluster of entities and value objects treated as a single unit for data consistency. The aggregate root is the only entry point — external code never reaches inside to modify a child entity directly.
The boundaries matter because they define your transactional consistency boundaries. Everything inside an aggregate is strongly consistent. Everything across aggregates is eventually consistent.
A good aggregate boundary satisfies two constraints:
- ✓All invariants within the aggregate can be enforced synchronously — you do not need to query another aggregate to validate a business rule.
- ✓The aggregate is small enough to avoid contention — if two users frequently need to modify the same aggregate simultaneously, your boundary is probably too wide.
public sealed class InventoryItem : Entity<Guid>
{
private readonly List<StockAdjustment> _adjustments = [];
public string Sku { get; private set; }
public string Name { get; private set; }
public int QuantityOnHand { get; private set; }
public int ReorderThreshold { get; private set; }
public IReadOnlyCollection<StockAdjustment> Adjustments => _adjustments.AsReadOnly();
private InventoryItem() { } // EF Core
public static InventoryItem Create(string sku, string name, int reorderThreshold)
{
if (string.IsNullOrWhiteSpace(sku))
throw new DomainException("SKU is required.");
if (reorderThreshold < 0)
throw new DomainException("Reorder threshold cannot be negative.");
return new InventoryItem
{
Id = Guid.NewGuid(),
Sku = sku.Trim().ToUpperInvariant(),
Name = name,
QuantityOnHand = 0,
ReorderThreshold = reorderThreshold
};
}
public void AdjustStock(int quantity, string reason)
{
if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("Adjustment reason is required.");
int newQuantity = QuantityOnHand + quantity;
if (newQuantity < 0)
throw new DomainException(
$"Insufficient stock. Current: {QuantityOnHand}, requested adjustment: {quantity}.");
var adjustment = new StockAdjustment(Guid.NewGuid(), quantity, reason, DateTime.UtcNow);
_adjustments.Add(adjustment);
QuantityOnHand = newQuantity;
RaiseDomainEvent(new StockAdjustedEvent(Id, quantity, reason, DateTime.UtcNow));
if (QuantityOnHand <= ReorderThreshold)
{
RaiseDomainEvent(new ReorderThresholdReachedEvent(Id, Sku, QuantityOnHand, DateTime.UtcNow));
}
}
}
Notice that AdjustStock enforces the invariant (stock cannot go negative) and raises domain events — all within the aggregate boundary. No external service call needed. This is the hallmark of a well-defined aggregate.
The Application Layer
The application layer orchestrates use cases. It knows what the system does but not how — it calls interfaces that the infrastructure layer implements. This layer is where you define the contracts (interfaces) for repositories, external services, and other infrastructure concerns. The implementation details live elsewhere; the application layer only knows the shape of the dependency, never the concrete type.
Use Cases as MediatR Handlers
Each use case is a single class: a request (command or query) and its handler. This forces a 1:1 mapping between business operations and code paths, which makes the system navigable.
public sealed record AdjustStockCommand(
Guid InventoryItemId,
int Quantity,
string Reason) : IRequest<Result<StockAdjustmentResponse>>;
public sealed record StockAdjustmentResponse(
Guid InventoryItemId,
int NewQuantityOnHand,
DateTime AdjustedAt);
Validation with FluentValidation
Validation happens at the application layer boundary, before the handler executes. This keeps the domain layer free from input validation concerns (the domain enforces invariants; the application layer validates input shape).
public sealed class AdjustStockCommandValidator : AbstractValidator<AdjustStockCommand>
{
public AdjustStockCommandValidator()
{
RuleFor(x => x.InventoryItemId)
.NotEmpty()
.WithMessage("Inventory item ID is required.");
RuleFor(x => x.Quantity)
.NotEqual(0)
.WithMessage("Quantity adjustment cannot be zero.");
RuleFor(x => x.Reason)
.NotEmpty()
.MaximumLength(500)
.WithMessage("Reason must be between 1 and 500 characters.");
}
}
Wire validation into the MediatR pipeline using a behavior:
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken))))
.SelectMany(result => result.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
The Complete Command Handler
public sealed class AdjustStockCommandHandler(
IInventoryRepository repository,
IUnitOfWork unitOfWork,
ILogger<AdjustStockCommandHandler> logger)
: IRequestHandler<AdjustStockCommand, Result<StockAdjustmentResponse>>
{
public async Task<Result<StockAdjustmentResponse>> Handle(
AdjustStockCommand request,
CancellationToken cancellationToken)
{
var item = await repository.GetByIdAsync(request.InventoryItemId, cancellationToken);
if (item is null)
return Result.Failure<StockAdjustmentResponse>(
DomainErrors.InventoryItem.NotFound(request.InventoryItemId));
item.AdjustStock(request.Quantity, request.Reason);
repository.Update(item);
await unitOfWork.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Stock adjusted for {Sku}: {Quantity} units. Reason: {Reason}",
item.Sku, request.Quantity, request.Reason);
return new StockAdjustmentResponse(
item.Id,
item.QuantityOnHand,
DateTime.UtcNow);
}
}
The handler depends on IInventoryRepository and IUnitOfWork — interfaces defined in the Application layer, implemented in Infrastructure. The domain entity does the actual work (item.AdjustStock). The handler is pure orchestration.
The Infrastructure Layer
Infrastructure implements the interfaces defined by the Application layer. This is where EF Core, HTTP clients, message brokers, and caching live. Every external dependency — databases, file systems, third-party APIs, message queues — gets wrapped in an adapter that conforms to an Application-layer interface. If you ever need to swap PostgreSQL for SQL Server, or replace an SMTP provider with SendGrid, the change is confined to this layer. Nothing upstream needs to know.
Repository Implementation
public sealed class InventoryRepository(AppDbContext context)
: IInventoryRepository
{
public async Task<InventoryItem?> GetByIdAsync(
Guid id, CancellationToken cancellationToken = default)
{
return await context.InventoryItems
.Include(i => i.Adjustments)
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<InventoryItem>> GetBelowReorderThresholdAsync(
CancellationToken cancellationToken = default)
{
return await context.InventoryItems
.Where(i => i.QuantityOnHand <= i.ReorderThreshold)
.ToListAsync(cancellationToken);
}
public void Add(InventoryItem item) => context.InventoryItems.Add(item);
public void Update(InventoryItem item) => context.InventoryItems.Update(item);
}
Unit of Work with Domain Event Dispatching
public sealed class UnitOfWork(AppDbContext context, IMediator mediator) : IUnitOfWork
{
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var domainEvents = context.ChangeTracker
.Entries<Entity<Guid>>()
.SelectMany(e => e.Entity.DomainEvents)
.ToList();
var result = await context.SaveChangesAsync(cancellationToken);
foreach (var domainEvent in domainEvents)
{
await mediator.Publish(domainEvent, cancellationToken);
}
context.ChangeTracker
.Entries<Entity<Guid>>()
.ToList()
.ForEach(e => e.Entity.ClearDomainEvents());
return result;
}
}
External Service Adapters
When integrating with external systems, wrap them behind an interface defined in the Application layer:
// Application layer — the contract
public interface IEmailService
{
Task SendReorderAlertAsync(string sku, int currentStock, CancellationToken ct = default);
}
// Infrastructure layer — the implementation
public sealed class SmtpEmailService(IOptions<SmtpSettings> settings) : IEmailService
{
public async Task SendReorderAlertAsync(
string sku, int currentStock, CancellationToken ct = default)
{
using var client = new SmtpClient(settings.Value.Host, settings.Value.Port);
var message = new MailMessage(
settings.Value.FromAddress,
settings.Value.AlertRecipient,
$"Reorder Alert: {sku}",
$"Stock for {sku} is at {currentStock} units, below reorder threshold.");
await client.SendMailAsync(message, ct);
}
}
Caching with IDistributedCache
public sealed class CachedInventoryRepository(
IInventoryRepository inner,
IDistributedCache cache,
ILogger<CachedInventoryRepository> logger) : IInventoryRepository
{
private static readonly DistributedCacheEntryOptions CacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};
public async Task<InventoryItem?> GetByIdAsync(
Guid id, CancellationToken cancellationToken = default)
{
string cacheKey = $"inventory:{id}";
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (cached is not null)
{
logger.LogDebug("Cache hit for {CacheKey}", cacheKey);
return JsonSerializer.Deserialize<InventoryItem>(cached);
}
var item = await inner.GetByIdAsync(id, cancellationToken);
if (item is not null)
{
await cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(item),
CacheOptions,
cancellationToken);
}
return item;
}
// Write-through methods delegate directly and invalidate cache
public void Add(InventoryItem item) => inner.Add(item);
public void Update(InventoryItem item) => inner.Update(item);
public async Task<IReadOnlyList<InventoryItem>> GetBelowReorderThresholdAsync(
CancellationToken cancellationToken = default)
{
return await inner.GetBelowReorderThresholdAsync(cancellationToken);
}
}
Note the decorator pattern here — CachedInventoryRepository wraps the real repository without modifying it. This is far cleaner than scattering cache logic inside the repository itself.
The Presentation Layer
The presentation layer is the entry point. In .NET 8, you have two practical choices: minimal APIs and controller-based APIs. Both work well with Clean Architecture; the choice comes down to team preference and project complexity. Minimal APIs shine for microservices and smaller bounded contexts — less ceremony, fewer files, and a functional programming style. Controllers remain a better fit for large API surfaces where attribute routing, model binding conventions, and filter pipelines reduce repetitive code.
Minimal APIs
public static class InventoryEndpoints
{
public static void MapInventoryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/inventory")
.WithTags("Inventory")
.RequireAuthorization();
group.MapPost("/{id:guid}/adjust", AdjustStock)
.WithName("AdjustStock")
.Produces<StockAdjustmentResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesValidationProblem();
group.MapGet("/{id:guid}", GetById)
.WithName("GetInventoryItem")
.Produces<InventoryItemResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound);
}
private static async Task<IResult> AdjustStock(
Guid id,
AdjustStockRequest request,
ISender sender)
{
var command = new AdjustStockCommand(id, request.Quantity, request.Reason);
var result = await sender.Send(command);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.Problem(
statusCode: StatusCodes.Status404NotFound,
detail: result.Error.Message);
}
private static async Task<IResult> GetById(Guid id, ISender sender)
{
var query = new GetInventoryItemQuery(id);
var result = await sender.Send(query);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.Problem(
statusCode: StatusCodes.Status404NotFound,
detail: result.Error.Message);
}
}
Controller-Based APIs
[ApiController]
[Route("api/[controller]")]
public sealed class InventoryController(ISender sender) : ControllerBase
{
[HttpPost("{id:guid}/adjust")]
[ProducesResponseType(typeof(StockAdjustmentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AdjustStock(
Guid id,
[FromBody] AdjustStockRequest request)
{
var command = new AdjustStockCommand(id, request.Quantity, request.Reason);
var result = await sender.Send(command);
return result.IsSuccess
? Ok(result.Value)
: NotFound(result.Error.Message);
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(InventoryItemResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id)
{
var query = new GetInventoryItemQuery(id);
var result = await sender.Send(query);
return result.IsSuccess
? Ok(result.Value)
: NotFound(result.Error.Message);
}
}
Both approaches are thin — they accept HTTP input, translate it to a MediatR request, and translate the result back to HTTP. No business logic lives here. When the presentation layer starts growing fat, it is usually a sign that use case logic is leaking out of the Application layer.
Global Exception Handling Middleware
public sealed class GlobalExceptionMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
var errors = ex.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
await context.Response.WriteAsJsonAsync(new
{
type = "validation_error",
errors
});
}
catch (DomainException ex)
{
logger.LogWarning(ex, "Domain rule violation");
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await context.Response.WriteAsJsonAsync(new
{
type = "domain_error",
detail = ex.Message
});
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
type = "server_error",
detail = "An unexpected error occurred."
});
}
}
}
CQRS with MediatR
Command Query Responsibility Segregation separates reads from writes into distinct models. With MediatR, this becomes a structural pattern rather than an infrastructure decision. You do not need separate databases or event sourcing to benefit from CQRS — the separation at the handler level already gives you the ability to optimize read and write paths independently, which matters once your system handles non-trivial query patterns alongside complex write operations.
Commands Modify State
public sealed record AdjustStockCommand(
Guid InventoryItemId,
int Quantity,
string Reason) : IRequest<Result<StockAdjustmentResponse>>;
Queries Read State
public sealed record GetInventoryItemQuery(Guid Id)
: IRequest<Result<InventoryItemResponse>>;
public sealed class GetInventoryItemQueryHandler(
AppDbContext context)
: IRequestHandler<GetInventoryItemQuery, Result<InventoryItemResponse>>
{
public async Task<Result<InventoryItemResponse>> Handle(
GetInventoryItemQuery request,
CancellationToken cancellationToken)
{
var item = await context.InventoryItems
.AsNoTracking()
.Where(i => i.Id == request.Id)
.Select(i => new InventoryItemResponse(
i.Id,
i.Sku,
i.Name,
i.QuantityOnHand,
i.ReorderThreshold))
.FirstOrDefaultAsync(cancellationToken);
return item is not null
? Result.Success(item)
: Result.Failure<InventoryItemResponse>(
DomainErrors.InventoryItem.NotFound(request.Id));
}
}
Notice that the query handler bypasses the repository entirely and projects directly from DbContext with AsNoTracking(). This is intentional. Queries do not need to go through the domain model — they are not enforcing business rules. Projecting directly to DTOs avoids materializing full aggregate graphs for simple reads.
When CQRS Adds Value vs. When It Is Overhead
CQRS is worth the structural cost when:
- ✓Read and write models diverge significantly — the data you write looks different from what you read (e.g., a denormalized reporting view)
- ✓Read and write scaling requirements differ — you need to cache or replicate reads independently
- ✓Complex domain logic makes the write path heavyweight — separating reads avoids loading aggregate graphs for simple queries
- ✓You want independent optimization — query handlers can use raw SQL, Dapper, or read replicas without polluting the write model
CQRS is overhead when:
- ✓Your read and write models are nearly identical (basic CRUD)
- ✓The team is small and the domain is simple
- ✓You are building a prototype or an internal tool with a short lifespan
For most enterprise applications with a non-trivial domain, CQRS pays for itself within the first few months. For a todo app, it is over-engineering.
Dependency Injection Done Right
.NET 8's built-in DI container is sufficient for most applications. The key is understanding lifetimes and avoiding common traps. Misusing service lifetimes is one of the most frequent sources of production bugs in .NET applications — issues that are difficult to reproduce locally because they depend on request concurrency, timing, and garbage collection behavior.
Service Lifetimes
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddValidatorsFromAssembly(
typeof(DependencyInjection).Assembly,
includeInternalTypes: true);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
return services;
}
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// DbContext — Scoped (one per request, tracks changes across the request)
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Default")));
// Repositories — Scoped (same lifetime as DbContext)
services.AddScoped<IInventoryRepository, InventoryRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
// HTTP clients — use IHttpClientFactory (manages handler lifetimes internally)
services.AddHttpClient<IExternalPricingService, ExternalPricingService>(client =>
{
client.BaseAddress = new Uri(configuration["PricingApi:BaseUrl"]!);
client.Timeout = TimeSpan.FromSeconds(10);
});
// Caching — Singleton (thread-safe, shared across requests)
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = configuration.GetConnectionString("Redis");
});
// Decorator pattern for caching
services.Decorate<IInventoryRepository, CachedInventoryRepository>();
return services;
}
}
Common Pitfalls
Captive dependency: A singleton service that injects a scoped service. The scoped service gets captured and reused across requests, leading to stale data, concurrency bugs, or disposed DbContext exceptions.
// BAD — Singleton captures Scoped DbContext
services.AddSingleton<ISomeService, SomeService>(); // SomeService depends on AppDbContext
services.AddScoped<AppDbContext>();
// GOOD — Use IServiceScopeFactory to create scopes manually in singletons
public sealed class SomeBackgroundService(IServiceScopeFactory scopeFactory)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Use context within this scope
}
}
Service locator anti-pattern: Injecting IServiceProvider directly and resolving dependencies at runtime. This hides dependencies, makes testing harder, and defeats the purpose of compile-time dependency analysis.
// BAD — Hidden dependency resolved at runtime
public class OrderService(IServiceProvider provider)
{
public void Process()
{
var repo = provider.GetRequiredService<IOrderRepository>(); // Hidden!
}
}
// GOOD — Explicit constructor injection
public class OrderService(IOrderRepository repository)
{
public void Process()
{
// dependency is visible, testable, and verified at startup
}
}
Testing Each Layer Independently
Clean Architecture's primary payoff is testability. Each layer can be tested in isolation because dependencies flow in one direction and all external dependencies are behind interfaces. This is not an abstract benefit — it translates directly into faster feedback loops, fewer regression bugs, and the confidence to refactor without fear. The test pyramid in a Clean Architecture system has a wide base of fast domain tests, a middle layer of application tests with mocked infrastructure, and a narrow top of integration and end-to-end tests.
Domain Layer — No Mocks Required
Domain tests are the simplest and most valuable. They test pure business logic with no infrastructure dependencies.
public class InventoryItemTests
{
[Fact]
public void AdjustStock_WithSufficientQuantity_UpdatesQuantityOnHand()
{
var item = InventoryItem.Create("SKU-001", "Widget", reorderThreshold: 10);
item.AdjustStock(50, "Initial stock");
item.AdjustStock(-20, "Sold 20 units");
Assert.Equal(30, item.QuantityOnHand);
}
[Fact]
public void AdjustStock_BelowZero_ThrowsDomainException()
{
var item = InventoryItem.Create("SKU-001", "Widget", reorderThreshold: 10);
item.AdjustStock(10, "Initial stock");
var exception = Assert.Throws<DomainException>(
() => item.AdjustStock(-15, "Oversell attempt"));
Assert.Contains("Insufficient stock", exception.Message);
}
[Fact]
public void AdjustStock_BelowThreshold_RaisesReorderEvent()
{
var item = InventoryItem.Create("SKU-001", "Widget", reorderThreshold: 10);
item.AdjustStock(15, "Initial stock");
item.AdjustStock(-8, "Sold 8 units");
Assert.Contains(item.DomainEvents,
e => e is ReorderThresholdReachedEvent);
}
}
These tests run in milliseconds with no setup. No database, no DI container, no HTTP server. This is the payoff of keeping the domain layer pure.
Application Layer — Mock the Interfaces
Application layer tests verify that the orchestration logic works correctly. Mock the infrastructure interfaces.
public class AdjustStockCommandHandlerTests
{
private readonly Mock<IInventoryRepository> _repository = new();
private readonly Mock<IUnitOfWork> _unitOfWork = new();
private readonly Mock<ILogger<AdjustStockCommandHandler>> _logger = new();
[Fact]
public async Task Handle_ExistingItem_ReturnsUpdatedQuantity()
{
var item = InventoryItem.Create("SKU-001", "Widget", reorderThreshold: 10);
item.AdjustStock(100, "Initial stock");
_repository
.Setup(r => r.GetByIdAsync(item.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(item);
var handler = new AdjustStockCommandHandler(
_repository.Object, _unitOfWork.Object, _logger.Object);
var command = new AdjustStockCommand(item.Id, -30, "Customer order");
var result = await handler.Handle(command, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(70, result.Value.NewQuantityOnHand);
_unitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_NonExistentItem_ReturnsFailure()
{
_repository
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((InventoryItem?)null);
var handler = new AdjustStockCommandHandler(
_repository.Object, _unitOfWork.Object, _logger.Object);
var command = new AdjustStockCommand(Guid.NewGuid(), 10, "Restock");
var result = await handler.Handle(command, CancellationToken.None);
Assert.True(result.IsFailure);
}
}
Infrastructure Layer — Real Databases
For infrastructure tests, the choice between in-memory databases and real databases matters more than most teams realize.
In-memory databases (UseInMemoryDatabase) are fast but lie to you. They do not enforce foreign keys, do not support transactions the way a real database does, and miss provider-specific query translation bugs. They are acceptable for quick smoke tests but not for validating repository behavior.
TestContainers spins up a real database in Docker for each test run. Slower to start but catches real issues:
public class InventoryRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private AppDbContext _context = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_context = new AppDbContext(options);
await _context.Database.MigrateAsync();
}
[Fact]
public async Task GetByIdAsync_ExistingItem_ReturnsWithAdjustments()
{
var item = InventoryItem.Create("SKU-001", "Widget", reorderThreshold: 10);
item.AdjustStock(50, "Initial stock");
_context.InventoryItems.Add(item);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var repository = new InventoryRepository(_context);
var loaded = await repository.GetByIdAsync(item.Id);
Assert.NotNull(loaded);
Assert.Equal(50, loaded.QuantityOnHand);
Assert.Single(loaded.Adjustments);
}
public async Task DisposeAsync() => await _postgres.DisposeAsync();
}
Use TestContainers for CI. Use in-memory only when the tradeoff is understood and accepted.
Project Structure
A well-organized solution makes the architecture visible in the file explorer:
src/
├── Acme.Inventory.Domain/
│ ├── Entities/
│ │ ├── InventoryItem.cs
│ │ └── StockAdjustment.cs
│ ├── ValueObjects/
│ │ ├── Money.cs
│ │ └── Sku.cs
│ ├── Events/
│ │ ├── IDomainEvent.cs
│ │ ├── StockAdjustedEvent.cs
│ │ └── ReorderThresholdReachedEvent.cs
│ ├── Exceptions/
│ │ └── DomainException.cs
│ └── Common/
│ └── Entity.cs
│
├── Acme.Inventory.Application/
│ ├── Inventory/
│ │ ├── Commands/
│ │ │ ├── AdjustStock/
│ │ │ │ ├── AdjustStockCommand.cs
│ │ │ │ ├── AdjustStockCommandHandler.cs
│ │ │ │ └── AdjustStockCommandValidator.cs
│ │ │ └── CreateInventoryItem/
│ │ │ ├── CreateInventoryItemCommand.cs
│ │ │ ├── CreateInventoryItemCommandHandler.cs
│ │ │ └── CreateInventoryItemCommandValidator.cs
│ │ └── Queries/
│ │ ├── GetInventoryItem/
│ │ │ ├── GetInventoryItemQuery.cs
│ │ │ └── GetInventoryItemQueryHandler.cs
│ │ └── GetLowStockItems/
│ │ ├── GetLowStockItemsQuery.cs
│ │ └── GetLowStockItemsQueryHandler.cs
│ ├── Common/
│ │ ├── Behaviors/
│ │ │ ├── ValidationBehavior.cs
│ │ │ └── LoggingBehavior.cs
│ │ └── Interfaces/
│ │ ├── IInventoryRepository.cs
│ │ └── IUnitOfWork.cs
│ ├── DTOs/
│ │ ├── InventoryItemResponse.cs
│ │ └── StockAdjustmentResponse.cs
│ └── DependencyInjection.cs
│
├── Acme.Inventory.Infrastructure/
│ ├── Persistence/
│ │ ├── AppDbContext.cs
│ │ ├── Configurations/
│ │ │ └── InventoryItemConfiguration.cs
│ │ ├── Repositories/
│ │ │ └── InventoryRepository.cs
│ │ └── UnitOfWork.cs
│ ├── Caching/
│ │ └── CachedInventoryRepository.cs
│ ├── ExternalServices/
│ │ └── SmtpEmailService.cs
│ └── DependencyInjection.cs
│
├── Acme.Inventory.Api/
│ ├── Endpoints/
│ │ └── InventoryEndpoints.cs
│ ├── Middleware/
│ │ └── GlobalExceptionMiddleware.cs
│ ├── Program.cs
│ └── appsettings.json
│
tests/
├── Acme.Inventory.Domain.Tests/
├── Acme.Inventory.Application.Tests/
├── Acme.Inventory.Infrastructure.Tests/
└── Acme.Inventory.Api.Tests/
Each layer is its own .csproj. Project references enforce the dependency rule at compile time — if Acme.Inventory.Domain does not reference Acme.Inventory.Infrastructure, no developer can accidentally import an infrastructure type into the domain.
Anti-Patterns to Avoid
Anemic Domain Models
The most common violation in .NET projects. Entities become data bags and all logic lives in service classes.
// BAD — Anemic domain model
public class InventoryItem
{
public Guid Id { get; set; }
public string Sku { get; set; }
public int QuantityOnHand { get; set; } // Public setter, no invariant enforcement
}
public class InventoryService
{
public void AdjustStock(InventoryItem item, int quantity, string reason)
{
if (item.QuantityOnHand + quantity < 0)
throw new Exception("Insufficient stock");
item.QuantityOnHand += quantity; // Logic outside the entity
}
}
// GOOD — Rich domain model
public sealed class InventoryItem : Entity<Guid>
{
public int QuantityOnHand { get; private set; } // Private setter
public void AdjustStock(int quantity, string reason)
{
// Invariant enforcement inside the entity
if (QuantityOnHand + quantity < 0)
throw new DomainException("Insufficient stock.");
QuantityOnHand += quantity;
RaiseDomainEvent(new StockAdjustedEvent(Id, quantity, reason, DateTime.UtcNow));
}
}
The difference is not cosmetic. When the invariant lives inside the entity, it is impossible to bypass. When it lives in a service, every caller must remember to go through that service — and sooner or later, someone will not.
"God" Service Classes
// BAD — One service handling everything
public class InventoryService
{
public Task CreateItem(...) { }
public Task AdjustStock(...) { }
public Task TransferBetweenWarehouses(...) { }
public Task GenerateReport(...) { }
public Task SendAlerts(...) { }
public Task ReconcileWithExternalSystem(...) { }
// 30+ more methods
}
This class will grow without bound. Every new feature touches it. Merge conflicts are constant. Tests require setting up the entire class for each scenario.
With MediatR, each operation is its own handler. Adding a new feature means adding a new class — no existing code is modified.
Leaky Abstractions
// BAD — Repository returning IQueryable leaks EF Core details
public interface IInventoryRepository
{
IQueryable<InventoryItem> GetAll(); // Caller can compose arbitrary queries
}
// This lets the Application layer write EF Core-specific expressions:
var items = repo.GetAll()
.Include(i => i.Adjustments) // EF Core specific
.Where(i => EF.Functions.Like(i.Sku, "%WIDGET%")); // Provider-specific
// GOOD — Repository exposes specific, intention-revealing methods
public interface IInventoryRepository
{
Task<InventoryItem?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<InventoryItem>> GetBelowReorderThresholdAsync(CancellationToken ct = default);
Task<IReadOnlyList<InventoryItem>> SearchBySkuAsync(string skuPattern, CancellationToken ct = default);
void Add(InventoryItem item);
void Update(InventoryItem item);
}
Specific methods are easier to test, easier to cache, easier to optimize, and they document what the Application layer actually needs.
Case Study: Enterprise ERP Inventory Module
In mid-2024, Stripe Systems took on the restructuring of an inventory management module within a large ERP system built on .NET Framework 4.8. The module had been in production for seven years, maintained by rotating teams, and had accumulated significant structural debt.
The Starting Point
The legacy system had these characteristics:
- ✓Single project with 847 classes in a flat namespace structure
- ✓No architectural layers — Controllers directly called
SqlConnectionobjects with inline SQL - ✓12% test coverage — most tests were fragile integration tests that hit a shared dev database
- ✓Average PR review time: 4.2 hours — reviewers could not determine the blast radius of changes
- ✓Circular dependencies between namespaces (detected via NDepend)
- ✓~40 aggregate roots identified during domain analysis, but none were modeled as such in code
The Solution Structure After Migration
src/
├── ErpInventory.Domain/ → 62 classes, 0 external dependencies
├── ErpInventory.Application/ → 128 classes (commands, queries, validators)
├── ErpInventory.Infrastructure/ → 43 classes (EF Core, Redis, SMTP, SAP adapter)
├── ErpInventory.Api/ → 18 endpoint groups
│
tests/
├── ErpInventory.Domain.Tests/ → 214 tests (pure logic, <3s total run time)
├── ErpInventory.Application.Tests/ → 186 tests (mocked infrastructure)
├── ErpInventory.Infrastructure.Tests/ → 47 tests (TestContainers + PostgreSQL)
└── ErpInventory.Api.Tests/ → 31 tests (WebApplicationFactory)
Representative Aggregate: InventoryItem
The full InventoryItem aggregate after migration, handling stock adjustments, warehouse transfers, and reorder tracking:
public sealed class InventoryItem : AggregateRoot<InventoryItemId>
{
private readonly List<StockAdjustment> _adjustments = [];
private readonly List<WarehouseAllocation> _allocations = [];
public Sku Sku { get; private set; }
public ProductName Name { get; private set; }
public int TotalQuantityOnHand => _allocations.Sum(a => a.Quantity);
public Money UnitCost { get; private set; }
public int ReorderPoint { get; private set; }
public int ReorderQuantity { get; private set; }
public bool IsDiscontinued { get; private set; }
public IReadOnlyCollection<StockAdjustment> Adjustments => _adjustments.AsReadOnly();
public IReadOnlyCollection<WarehouseAllocation> Allocations => _allocations.AsReadOnly();
private InventoryItem() { }
public static InventoryItem Register(
Sku sku,
ProductName name,
Money unitCost,
int reorderPoint,
int reorderQuantity)
{
if (reorderPoint < 0)
throw new DomainException("Reorder point cannot be negative.");
if (reorderQuantity <= 0)
throw new DomainException("Reorder quantity must be positive.");
var item = new InventoryItem
{
Id = InventoryItemId.CreateNew(),
Sku = sku,
Name = name,
UnitCost = unitCost,
ReorderPoint = reorderPoint,
ReorderQuantity = reorderQuantity,
IsDiscontinued = false
};
item.RaiseDomainEvent(new InventoryItemRegisteredEvent(item.Id, sku));
return item;
}
public void ReceiveStock(WarehouseId warehouseId, int quantity, string referenceNumber)
{
Guard.Against.NegativeOrZero(quantity, nameof(quantity));
Guard.Against.NullOrWhiteSpace(referenceNumber, nameof(referenceNumber));
if (IsDiscontinued)
throw new DomainException($"Cannot receive stock for discontinued item {Sku}.");
var allocation = _allocations.FirstOrDefault(a => a.WarehouseId == warehouseId);
if (allocation is null)
{
allocation = new WarehouseAllocation(warehouseId, 0);
_allocations.Add(allocation);
}
allocation.IncreaseQuantity(quantity);
_adjustments.Add(StockAdjustment.Receipt(
Id, warehouseId, quantity, referenceNumber, DateTime.UtcNow));
RaiseDomainEvent(new StockReceivedEvent(Id, warehouseId, quantity, referenceNumber));
}
public void IssueStock(WarehouseId warehouseId, int quantity, string referenceNumber)
{
Guard.Against.NegativeOrZero(quantity, nameof(quantity));
var allocation = _allocations.FirstOrDefault(a => a.WarehouseId == warehouseId)
?? throw new DomainException(
$"Item {Sku} has no allocation in warehouse {warehouseId}.");
if (allocation.Quantity < quantity)
throw new DomainException(
$"Insufficient stock in warehouse {warehouseId}. " +
$"Available: {allocation.Quantity}, requested: {quantity}.");
allocation.DecreaseQuantity(quantity);
_adjustments.Add(StockAdjustment.Issue(
Id, warehouseId, quantity, referenceNumber, DateTime.UtcNow));
RaiseDomainEvent(new StockIssuedEvent(Id, warehouseId, quantity, referenceNumber));
if (TotalQuantityOnHand <= ReorderPoint)
{
RaiseDomainEvent(new ReorderPointReachedEvent(
Id, Sku, TotalQuantityOnHand, ReorderPoint, ReorderQuantity));
}
}
public void TransferBetweenWarehouses(
WarehouseId sourceWarehouse,
WarehouseId targetWarehouse,
int quantity,
string transferReference)
{
IssueStock(sourceWarehouse, quantity, transferReference);
ReceiveStock(targetWarehouse, quantity, transferReference);
RaiseDomainEvent(new StockTransferredEvent(
Id, sourceWarehouse, targetWarehouse, quantity, transferReference));
}
public void Discontinue(string reason)
{
if (IsDiscontinued)
throw new DomainException($"Item {Sku} is already discontinued.");
IsDiscontinued = true;
RaiseDomainEvent(new InventoryItemDiscontinuedEvent(Id, Sku, reason));
}
}
The MediatR Command Handler
public sealed record ReceiveStockCommand(
Guid InventoryItemId,
Guid WarehouseId,
int Quantity,
string ReferenceNumber) : IRequest<Result<StockReceiptResponse>>;
public sealed class ReceiveStockCommandHandler(
IInventoryItemRepository repository,
IUnitOfWork unitOfWork,
ILogger<ReceiveStockCommandHandler> logger)
: IRequestHandler<ReceiveStockCommand, Result<StockReceiptResponse>>
{
public async Task<Result<StockReceiptResponse>> Handle(
ReceiveStockCommand request,
CancellationToken cancellationToken)
{
var item = await repository.GetByIdAsync(
new InventoryItemId(request.InventoryItemId), cancellationToken);
if (item is null)
return Result.Failure<StockReceiptResponse>(
InventoryErrors.ItemNotFound(request.InventoryItemId));
item.ReceiveStock(
new WarehouseId(request.WarehouseId),
request.Quantity,
request.ReferenceNumber);
repository.Update(item);
await unitOfWork.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Received {Quantity} units of {Sku} at warehouse {WarehouseId}. Ref: {Reference}",
request.Quantity, item.Sku, request.WarehouseId, request.ReferenceNumber);
return new StockReceiptResponse(
item.Id.Value,
item.TotalQuantityOnHand,
DateTime.UtcNow);
}
}
The Results
After six months of incremental migration (running old and new code in parallel behind feature flags), the team at Stripe Systems measured the following:
| Metric | Before (.NET Framework 4.8) | After (.NET 8 Clean Architecture) |
|---|---|---|
| Test coverage | 12% | 78% |
| Domain test execution time | N/A | 2.8s |
| Full test suite time | 18 min | 4.2 min |
| Average PR review time | 4.2 hrs | 1.1 hrs |
| Production incidents/month | 8.3 | 1.7 |
| Time to onboard new dev | 3 weeks | 5 days |
| Cyclomatic complexity (avg) | 23.4 | 6.1 |
| Lines of code | 47,200 | 31,800 |
The line count dropped because the original codebase was full of duplicated logic — the same business rules reimplemented in multiple service classes. Once each rule lived in exactly one place (the aggregate), the duplication disappeared.
The test coverage increase was not the result of writing more tests per se. It was structural: Clean Architecture makes code testable by default. Domain logic is pure functions on entities. Application logic is handler orchestration against mockable interfaces. When code is easy to test, developers write tests. When it is hard, they do not.
Key Decisions That Mattered
One aggregate per bounded context per transaction. The original system would load 5-6 aggregates into memory, mutate all of them, and save in a single transaction. This caused lock contention under load. After restructuring, each command touches exactly one aggregate. Cross-aggregate consistency is handled through domain events processed asynchronously.
Strongly-typed IDs. Using InventoryItemId and WarehouseId instead of raw Guid caught 14 bugs during compilation that the old system would have surfaced only at runtime — cases where a warehouse ID was accidentally passed as an inventory item ID.
Vertical slice organization within each layer. Commands and queries are grouped by feature (e.g., Application/Inventory/Commands/ReceiveStock/) rather than by technical role. This makes it possible to understand a feature by looking at one folder instead of scanning across multiple directories.
Closing Thoughts
Clean Architecture is not a silver bullet. It adds structural overhead that is not justified for small applications or short-lived projects. The ceremony of separate projects, explicit interfaces, and MediatR handlers is a cost — one that pays off only when the system is complex enough and long-lived enough to benefit from the isolation.
For enterprise applications — systems with complex business rules, multiple integration points, long maintenance horizons, and rotating teams — that cost pays for itself quickly. The ability to test domain logic in milliseconds, swap infrastructure without touching business code, and onboard new developers by showing them one layer at a time is worth the initial setup time.
Start with the domain. Model the aggregates correctly. Let everything else follow from the dependency rule. The architecture will take care of the rest.
Ready to discuss your project?
Get in Touch →