CQRS in .NET with Cortex.Mediator

A practitioner's guide to building clean, testable .NET applications with Cortex.Mediator: the Mediator pattern, CQRS commands and queries, notifications, pipeline behaviors for logging, validation and transactions, and how it all fits Vertical Slice Architecture.

By Buildersoft Team

Most .NET applications start clean and end up tangled. A controller calls a service, the service calls three more services, each of those reaches into a repository, and somewhere along the way a cross-cutting concern — logging, validation, a transaction — gets bolted onto every method by hand. Six months in, nobody can tell what a single feature actually does without opening five folders.

The Mediator pattern and CQRS are a direct answer to that drift, and Cortex.Mediator is a small, focused implementation of both for .NET. It is part of the open-source Cortex Data Framework, but you do not need a single line of streaming code to use it. This post is for application developers building plain web APIs, services, and back-office tools.

The pattern, briefly

A Mediator sits between the code that issues a request and the code that handles it. Your controller does not know which class processes a CreateOrderCommand; it hands the command to IMediator and gets a result back. The sender and the handler are decoupled — they never reference each other.

CQRS (Command Query Responsibility Segregation) adds a second idea: split writes from reads.

  • Commands change state. They may return a value (a new ID) or nothing.
  • Queries return data and never change state.

That split is not bureaucracy for its own sake. Reads and writes have genuinely different shapes — reads want projections, paging, and caching; writes want validation, transactions, and domain events. Modeling them as separate types lets you optimize each independently and makes intent obvious from the class name alone.

Together, the two patterns enable Vertical Slice Architecture: instead of organizing code by technical layer (Controllers, Services, Repositories), you organize by feature. Everything for "create an order" — the command, its handler, its validator, its endpoint — lives in one folder. Each handler is a small, single-purpose class with its dependencies injected, which makes it trivial to unit test in isolation.

Rendering diagram…

Setting up Cortex.Mediator

Install the package:

dotnet add package Cortex.Mediator

Registration is a single call. In Program.cs:

using Cortex.Mediator.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Add Cortex.Mediator
builder.Services.AddCortexMediator(
    new[] { typeof(Program) },
    options => options.AddDefaultBehaviors() // Adds logging
);

builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

AddCortexMediator takes the marker types whose assemblies should be scanned as handlerAssemblyMarkerTypes and a configure action. It finds every command, query, and notification handler in those assemblies and registers them for you. AddDefaultBehaviors() wires up the standard logging pipeline behaviors. From there you inject IMediator wherever you need to dispatch a request.

The classic Startup.ConfigureServices form is also supported, scanning by marker type:

services.AddCortexMediator(
    handlerAssemblyMarkerTypes: new[] { typeof(Startup) },
    configure: options =>
    {
        options.AddDefaultBehaviors();
    }
);

Commands and command handlers

A command is a request to change state. It implements ICommand<TResult> from Cortex.Mediator.Commands. Here is the canonical example from the docs:

using Cortex.Mediator.Commands;

public class CreateOrderCommand : ICommand<Guid>
{
    public string CustomerName { get; set; }
    public string Email { get; set; }
    public List<OrderItem> Items { get; set; }
    public decimal TotalAmount { get; set; }
}

If a command returns nothing, use the Unit type (ICommand<Unit>), or the ICommand shorthand:

using Cortex.Mediator.Commands;

public class DeleteUserCommand : ICommand
{
    public Guid UserId { get; set; }
}

The handler implements ICommandHandler<TCommand, TResult> and exposes a single Handle method. Dependencies arrive through the constructor — exactly what you want for testing:

using Cortex.Mediator.Commands;

public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<CreateOrderCommandHandler> _logger;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        ILogger<CreateOrderCommandHandler> logger)
    {
        _orderRepository = orderRepository;
        _logger = logger;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Creating order for {CustomerName}", command.CustomerName);

        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerName = command.CustomerName,
            Email = command.Email,
            Items = command.Items,
            TotalAmount = command.TotalAmount,
            CreatedAt = DateTime.UtcNow
        };

        await _orderRepository.AddAsync(order, cancellationToken);
        await _orderRepository.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

Dispatching from a controller uses the simplified SendAsync, which infers the result type from the command:

[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
    var command = new CreateOrderCommand
    {
        CustomerName = request.CustomerName,
        Email = request.Email,
        Items = request.Items,
        TotalAmount = request.TotalAmount
    };

    var orderId = await _mediator.SendAsync(command);

    return CreatedAtAction(nameof(GetOrder), new { id = orderId }, new { orderId });
}

There is also an explicit, fully-typed form for when you want it: await _mediator.SendCommandAsync<CreateOrderCommand, Guid>(command). For void commands that is SendCommandAsync<DeleteUserCommand, Unit>(command).

Queries and query handlers

Queries follow the mirror image. A query implements IQuery<TResult> from Cortex.Mediator.Queries, and the handler implements IQueryHandler<TQuery, TResult>:

