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.

01

Why Clean Architecture?

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.

1.1

The dependency rule

// 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.

02

Solution Layout — Domain / Application / Infrastructure / API

A typical .NET 9 solution splits responsibilities into four projects. Names vary (Core, UseCases, Persistence) but the rings stay the same.

2.1

Project structure

// 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;
}
03

DDD — Aggregates, Value Objects & Domain Events

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.

04

CQRS — Commands vs Queries, Separate DbContexts

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.

4.1

Command vs query

// 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);
}
4.2

Separate DbContexts

// 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));
05

MediatR — Handlers, IRequest & Pipeline Behaviors

MediatR is an in-process mediator. The API sends an IRequest<T>; MediatR resolves the matching IRequestHandler. Pipeline behaviors wrap every request — validation, logging, transactions, caching — without polluting handlers.

5.1

API → MediatR → Handler

// Billing.Api/Endpoints/OrderEndpoints.cs
public static class OrderEndpoints
{
    public static void MapOrderEndpoints(this WebApplication app)
    {
        var g = app.MapGroup("/api/orders").WithTags("Orders");

        g.MapPost("/", async (PlaceOrderCommand cmd, ISender sender, CancellationToken ct) =>
        {
            var id = await sender.Send(cmd, ct);
            return Results.Created($"/api/orders/{id}", new { id });
        });

        g.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
        {
            var dto = await sender.Send(new GetOrderByIdQuery(id), ct);
            return dto is null ? Results.NotFound() : Results.Ok(dto);
        });
    }
}

// Controller equivalent — same pattern
// [HttpPost] public async Task<IActionResult> Place(PlaceOrderCommand cmd)
//     => Ok(await _sender.Send(cmd));
5.2

Pipeline behavior chain

// Order of registration = order of wrapping (outer → inner)
// Request flows: Logging → Validation → Transaction → Handler

public sealed class LoggingBehavior<TRequest, TResponse>(
    ILogger<LoggingBehavior<TRequest, TResponse>> log)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(
        TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        log.LogInformation("Handling {Request}", typeof(TRequest).Name);
        var sw = Stopwatch.StartNew();
        var response = await next();
        log.LogInformation("Handled in {Ms}ms", sw.ElapsedMilliseconds);
        return response;
    }
}

// IPipelineBehavior<TRequest,TResponse> — one class, all requests
// Or constrain: where TRequest : ICommand for transaction-only-on-writes
06

FluentValidation + TransactionBehavior

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.

6.1

ValidationBehavior

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();
    }
}
6.2

TransactionBehavior

// 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.

07

Outbox Pattern — Reliable Messaging

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)
08

EF Core Interceptors for Domain Events

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
09

Multi-tenancy + ICurrentUser

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);
}
9.2

Global query filter

// 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

Production Checklist

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"));
}
10.2

Observability & ops

// 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.