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.
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.
What We’ll Build
We will implement a vertical slice architecture using:
- FastEndpoints: A high-performance alternative to Controllers for defining API endpoints.
- MediatR (Optional): For dispatching internal application logic, though FastEndpoints can handle dispatching natively too. We’ll show the pattern of distinct Request/Response pairs.
- 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-01Here 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().
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-20public 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.