using Cortex.Mediator.Queries;

public class GetOrderQuery : IQuery<OrderDetailResponse>
{
    public Guid OrderId { get; init; }
}

public class GetOrderHandler : IQueryHandler<GetOrderQuery, OrderDetailResponse>
{
    private readonly ApplicationDbContext _context;

    public GetOrderHandler(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<OrderDetailResponse> Handle(
        GetOrderQuery query,
        CancellationToken cancellationToken)
    {
        var order = await _context.Orders
            .AsNoTracking()
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == query.OrderId, cancellationToken);

        if (order == null)
        {
            throw new NotFoundException(nameof(Order), query.OrderId);
        }

        return new OrderDetailResponse
        {
            Id = order.Id,
            OrderNumber = order.OrderNumber,
            TotalAmount = order.TotalAmount,
            Status = order.Status.ToString(),
            CreatedAt = order.CreatedAt
        };
    }
}

Note the read-side touches: AsNoTracking() and projecting into a response DTO rather than leaking the entity. Queries are dispatched with QueryAsync:

[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
    var result = await _mediator.QueryAsync(new GetOrderQuery { OrderId = id });
    return Ok(result);
}

The explicit form is SendQueryAsync<GetOrderQuery, OrderDetailResponse>(query).

Here is the heart of the value. Both controller actions are three lines. There is no service layer to thread the call through, no manual logging, no transaction scaffolding. All of that lives in the handlers and the pipeline — and the controller's only job is to translate HTTP into a request object.

Notifications: one event, many reactions

Commands and queries each have exactly one handler. Notifications are different: a notification can have zero, one, or many handlers, all invoked when you publish it. They model domain events — something happened that other parts of the system care about.

A notification implements INotification from Cortex.Mediator.Notifications; handlers implement INotificationHandler<TNotification>:

using Cortex.Mediator.Notifications;

public class OrderCreatedNotification : INotification
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public decimal TotalAmount { get; set; }
}

public class SendOrderConfirmationHandler : INotificationHandler<OrderCreatedNotification>
{
    private readonly IEmailService _emailService;

    public SendOrderConfirmationHandler(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public async Task Handle(
        OrderCreatedNotification notification,
        CancellationToken cancellationToken)
    {
        await _emailService.SendOrderConfirmationAsync(notification.OrderId, cancellationToken);
    }
}

You can add an UpdateInventoryHandler and an AwardLoyaltyPointsHandler for the same notification, and every one runs when the event fires — no central dispatcher to edit. A command handler publishes the event after it completes its work:

await _mediator.PublishAsync(new OrderCreatedNotification
{
    OrderId = order.Id,
    CustomerId = order.CustomerId,
    TotalAmount = order.TotalAmount
}, cancellationToken);

Handlers run sequentially in registration order. Name notifications in the past tense (OrderCreated, not CreateOrder), keep each handler independent, and reserve them for side effects — not for operations the caller depends on.

Pipeline behaviors: cross-cutting concerns, once

This is where the Mediator earns its keep. A pipeline behavior is middleware for your requests: it wraps the handler, runs code before and after, can short-circuit the pipeline, and can catch exceptions — all without the handler knowing it exists.

Cortex.Mediator ships several built-in behaviors:

BehaviorPurposeApplies to
LoggingCommandBehaviorLogs command execution with timingCommands
LoggingQueryBehaviorLogs query execution with timingQueries
ValidationCommandBehaviorFluentValidation supportCommands
ExceptionHandlingCommandBehaviorCentralized exception handlingCommands
CachingQueryBehaviorAutomatic query result cachingQueries

A behavior is an open generic. For commands it implements ICommandPipelineBehavior<TCommand, TResult>, and its Handle method receives a CommandHandlerDelegate<TResult> next — call next() to continue down the pipeline toward the handler:

using System.Diagnostics;
using Cortex.Mediator.Commands;

public class TimingCommandBehavior<TCommand, TResult>
    : ICommandPipelineBehavior<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    private readonly ILogger<TimingCommandBehavior<TCommand, TResult>> _logger;

    public TimingCommandBehavior(ILogger<TimingCommandBehavior<TCommand, TResult>> logger)
    {
        _logger = logger;
    }

    public async Task<TResult> Handle(
        TCommand command,
        CommandHandlerDelegate<TResult> next,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();

        var result = await next(); // continue to the next behavior or the handler

        stopwatch.Stop();
        _logger.LogInformation(
            "Completed {CommandName} in {ElapsedMs}ms",
            typeof(TCommand).Name,
            stopwatch.ElapsedMilliseconds);

        return result;
    }
}

Register a custom open-generic behavior with AddOpenCommandPipelineBehavior (queries have AddOpenQueryPipelineBehavior):

