Backend Intermediate 18 min

Practical CQRS with FastEndpoints and MediatR

Separating reads from writes doesn't have to be complicated. Learn how to implement a practical CQRS architecture using FastEndpoints for the API and MediatR for clean command handling.

By Victor Robin Updated:

When I first attempted to implement CQRS in a .NET project, I over-engineered it dramatically — separate databases, an event bus for synchronization, dedicated read-model projectors, the whole works. The result was a system so complicated that onboarding new developers took weeks instead of days. It was only after stripping it back to a simple “commands use domain entities, queries use projections” split within a single PostgreSQL database that the pattern started paying dividends. The lesson I keep coming back to is that CQRS is a spectrum, not a binary choice, and you should adopt only the complexity your problem actually demands.

Introduction

Command Query Responsibility Segregation (CQRS) is a pattern that separates the operations that read data (Queries) from the operations that update data (Commands).

In a traditional “CRUD” architecture, the same model entity is often used for both saving to the database and returning data to the UI. This leads to bloated classes with properties like IsPasswordConfidential or complex mappings that slow down simple reads.

Why CQRS:

  • Performance: You can optimize your “Read” store (e.g., flattened SQL views, ElasticSearch) without worrying about normal forms required for writes.
  • Security: Commands have strict validation and business logic. Queries just fetch data.
  • Scalability: Reads usually outnumber writes 100:1. You can scale the Read side independently.
[CQRS Pattern] — Microsoft , 2024-06-12

What We’ll Build

We will implement a vertical slice architecture using:

  1. FastEndpoints: A high-performance alternative to Controllers for defining API endpoints.
  2. MediatR (Optional): For dispatching internal application logic, though FastEndpoints can handle dispatching natively too. We’ll show the pattern of distinct Request/Response pairs.
  3. Materialized Views: A dedicated read model.

Architecture Overview

We strictly separate the path data takes when entering the system.

flowchart TD
    User[Client]

    subgraph Write_Side
        Command[Command: CreateUser]
        Domain[Domain Logic]
        WriteDB[(Write DB\nPostgres)]
    end

    subgraph Read_Side
        Query[Query: GetUserProfile]
        ReadModel[Read DTO]
        ReadDB[(Read DB / Cache)]
    end

    User -->|POST| Command
    Command --> Domain
    Domain --> WriteDB

    User -->|GET| Query
    Query --> ReadDB

    WriteDB -.->|Sync/Event| ReadDB

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class User warning
    class Domain primary
    class Command,Query,ReadModel secondary
    class WriteDB,ReadDB db

Section 1: The Command (Write Side)

Using FastEndpoints, we treat every endpoint as a distinct “Handler”.

[FastEndpoints Documentation] — Dj Mudaliar , 2024-09-01

Here is a command to Create a Document. Notice it only returns an ID, not the full object.

// 1. The Request DTO
public class CreateDocumentRequest
{
    public string Title { get; set; }
    public string Content { get; set; }
}

// 2. The Endpoint
public class CreateDocumentEndpoint : Endpoint<CreateDocumentRequest, Guid>
{
    public override void Configure()
    {
        Post("/documents");
        AllowAnonymous();
    }

    public override async Task HandleAsync(CreateDocumentRequest req, CancellationToken ct)
    {
        // 3. Domain Logic (Command)
        var doc = Document.Create(req.Title, req.Content);

        await Data.AddAsync(doc);
        await Data.SaveChangesAsync();

        // 4. Response
        await SendOkAsync(doc.Id, ct);
    }
}

Section 2: The Query (Read Side)

For the Read side, we bypass the heavy Domain Entities. we don’t need a Document class with behavior methods like Approve() or Publish(). We just need a DTO.

We can use SQL implementations like Dapper or EF Core with AsNoTracking() and .Select().

[MediatR Wiki] — Jimmy Bogard , 2024-07-15
public class GetDocumentEndpoint : EndpointWithoutRequest<DocumentSummaryDto>
{
    private readonly AppDbContext _db;

    public GetDocumentEndpoint(AppDbContext db)
    {
        _db = db;
    }

    public override void Configure()
    {
        Get("/documents/{id}");
        AllowAnonymous();
    }

    public override async Task HandleAsync(CancellationToken ct)
    {
        var id = Route<Guid>("id");

        // Direct Projection to DTO - efficiently queries only needed columns
        var summary = await _db.Documents
            .AsNoTracking()
            .Where(d => d.Id == id)
            .Select(d => new DocumentSummaryDto
            {
                Id = d.Id,
                Title = d.Title,
                // We might flatten related data here
                AuthorName = d.Author.FullName
            })
            .FirstOrDefaultAsync(ct);

        if (summary is null)
        {
            await SendNotFoundAsync(ct);
            return;
        }

        await SendOkAsync(summary, ct);
    }
}
[EF Core Performance Tips] — Microsoft , 2024-08-05

Section 3: Synchronization

In advanced CQRS, the Read Store might be a different technology entirely (e.g., Redis or ElasticSearch).

In that case, you would use an Event Handler (triggered after the Write transaction commits) to update the Read Store.

[Implementing CQRS in .NET] — Kamil Grzybek , 2023-10-20
public class DocumentIndexedHandler : INotificationHandler<DocumentCreatedEvent>
{
    public async Task Handle(DocumentCreatedEvent evt, CancellationToken ct)
    {
        // Update the search index (Read Model)
        await _qdrant.UpsertAsync(evt.DocumentId, evt.Text);
    }
}

Conclusion

CQRS adds complexity, but it solves the impedance mismatch between high-performance reading and complex transactional writing.

By using FastEndpoints, we make this separation explicit in our project structure. Commands live in Features/Documents/Create, and Queries live in Features/Documents/Get.

After running this pattern across multiple services in production, I can say confidently that the biggest win is not performance — it is clarity. When a new developer opens a vertical slice folder and sees a Command, a Validator, and a Handler all in one place, they understand exactly what happens when a request arrives. The read side is equally transparent: a query, a DTO, and a projection, nothing more. That clarity compounds over time as your team grows and your codebase evolves.

Next Steps

  • Explore [Projections] to pre-calculate expensive reads.
  • Learn about [Event Sourcing], the natural evolution of CQRS.

Further Reading

[Microsoft CQRS pattern guide] — Microsoft , 2024 [FastEndpoints documentation] — Dhananjay Kumar , 2024 [MediatR Wiki] — GitHub Community , 2024 [Kamil Grzybek's modular monolith series] — Kamil Grzybek , 2024