DEV Community

IronSoftware
IronSoftware

Posted on

CQRS Pattern in C# - Practical Guide with MediatR

CQRS (Command Query Responsibility Segregation) separates read operations from write operations into distinct models. Commands change state (create invoice, update customer). Queries retrieve state (get invoice list, search customers). By segregating these responsibilities, systems optimize each independently — write models for transactional integrity, read models for query performance.

I've implemented CQRS in document management systems where the pattern solved specific scaling problems. The write side handled document uploads, metadata updates, and version control with full transaction support and validation. The read side served dashboard queries, search results, and reporting from denormalized views optimized for fast retrieval. This separation let us scale reads independently without impacting write performance.

The pattern isn't appropriate for all systems. CQRS adds architectural complexity — separate command and query models, message handling infrastructure, potential eventual consistency between models. For simple CRUD applications where reads and writes follow similar patterns, traditional layered architecture is simpler and sufficient. I only apply CQRS when scaling requirements or domain complexity justify the overhead.

Understanding when CQRS helps versus when it adds unnecessary complexity is critical. The pattern excels in systems with different read and write scaling characteristics, complex business logic requiring extensive validation on writes but simple data retrieval on reads, or reporting requirements benefiting from denormalized read models. It struggles in simple CRUD applications, systems with low traffic where scaling isn't a concern, or teams unfamiliar with message-based architecture who face a steep learning curve.

The implementation typically uses MediatR in .NET, which provides in-process messaging for commands and queries. Commands and queries become message objects handled by specific handler classes. Controllers dispatch messages rather than calling service methods directly. This creates clear separation between web layer concerns (HTTP handling) and business logic (command/query execution).

using MediatR;
// Install via NuGet: Install-Package MediatR

// Command: Generate invoice PDF
public record GenerateInvoiceCommand(int InvoiceId) : IRequest<byte[]>;