builder.Services.AddCortexMediator(
    new[] { typeof(Program) },
    options => options.AddOpenCommandPipelineBehavior(typeof(TimingCommandBehavior<,>))
);

Order matters. Behaviors execute in registration order — the first registered is the outermost, running first on the way in and last on the way out. A typical chain: exception handling outermost, then logging, then validation closest to the handler.

Validation with FluentValidation

The Cortex.Mediator.Behaviors.FluentValidation package plugs FluentValidation into the pipeline. Validation runs before the handler, and on failure it throws a ValidationException — the handler never sees invalid input. Install and register:

dotnet add package Cortex.Mediator.Behaviors.FluentValidation
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
using Cortex.Mediator.DependencyInjection;
using FluentValidation;

builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

builder.Services.AddCortexMediator(
    new[] { typeof(Program) },
    options => options
        .AddDefaultBehaviors()
        .AddOpenCommandPipelineBehavior(typeof(ValidationCommandBehavior<,>))
);

Then write a plain FluentValidation validator for the command — no mediator-specific base class required:

using FluentValidation;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerName).NotEmpty();
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Items).NotEmpty().WithMessage("At least one item is required");
        RuleFor(x => x.TotalAmount).GreaterThan(0);
    }
}
Rendering diagram…

Transactions

The Cortex.Mediator.Behaviors.Transactional package wraps command execution in a transaction scope, committing on success and rolling back on any exception — so a handler that performs several writes either persists all of them or none.

dotnet add package Cortex.Mediator.Behaviors.Transactional

Add the transactional pipeline behaviors to the mediator options, and register the transactional options on the service collection:

using Cortex.Mediator.DependencyInjection;
using Cortex.Mediator.Behaviors.Transactional.DependencyInjection;

builder.Services.AddCortexMediator(
    new[] { typeof(Program) },
    options => options
        .AddDefaultBehaviors()
        .AddTransactionalBehaviors()
);

// Register transactional options (with optional configuration)
builder.Services.AddTransactionalBehavior(options =>
{
    options.IsolationLevel = IsolationLevel.ReadCommitted;
    options.Timeout = TimeSpan.FromSeconds(30);
});

For full control over how a transaction begins, commits, and rolls back, implement ITransactionalContext — for example over EF Core — and register it:

public class EfCoreTransactionalContext : ITransactionalContext
{
    private readonly ApplicationDbContext _context;
    private IDbContextTransaction _transaction;

    public EfCoreTransactionalContext(ApplicationDbContext context) => _context = context;

    public async Task BeginTransactionAsync(CancellationToken ct = default) =>
        _transaction = await _context.Database.BeginTransactionAsync(ct);

    public async Task CommitAsync(CancellationToken ct = default)
    {
        await _context.SaveChangesAsync(ct);
        await _transaction.CommitAsync(ct);
    }

    public async Task RollbackAsync(CancellationToken ct = default) =>
        await _transaction.RollbackAsync(ct);
}

builder.Services.AddTransactionalBehavior();
builder.Services.AddTransactionalContext<EfCoreTransactionalContext>();

The recommended order is validation first (fail fast before a transaction opens), then the transaction wrapping the actual execution. Mark read-only operations with [NonTransactional] to skip the overhead, and keep transaction scopes short — never wrap external calls like email sends inside them.

How it fits Vertical Slice Architecture

Because every command and query is a self-contained type with its own handler, you can stop organizing by layer and start organizing by feature. Instead of Controllers/, Services/, and Repositories/ folders that you edit in lockstep for every change, each feature gets a folder:

Features/Orders/
├── CreateOrder/
│   ├── CreateOrderCommand.cs
│   ├── CreateOrderHandler.cs
│   ├── CreateOrderValidator.cs
│   └── CreateOrderEndpoint.cs
├── GetOrder/
│   ├── GetOrderQuery.cs
│   ├── GetOrderHandler.cs
│   └── GetOrderEndpoint.cs
└── Events/
    ├── OrderCreatedNotification.cs
    └── SendOrderConfirmationHandler.cs

Adding a feature means adding a folder; removing one means deleting it. Nothing else moves. A minimal-API endpoint becomes a thin adapter over the mediator:

public static class CreateOrderEndpoint
{
    public static void MapCreateOrderEndpoint(this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", async (
            CreateOrderCommand command,
            IMediator mediator,
            CancellationToken cancellationToken) =>
        {
            var result = await mediator.SendAsync(command, cancellationToken);
            return Results.Created($"/api/orders/{result}", result);
        });
    }
}

The payoff compounds. Cross-cutting concerns are defined once as behaviors and apply to every slice automatically. Handlers have explicit constructor dependencies, so unit tests construct them directly and assert on the result — no HTTP host, no DI container. For small projects the separation costs little; as the application grows, the structure keeps it readable, testable, and easy to change one feature at a time.

Where to go next

Ready to Transform Your Business?

Let's discuss how we can help you build innovative solutions that drive real results.