CQRS, Mediator and Observer in C#: command handlers and domain events without MediatR

.NET Architecture & Design Patterns November 13, 2026 csharp

CQRS: commands change state through the aggregate.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// ======================================================================
// Q: Explain Mediator / CQRS and Observer (domain events).
//
// CQRS: commands change state through the aggregate. Queries read directly with
// AsNoTracking() and projection. You don't need two databases: separation in the
// code is enough. Mediator: the controller doesn't know who executes the
// command, it just sends it.
// Observer (domain events): many handlers react to the same event, without the
// Order knowing about them.
// - Events are dispatched in SaveChangesAsync (e.g. with an EF Core
//   SaveChangesInterceptor).
// - Anything that leaves the process (ERP, another service) goes through the
//   outbox (Q10), not in-memory, so it isn't lost.
// - MediatR became commercial (commercial license) in 2025. Alternatives: your
//   own simple dispatcher as above, Wolverine, or a source-generated mediator.
// ======================================================================

// CQRS command, handler and thin controller
public record PlaceOrder(int OrderId);

public interface ICommandHandler<in TCommand>
{
    Task Handle(TCommand command, CancellationToken ct);
}

public class PlaceOrderHandler(IOrderRepository orders, IUnitOfWork uow) : ICommandHandler<PlaceOrder>
{
    public async Task Handle(PlaceOrder cmd, CancellationToken ct)
    {
        var order = await orders.GetAsync(cmd.OrderId, ct) ?? throw new NotFoundException();
        order.Place();                    // the business logic lives in the aggregate
        await uow.SaveChangesAsync(ct);   // domain events are dispatched here
    }
}

// Controller: thin
[HttpPost("{id:int}/place")]
public async Task<IActionResult> Place(int id, ICommandHandler<PlaceOrder> handler, CancellationToken ct)
{
    await handler.Handle(new PlaceOrder(id), ct);
    return NoContent();
}

// Observer: multiple domain event handlers for OrderPlaced
public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
{
    Task Handle(TEvent e, CancellationToken ct);
}

public class SendOrderConfirmation(IEmailSender email) : IDomainEventHandler<OrderPlaced> { /* ... */ }
public class WriteErpOutboxMessage(AppDbContext db)   : IDomainEventHandler<OrderPlaced> { /* ... */ }

builder.Services.AddScoped<IDomainEventHandler<OrderPlaced>, SendOrderConfirmation>();
builder.Services.AddScoped<IDomainEventHandler<OrderPlaced>, WriteErpOutboxMessage>();