// Command Handler
public class GenerateInvoiceHandler : IRequestHandler<GenerateInvoiceCommand, byte[]>
{
    private readonly IInvoiceRepository _repo;
    private readonly [[ChromePdfRenderer](https://ironpdf.com/java/how-to/java-create-pdf-tutorial/)](https://ironpdf.com/nodejs/how-to/nodejs-pdf-to-image/) _pdfRenderer;

    public GenerateInvoiceHandler(IInvoiceRepository repo, ChromePdfRenderer pdfRenderer)
    {
        _repo = repo;
        _pdfRenderer = pdfRenderer;
    }

    public async Task<byte[]> Handle(GenerateInvoiceCommand command, CancellationToken cancellationToken)
    {
        var invoice = await _repo.GetByIdAsync(command.InvoiceId);

        var html = $"<h1>Invoice #{invoice.Number}</h1><p>Total: {invoice.Total:C}</p>";
        var pdf = _pdfRenderer.RenderHtmlAsPdf(html);

        return pdf.BinaryData;
    }
}

// In Controller
public class InvoiceController : ControllerBase
{
    private readonly IMediator _mediator;

    public InvoiceController(IMediator mediator) => _mediator = mediator;

    [HttpPost("invoice/{id}/pdf")]
    public async Task<IActionResult> GeneratePdf(int id)
    {
        var pdfBytes = await _mediator.Send(new GenerateInvoiceCommand(id));
        return File(pdfBytes, "application/pdf", $"invoice-{id}.pdf");
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the fundamental pattern — commands as messages, handlers containing business logic, MediatR dispatching messages to handlers. The controller is thin, delegating actual work to the command handler. For production systems, you'd add validation pipelines, logging pipelines, and transaction handling around the handler execution.

Why Use CQRS Instead of Traditional Layered Architecture?

Traditional layered architecture uses shared models for reads and writes. The same service methods handle creating invoices and querying invoice lists. The same domain models represent data for both operations. This works fine for simple applications but creates issues at scale.

The problems appear when read and write requirements diverge. Writes need full domain models with validation logic, business rules, and transactional consistency. Reads need flattened views with joins across aggregates for efficient querying. Forcing both through the same models creates awkward compromises.

I've maintained traditional layered invoice systems where dashboard queries required loading full invoice aggregates (header, line items, customer, products) only to project three summary fields. The ORM loaded entire object graphs for queries that needed simple tabular data. Performance suffered because the models optimized for write operations didn't match query needs.

CQRS solves this by separating the models. Write models use rich domain objects with behavior and validation. Read models use simple DTOs or database views optimized for specific queries. Each side optimizes independently without compromising the other.

The trade-off is complexity. You maintain two models instead of one. Commands flow through validation and business logic. Queries bypass business logic and read directly from optimized views. If reads and writes stay synchronized (same database), consistency is straightforward. If they're separated (eventual consistency), you handle synchronization complexity.

I apply CQRS selectively, not universally. Document generation systems benefit because generating PDFs is a clear command (write operation with business logic), while listing documents is a query (read-optimized retrieval). Simple lookup tables stay traditional CRUD — no benefit from separating reads and writes when both are trivial.

How Do I Set Up MediatR for CQRS in ASP.NET Core?

MediatR provides the messaging infrastructure for CQRS without external message brokers. Commands and queries are in-process messages dispatched to handlers registered via dependency injection.

Install MediatR:

dotnet add package MediatR
Enter fullscreen mode Exit fullscreen mode

Register MediatR in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

builder.Services.AddSingleton<ChromePdfRenderer>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

The RegisterServicesFromAssembly scans for IRequestHandler implementations and registers them automatically. I register the PDF renderer as singleton (expensive initialization, thread-safe) and repositories as scoped (per-request lifetime for database connections).

Create a command:

public record CreateInvoiceCommand(string CustomerName, decimal Amount) : IRequest<int>;
Enter fullscreen mode Exit fullscreen mode

The command is a record (immutable data object) implementing IRequest<int> where int is the return type (invoice ID in this case). Using records ensures commands are immutable, preventing accidental modification during handling.

Create a handler:

public class CreateInvoiceHandler : IRequestHandler<CreateInvoiceCommand, int>
{
    private readonly IInvoiceRepository _repo;

    public CreateInvoiceHandler(IInvoiceRepository repo) => _repo = repo;

    public async Task<int> Handle(CreateInvoiceCommand command, CancellationToken cancellationToken)
    {
        var invoice = new Invoice
        {
            CustomerName = command.CustomerName,
            Amount = command.Amount,
            CreatedDate = DateTime.UtcNow
        };

        await _repo.AddAsync(invoice);
        return invoice.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler receives the command and executes business logic. It doesn't know about HTTP, controllers, or web concerns. This separation makes handlers testable and reusable — the same handler works from controllers, background jobs, or message queue consumers.

Dispatch commands from controllers:

[HttpPost("invoices")]
public async Task<IActionResult> Create([FromBody] CreateInvoiceCommand command)
{
    var invoiceId = await _mediator.Send(command);
    return CreatedAtAction(nameof(GetById), new { id = invoiceId }, null);
}
Enter fullscreen mode Exit fullscreen mode

The controller receives the command from the request body and dispatches it via MediatR. The mediator finds the registered handler and executes it. This keeps controllers thin — they handle HTTP concerns (routing, status codes) while handlers contain business logic.

How Do I Implement Queries with CQRS?

Queries retrieve data without changing state. They're simpler than commands because they don't need validation pipelines or transaction handling. Queries directly access read-optimized data stores or database views.

Create a query:

public record GetInvoiceListQuery(int PageNumber, int PageSize) : IRequest<List<InvoiceDto>>;

public record InvoiceDto(int Id, string CustomerName, decimal Amount, DateTime CreatedDate);
Enter fullscreen mode Exit fullscreen mode

The query specifies parameters (pagination) and returns a DTO list. DTOs are simple data containers without behavior, optimized for serialization and data transfer.

Create a query handler:

public class GetInvoiceListHandler : IRequestHandler<GetInvoiceListQuery, List<InvoiceDto>>
{
    private readonly IInvoiceRepository _repo;

    public GetInvoiceListHandler(IInvoiceRepository repo) => _repo = repo;

    public async Task<List<InvoiceDto>> Handle(GetInvoiceListQuery query, CancellationToken cancellationToken)
    {
        var invoices = await _repo.GetPagedAsync(query.PageNumber, query.PageSize);

        return invoices.Select(i => new InvoiceDto(
            i.Id,
            i.CustomerName,
            i.Amount,
            i.CreatedDate
        )).ToList();
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler queries the repository and projects results to DTOs. The repository method is read-optimized — it might query database views, use raw SQL for performance, or access denormalized tables. It doesn't load full domain aggregates because queries don't need business logic.

Dispatch queries from controllers:

[HttpGet("invoices")]
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int size = 20)
{
    var query = new GetInvoiceListQuery(page, size);
    var invoices = await _mediator.Send(query);
    return Ok(invoices);
}
Enter fullscreen mode Exit fullscreen mode

This follows the same pattern as commands. The controller creates a query message and dispatches it. The mediator routes it to the handler. The result returns to the controller for HTTP serialization.

How Does CQRS Work with PDF Generation?

PDF generation fits naturally as commands in CQRS systems. Generating a PDF is a write operation — it creates a resource, potentially stores it, and triggers business logic (logging, notifications, archival).

Create a PDF generation command:

public record GeneratePdfReportCommand(int ReportId, ReportFormat Format) : IRequest<string>;

public enum ReportFormat { Summary, Detailed, Executive }
Enter fullscreen mode Exit fullscreen mode

The command specifies which report to generate and the format. The return type is a string (file path or blob storage URL) where the PDF is saved.

Implement the handler:

public class GeneratePdfReportHandler : IRequestHandler<GeneratePdfReportCommand, string>
{
    private readonly IReportRepository _repo;
    private readonly ChromePdfRenderer _renderer;
    private readonly IBlobStorage _storage;

    public GeneratePdfReportHandler(
        IReportRepository repo,
        ChromePdfRenderer renderer,
        IBlobStorage storage)
    {
        _repo = repo;
        _renderer = renderer;
        _storage = storage;
    }

    public async Task<string> Handle(GeneratePdfReportCommand command, CancellationToken cancellationToken)
    {
        var reportData = await _repo.GetReportDataAsync(command.ReportId);

        var template = command.Format switch
        {
            ReportFormat.Summary => "summary-template.html",
            ReportFormat.Detailed => "detailed-template.html",
            ReportFormat.Executive => "executive-template.html",
            _ => throw new ArgumentException("Invalid format")
        };

        var html = await RenderTemplateAsync(template, reportData);
        var pdf = _renderer.RenderHtmlAsPdf(html);

        var fileName = $"report-{command.ReportId}-{DateTime.UtcNow:yyyyMMdd}.pdf";
        var url = await _storage.UploadAsync(fileName, pdf.BinaryData);

        return url;
    }

    private async Task<string> RenderTemplateAsync(string template, object data)
    {
        var templateContent = await File.ReadAllTextAsync($"Templates/{template}");
        // Use Handlebars or Razor for template rendering
        return templateContent; // Simplified
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler fetches report data, selects the appropriate template based on format, renders HTML, generates PDF, uploads to blob storage, and returns the URL. All business logic (template selection, data fetching, storage) lives in the handler, not the controller.

Query for generated reports:

public record GetGeneratedReportsQuery(int ReportId) : IRequest<List<GeneratedReportDto>>;

public class GetGeneratedReportsHandler : IRequestHandler<GetGeneratedReportsQuery, List<GeneratedReportDto>>
{
    private readonly IReportRepository _repo;

    public async Task<List<GeneratedReportDto>> Handle(GetGeneratedReportsQuery query, CancellationToken cancellationToken)
    {
        return await _repo.GetGeneratedReportsAsync(query.ReportId);
    }
}
Enter fullscreen mode Exit fullscreen mode

The query retrieves previously generated PDFs without regenerating them. This read operation accesses a denormalized view of generated reports optimized for list queries.

When Should I NOT Use CQRS?

CQRS adds architectural complexity that's unjustified for many applications. Knowing when to avoid it is as important as knowing when to apply it.

Simple CRUD applications don't benefit from CQRS. If your entities have straightforward create, read, update, delete operations with similar patterns for reads and writes, traditional layered architecture is simpler. Adding CQRS creates overhead without solving actual problems.

Low-traffic systems where performance isn't a bottleneck gain nothing from CQRS's optimization potential. If your application serves 100 users and queries complete in milliseconds, separating read and write models optimizes problems that don't exist.

Teams unfamiliar with message-based architecture face steep learning curves with CQRS. The pattern requires understanding command/query separation, handler registration, message dispatch, and potentially eventual consistency. If the team struggles with these concepts, simpler patterns are more productive.

Domains without complex business logic have minimal validation or workflow requirements that don't justify command handlers. If writes are simple database inserts without business rules, handlers add ceremony without value.

I've seen CQRS applied to settings management screens (key-value pairs with no business logic) and user profile updates (simple field edits). Both added complexity without benefits. The effort of maintaining separate command/query models and handlers exceeded any gains from the pattern.

Apply CQRS when you have clear problems it solves: scaling reads independently from writes, optimizing complex queries with denormalized views, or complex write operations with extensive validation and business logic. Don't apply it preemptively based on architectural trends.

Quick Reference

Concept Implementation Notes
Command record CreateInvoiceCommand(...) : IRequest<int> Changes state, returns result
Query record GetInvoiceQuery(...) : IRequest<InvoiceDto> Retrieves data, no side effects
Handler IRequestHandler<TRequest, TResponse> Processes commands/queries
Register MediatR AddMediatR(cfg => cfg.RegisterServices...) In Program.cs
Dispatch await _mediator.Send(command) From controllers
Validation Pipeline behaviors Cross-cutting concerns

Key Principles:

  • Commands change state, queries retrieve data
  • Separate read and write models when requirements diverge
  • MediatR provides in-process messaging in .NET
  • Handlers contain business logic, controllers handle HTTP concerns
  • Don't apply CQRS to simple CRUD (overhead exceeds benefits)
  • Use when read/write scaling differs or queries need denormalized views
  • PDF generation is a command (creates resource, has business logic)
  • Listing generated PDFs is a query (read-only retrieval)

The complete CQRS pattern guide covers event sourcing integration and advanced MediatR pipeline behaviors.


Written by Jacob Mellor, CTO at Iron Software. Jacob created IronPDF and leads a team of 50+ engineers building .NET document processing libraries.

Top comments (0)