In-Depth Guide · 10 Sections · Enterprise .NET
Clean Architecture + CQRS In Depth
From Domain aggregates and CQRS separation to MediatR pipeline behaviors, FluentValidation, the transactional Outbox, EF Core interceptors, multi-tenancy and a production checklist. Plain English, copy-paste .NET 9 code.
Clean Architecture
CQRS & MediatR
DDD & Domain Events
Outbox & Production
Prerequisite: solid C# and ASP.NET Core — start with OOP & SOLID, Minimal API for Dummies, and MassTransit & RabbitMQ (section 9 covers outbox from the messaging side).
For a working reference implementation see OutboxPattern on GitHub.
Most apps start simple — controllers call EF directly, business rules live in services, and every change ripples through the stack. Clean Architecture (Uncle Bob / onion / ports-and-adapters) flips the dependency rule: inner layers never depend on outer layers. Your domain and application logic stay testable and framework-agnostic; infrastructure is a plug-in.
// Dependencies point INWARD only:
// API (Controllers, Minimal APIs)
// ↓ references
// Application (Commands, Queries, Handlers, Interfaces)
// ↓ references
// Domain (Entities, Value Objects, Domain Events)
// ↑ implemented by
// Infrastructure (EF Core, MassTransit, Email, FileStorage)
// Domain has ZERO references to EF, ASP.NET, MediatR, or SQL.
// Application defines IOrderRepository — Infrastructure implements it.
// API sends IRequest via MediatR — never touches DbContext directly.
1.2
What you gain (and what it costs)
// ✅ Gains
// - Unit-test business rules without a database
// - Swap EF for Dapper, SQL for Postgres, without touching Domain
// - Clear boundaries — new devs know where code belongs
// - CQRS + Outbox fit naturally in Application/Infrastructure
// ⚠️ Costs
// - More projects and files (worth it above ~5 endpoints)
// - Mapping between Domain entities and DTOs
// - Discipline — resist putting EF in handlers
// When to use: multi-year products, teams, regulated domains
// When to skip: prototypes, CRUD admin panels, throwaway tools
Watch Out — architecture theatre
Four projects with anemic entities and fat controllers that still call DbContext directly is not Clean Architecture — it's folder tax. The test: can you unit-test Order.Place() without EF, HTTP, or a running SQL Server? If not, your domain layer is a DTO bag.
A typical .NET 9 solution splits responsibilities into four projects. Names vary (Core, UseCases, Persistence) but the rings stay the same.
// Solution: Billing.sln
Billing.Domain/ // No NuGet deps except maybe MediatR.Contracts
Common/ BaseEntity, IDomainEvent, ValueObject
Orders/ Order.cs, OrderLine.cs, OrderPlacedEvent.cs
Customers/ Customer.cs, EmailAddress.cs
Billing.Application/ // References Domain only
Orders/
Commands/ PlaceOrderCommand.cs, PlaceOrderHandler.cs
Queries/ GetOrderByIdQuery.cs, GetOrderByIdHandler.cs
Common/
Interfaces/ IApplicationDbContext.cs, ICurrentUser.cs
Behaviors/ ValidationBehavior.cs, TransactionBehavior.cs
Billing.Infrastructure/ // References Application + Domain
Persistence/ ApplicationDbContext.cs, OrderRepository.cs
Messaging/ OutboxPublisher.cs, MassTransitConfig.cs
Identity/ CurrentUserService.cs
Billing.Api/ // References Application + Infrastructure
Program.cs
Endpoints/ OrderEndpoints.cs
2.2
DI registration — composition root
// Billing.Api/Program.cs — the ONLY place that wires everything
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplication(); // MediatR, FluentValidation, behaviors
builder.Services.AddInfrastructure(
builder.Configuration); // EF, Outbox, MassTransit, ICurrentUser
var app = builder.Build();
app.MapOrderEndpoints();
app.Run();
// Billing.Application/DependencyInjection.cs
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(PlaceOrderHandler).Assembly));
services.AddValidatorsFromAssembly(typeof(PlaceOrderValidator).Assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
return services;
}
Domain-Driven Design keeps business rules inside rich domain models. An aggregate is a cluster of entities with one root — the only entry point for changes. Value objects are immutable and compared by value. Domain events record something meaningful that happened inside the domain.
3.1
Aggregate root with invariants
// Billing.Domain/Orders/Order.cs
public sealed class Order : BaseEntity
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } = Money.Zero;
private Order() { } // EF
public static Order Create(CustomerId customerId)
{
var order = new Order { Id = OrderId.New(), CustomerId = customerId };
order.Raise(new OrderCreatedEvent(order.Id));
return order;
}
public void AddLine(ProductId productId, int qty, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a placed order.");
if (qty <= 0) throw new DomainException("Quantity must be positive.");
_lines.Add(new OrderLine(productId, qty, unitPrice));
Total = Money.Sum(_lines.Select(l => l.LineTotal));
}
public void Place()
{
if (_lines.Count == 0) throw new DomainException("Empty order.");
Status = OrderStatus.Placed;
Raise(new OrderPlacedEvent(Id, CustomerId, Total));
}
}
3.2
Value objects & domain events
// Value object — immutable, no identity
public sealed record Money(decimal Amount, string Currency)
{
public static Money Zero => new(0m, "EUR");
public static Money Sum(IEnumerable<Money> items) =>
new(items.Sum(m => m.Amount), items.First().Currency);
}
public sealed record EmailAddress
{
public string Value { get; }
public EmailAddress(string value)
{
if (!value.Contains('@')) throw new DomainException("Invalid email.");
Value = value.Trim().ToLowerInvariant();
}
}
// Domain event — past tense, raised inside aggregate
public sealed record OrderPlacedEvent(OrderId OrderId, CustomerId CustomerId, Money Total)
: IDomainEvent;
// Base entity collects events until SaveChanges dispatches them
public abstract class BaseEntity
{
private readonly List<IDomainEvent> _events = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _events.AsReadOnly();
protected void Raise(IDomainEvent e) => _events.Add(e);
public void ClearDomainEvents() => _events.Clear();
}
Watch Out — anemic domain models
If your entity is only properties and every rule lives in a OrderService.PlaceOrder() method, you have an anemic model. Move invariants into the aggregate — order.Place() should be the only way to transition status, and it should enforce all rules internally.
CQRS (Command Query Responsibility Segregation) splits writes from reads. Commands change state and return little or nothing; queries return DTOs and never mutate. In .NET you often use two DbContexts — one tracked for commands, one no-tracking for reads.
// COMMAND — mutates state, returns ID or Unit
public sealed record PlaceOrderCommand(
Guid CustomerId,
IReadOnlyList<PlaceOrderLineDto> Lines) : IRequest<Guid>;
public sealed class PlaceOrderHandler(
IApplicationDbContext db) : IRequestHandler<PlaceOrderCommand, Guid>
{
public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(new CustomerId(cmd.CustomerId));
foreach (var line in cmd.Lines)
order.AddLine(new ProductId(line.ProductId), line.Qty,
new Money(line.Price, "EUR"));
order.Place();
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id.Value;
}
}
// QUERY — read-only, returns DTO (never domain entity to API)
public sealed record GetOrderByIdQuery(Guid Id) : IRequest<OrderDto?>;
public sealed class GetOrderByIdHandler(IReadDbContext db)
: IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
public Task<OrderDto?> Handle(GetOrderByIdQuery q, CancellationToken ct) =>
db.Orders.AsNoTracking()
.Where(o => o.Id == q.Id)
.Select(o => new OrderDto(o.Id, o.Status, o.Total))
.FirstOrDefaultAsync(ct);
}
// Write context — tracked entities, domain events, transactions
public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> opts)
: DbContext(opts), IApplicationDbContext
{
public DbSet<Order> Orders => Set<Order>();
// Fluent API configs, interceptors registered here
}
// Read context — no tracking, can target read replica or projections
public sealed class ReadDbContext(DbContextOptions<ReadDbContext> opts)
: DbContext(opts), IReadDbContext
{
public DbSet<OrderReadModel> Orders => Set<OrderReadModel>();
protected override void OnConfiguring(DbContextOptionsBuilder b) =>
b.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
// Program.cs — same connection string initially; split when you scale reads
builder.Services.AddDbContext<ApplicationDbContext>(o =>
o.UseSqlServer(conn).AddInterceptors(sp.GetRequiredService<DispatchDomainEventsInterceptor>()));
builder.Services.AddDbContext<ReadDbContext>(o =>
o.UseSqlServer(conn));
Validate at the application boundary with FluentValidation in a pipeline behavior — fail fast before hitting the database. Wrap commands in a TransactionBehavior so SaveChanges and outbox writes commit atomically.
public sealed class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Lines).NotEmpty();
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Qty).GreaterThan(0);
line.RuleFor(l => l.Price).GreaterThan(0);
});
}
}
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (!validators.Any()) return await next();
var ctx = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(ctx))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
// Marker interface — only commands get transactions
public interface ICommand<out TResponse> : IRequest<TResponse> { }
public sealed record PlaceOrderCommand(...) : ICommand<Guid>;
public sealed class TransactionBehavior<TRequest, TResponse>(
IApplicationDbContext db)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand<TResponse>
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (db.Database.CurrentTransaction is not null)
return await next(); // nested — saga or nested command
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
var response = await next();
await db.SaveChangesAsync(ct); // domain events + outbox in same UoW
await tx.CommitAsync(ct);
return response;
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}
Watch Out — double SaveChanges
Pick one owner of SaveChangesAsync — either the handler or the TransactionBehavior, not both. The pattern above lets the behavior own the commit so interceptors (domain events → outbox) run once, inside the transaction.
The transactional outbox solves the dual-write problem: you need to update SQL and publish a message, but either can fail independently. Write the message to an OutboxMessages table in the same database transaction as your entity changes; a background worker publishes and marks rows processed.
7.1
Outbox table & publisher
public sealed class OutboxMessage
{
public Guid Id { get; init; }
public string Type { get; init; } = default!;
public string Payload { get; init; } = default!;
public DateTime OccurredOn { get; init; }
public DateTime? ProcessedOn { get; set; }
public string? Error { get; set; }
}
// Domain event → outbox row (same transaction as Order update)
public static OutboxMessage FromEvent(IDomainEvent e) => new()
{
Id = Guid.NewGuid(),
Type = e.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(e, e.GetType()),
OccurredOn = DateTime.UtcNow
};
// BackgroundService polls unpublished rows
public sealed class OutboxPublisher(IServiceScopeFactory scopes) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
var pending = await db.OutboxMessages
.Where(m => m.ProcessedOn == null)
.OrderBy(m => m.OccurredOn)
.Take(20)
.ToListAsync(ct);
foreach (var msg in pending)
{
await bus.Publish(Deserialize(msg), ct);
msg.ProcessedOn = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}
}
7.2
Reference implementation
// Full working sample — copy patterns, don't reinvent:
// https://github.com/stevsharp/OutboxPattern
// - ApplicationDbContext with OutboxMessages DbSet
// - Domain event interceptor → outbox insert
// - OutboxProcessor hosted service
// - MassTransit publish on process
// Flow:
// 1. Handler mutates Order aggregate
// 2. SaveChanges → interceptor copies DomainEvents to OutboxMessages
// 3. Transaction commits (Order + Outbox atomic)
// 4. OutboxPublisher reads rows, publishes to RabbitMQ
// 5. Consumer handles OrderPlacedEvent (send email, reserve stock)
// Pair with masstransit-rabbitmq-tutorial.html section 9 (Sagas & Outbox)
Don't dispatch domain events inside handlers — use an EF Core SaveChangesInterceptor to collect events from tracked aggregates and convert them to outbox rows right before commit.
8.1
DispatchDomainEventsInterceptor
public sealed class DispatchDomainEventsInterceptor : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var ctx = eventData.Context;
if (ctx is null) return await base.SavingChangesAsync(eventData, result, ct);
var aggregates = ctx.ChangeTracker
.Entries<BaseEntity>()
.Where(e => e.Entity.DomainEvents.Count != 0)
.Select(e => e.Entity)
.ToList();
var outbox = ctx.Set<OutboxMessage>();
foreach (var aggregate in aggregates)
{
foreach (var domainEvent in aggregate.DomainEvents)
outbox.Add(OutboxMessage.FromEvent(domainEvent));
aggregate.ClearDomainEvents();
}
return await base.SavingChangesAsync(eventData, result, ct);
}
}
// Register as singleton interceptor, add to DbContext options
services.AddSingleton<DispatchDomainEventsInterceptor>();
8.2
In-process handlers (optional)
// For side effects INSIDE the same service (email, cache invalidation)
// use MediatR INotification handlers — AFTER commit, not inside transaction
public sealed class OrderPlacedEventHandler(
IEmailSender email) : INotificationHandler<OrderPlacedEvent>
{
public Task Handle(OrderPlacedEvent e, CancellationToken ct) =>
email.SendAsync(e.CustomerId, "Order confirmed", ct);
}
// Publish notifications AFTER SaveChanges succeeds:
public sealed class DomainEventPublishingInterceptor(
IPublisher publisher) : SaveChangesInterceptor
{
private List<IDomainEvent> _events = [];
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken ct)
{
foreach (var e in _events)
_ = publisher.Publish(e, ct); // fire-and-forget or await in scope
_events.Clear();
return base.SavedChangesAsync(eventData, result, ct);
}
}
// Rule: outbox for cross-service; INotification for same-process reactions
SaaS apps need tenant isolation. Define ICurrentUser in Application; implement it in Infrastructure from HttpContext. Apply a global query filter on TenantId so developers can't accidentally leak data across tenants.
9.1
ICurrentUser abstraction
// Billing.Application/Common/Interfaces/ICurrentUser.cs
public interface ICurrentUser
{
Guid? UserId { get; }
Guid? TenantId { get; }
bool IsAuthenticated { get; }
bool IsInRole(string role);
}
// Billing.Infrastructure/Identity/CurrentUserService.cs
public sealed class CurrentUserService(IHttpContextAccessor http) : ICurrentUser
{
public Guid? UserId => ParseGuid(http.HttpContext?.User
.FindFirstValue(ClaimTypes.NameIdentifier));
public Guid? TenantId => ParseGuid(http.HttpContext?.User
.FindFirstValue("tenant_id"));
public bool IsAuthenticated =>
http.HttpContext?.User.Identity?.IsAuthenticated == true;
public bool IsInRole(string role) =>
http.HttpContext?.User.IsInRole(role) == true;
}
// Handler — tenant-aware without HttpContext
public sealed class GetOrdersHandler(IReadDbContext db, ICurrentUser user)
: IRequestHandler<GetOrdersQuery, List<OrderDto>>
{
public Task<List<OrderDto>> Handle(GetOrdersQuery q, CancellationToken ct) =>
db.Orders.Where(o => o.TenantId == user.TenantId)
.ProjectToDto()
.ToListAsync(ct);
}
// ITenantEntity — marker on all tenant-scoped tables
public interface ITenantEntity { Guid TenantId { get; } }
public sealed class ApplicationDbContext(
DbContextOptions<ApplicationDbContext> opts,
ICurrentUser currentUser) : DbContext(opts)
{
protected override void OnModelCreating(ModelBuilder model)
{
// Global filter — every query auto-scoped to current tenant
foreach (var entityType in model.Model.GetEntityTypes())
{
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
{
model.Entity(entityType.ClrType)
.HasQueryFilter(BuildTenantFilter(entityType.ClrType));
}
}
}
private LambdaExpression BuildTenantFilter(Type type)
{
var param = Expression.Parameter(type, "e");
var tenantId = Expression.Property(param, nameof(ITenantEntity.TenantId));
var current = Expression.Property(
Expression.Constant(currentUser), nameof(ICurrentUser.TenantId));
var body = Expression.Equal(tenantId, Expression.Convert(current, typeof(Guid)));
return Expression.Lambda(body, param);
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
if (entry.State == EntityState.Added)
entry.Property(nameof(ITenantEntity.TenantId)).CurrentValue
= currentUser.TenantId ?? throw new UnauthorizedAccessException();
return base.SaveChangesAsync(ct);
}
}
Watch Out — bypassing the filter
IgnoreQueryFilters() is an escape hatch for admin reports — use it deliberately and audit every call. Background jobs have no HttpContext; pass TenantId explicitly into the command instead of relying on ICurrentUser.
10.1
Architecture & data rules
- Domain has no EF, ASP.NET, or MassTransit references.
- Handlers are thin — orchestrate, don't contain business rules.
- Queries return DTOs; never expose
Order entities to the API.
- Commands use transactions; queries never call
SaveChanges.
- Domain events → outbox for cross-service; same-process →
INotification.
- Idempotent consumers — messages can be delivered more than once.
- Tenant filter on every
ITenantEntity; test with two tenants.
// Integration test — proves layers wired correctly
[Fact]
public async Task PlaceOrder_persists_and_writes_outbox()
{
await using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
s.AddScoped<IApplicationDbContext>(sp =>
sp.GetRequiredService<ApplicationDbContext>())));
var sender = factory.Services.GetRequiredService<ISender>();
var id = await sender.Send(new PlaceOrderCommand(...));
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.NotNull(await db.Orders.FindAsync(id));
Assert.Contains(db.OutboxMessages, m => m.Type.Contains("OrderPlacedEvent"));
}
// Production essentials
// ✓ Structured logging in pipeline behaviors (request type, duration, tenant)
// ✓ OpenTelemetry traces: API → MediatR → EF → Outbox publish
// ✓ Health checks: SQL, RabbitMQ, outbox backlog count
// ✓ Outbox dead-letter: rows with Error + retry count > 5 → alert
// ✓ EF migrations in CI/CD — never EnsureCreated in prod
// ✓ Read replica connection string for ReadDbContext when traffic grows
// ✓ API versioning + problem details for ValidationException
// Package versions (.NET 9)
// MediatR 12+
// FluentValidation 11+
// EF Core 9
// MassTransit 8+ (if using outbox publish)
Mastery Move — where to go next
1. MassTransit & RabbitMQ — wire outbox rows to consumers, retries, sagas and idempotent handlers.
2. EF Core In Depth — migrations, compiled queries, interceptors and read-model projections.
3. Job Reference — how these patterns show up in real .NET job postings and interview loops.
4. OutboxPattern repo — clone, run, and extend the reference solution.