Skip to main content

Command Palette

Search for a command to run...

CQRS and Event Sourcing in .NET Without Over Engineering

Updated
6 min read
CQRS and Event Sourcing in .NET Without Over Engineering
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

Most conversations about CQRS (Command Query Responsibility Segregation) and event sourcing in the .NET world quickly spiral into complexity. Developers picture dozens of layers, an ocean of boilerplate, and a graveyard of projects that collapsed under their own weight. The truth is that CQRS and event sourcing do not need to be over engineered. With modern .NET, EF Core, and the right mindset, you can design systems that separate reads from writes, capture meaningful domain events, and scale naturally, without creating a museum piece architecture.

This example will show the essentials of CQRS and event sourcing in .NET, and how to apply these patterns in a straightforward way. By the end, you should be able to design a solution that benefits from clarity and traceability while avoiding unnecessary abstraction.

Understanding CQRS Without the Hype

CQRS is simple, commands mutate state, queries return state. In a traditional CRUD application, the same model might serve both reads and writes, often leading to bloated entities, leaky domain rules, and inefficient queries. By drawing a line between write models (which focus on enforcing business rules) and read models (which focus on shaping data for consumers), you gain clarity. The mistake many people make is turning CQRS into an academic exercise, introducing message buses, dozens of projects, and infrastructure before even validating whether the split is justified. In reality, the majority of .NET systems can benefit from a lightweight CQRS approach where reads are handled with direct database queries and writes are encapsulated in application services that focus purely on behaviour.

Event Sourcing in Context

Event sourcing is often treated hand in hand with CQRS, but it’s not. CQRS can exist without event sourcing, and in many cases it should. Event sourcing is a persistence strategy where every change to application state is captured as an immutable event, rather than simply overwriting state in a database. This makes your system auditable and replayable, but it comes with trade offs around storage, projections, and tooling. The pragmatic approach is to event source where it adds business value. Regulatory compliance, audit trails, financial transactions, and workflow histories are natural candidates. For everything else, a well designed relational schema with soft deletes and history tables may be enough.

Designing a Lightweight CQRS Layer in .NET

Start with a command. Suppose we are working in an insurance underwriting system and need to create a new programme. A command encapsulates intent:

public record CreateProgrammeCommand(
    string Name,
    string ExternalId,
    decimal Limit) : IRequest<Guid>;

We use record for immutability and MediatR’s IRequest interface for dispatch. The command handler enforces rules:

public class CreateProgrammeHandler 
    : IRequestHandler<CreateProgrammeCommand, Guid>
{
    private readonly UwDbContext _db;

    public CreateProgrammeHandler(UwDbContext db)
    {
        _db = db;
    }

    public async Task<Guid> Handle(CreateProgrammeCommand cmd, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(cmd.Name))
            throw new ArgumentException("Programme name is required.");

        var programme = new Programme(cmd.Name, cmd.ExternalId, cmd.Limit);

        _db.Programmes.Add(programme);
        await _db.SaveChangesAsync(ct);

        return programme.Id;
    }
}

Notice the handler validates business rules, instantiates a domain entity, and persists it. No repositories or abstract factories are necessary unless your domain genuinely requires them.

For queries, the story is even simpler. Queries should bypass the domain and go directly to an optimised read model. With EF Core and Dapper this is trivial:

public class ProgrammeQueries
{
    private readonly IDbConnection _db;

    public ProgrammeQueries(IDbConnection db)
    {
        _db = db;
    }

    public async Task<ProgrammeDto?> GetProgrammeAsync(Guid id)
    {
        const string sql = """
            SELECT Id, Name, ExternalId, Limit
            FROM Programmes
            WHERE Id = @id
        """;

        return await _db.QueryFirstOrDefaultAsync<ProgrammeDto>(sql, new { id });
    }
}

public record ProgrammeDto(Guid Id, string Name, string ExternalId, decimal Limit);

Reads return a DTO shaped exactly for consumers. No domain baggage.

Adding Event Sourcing Without Drowning

To improve our architecture with event sourcing, we can capture domain events as part of the write flow. Instead of overwriting state silently, we append events that describe what happened.

First, define a base event:

public abstract record DomainEvent(Guid Id, DateTimeOffset OccurredOn);

public record ProgrammeCreated(
    Guid ProgrammeId,
    string Name,
    string ExternalId,
    decimal Limit,
    DateTimeOffset OccurredOn) 
    : DomainEvent(ProgrammeId, OccurredOn);

The entity raises events as it changes:

public class Programme
{
    private readonly List<DomainEvent> _events = new();

    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public string ExternalId { get; private set; }
    public decimal Limit { get; private set; }

    public IReadOnlyCollection<DomainEvent> Events => _events.AsReadOnly();

    private Programme() { } // EF Core

    public Programme(string name, string externalId, decimal limit)
    {
        Id = Guid.NewGuid();
        Name = name;
        ExternalId = externalId;
        Limit = limit;

        _events.Add(new ProgrammeCreated(Id, Name, ExternalId, Limit, DateTimeOffset.UtcNow));
    }
}

The DbContext can intercept SaveChangesAsync to persist these events:

public class UwDbContext : DbContext
{
    public DbSet<Programme> Programmes => Set<Programme>();
    public DbSet<StoredEvent> Events => Set<StoredEvent>();

    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        var domainEvents = ChangeTracker.Entries<Programme>()
            .SelectMany(e => e.Entity.Events)
            .ToList();

        foreach (var ev in domainEvents)
        {
            Events.Add(StoredEvent.FromDomainEvent(ev));
        }

        return await base.SaveChangesAsync(ct);
    }
}

public class StoredEvent
{
    public Guid Id { get; set; }
    public string Type { get; set; } = null!;
    public string Data { get; set; } = null!;
    public DateTimeOffset OccurredOn { get; set; }

    public static StoredEvent FromDomainEvent(DomainEvent ev)
    {
        return new StoredEvent
        {
            Id = ev.Id,
            Type = ev.GetType().Name,
            Data = JsonSerializer.Serialize(ev),
            OccurredOn = ev.OccurredOn
        };
    }
}

Now every creation, update, or decision in the domain is appended as an immutable record. Over time, you can build projections, integrate with messaging, or replay the event stream.

Projection and Replay

Events on their own are not enough; you need projections that shape them into queryable views. A projection listens to events and updates a read model. This can be synchronous inside the same transaction, or asynchronous using a background processor.

public class ProgrammeProjection
{
    private readonly UwDbContext _db;

    public ProgrammeProjection(UwDbContext db)
    {
        _db = db;
    }

    public async Task ProjectAsync(ProgrammeCreated ev, CancellationToken ct)
    {
        var dto = new ProgrammeDto(ev.ProgrammeId, ev.Name, ev.ExternalId, ev.Limit);
        _db.Set<ProgrammeDto>().Add(dto);

        await _db.SaveChangesAsync(ct);
    }
}

For replay, you would rebuild projections by iterating through stored events and reapplying them. This is where the auditability of event sourcing is useful.

Avoiding Over Engineering

The temptation with CQRS and event sourcing is to design for scale you may never reach. Before introducing Kafka, Cosmos DB event stores, and sagas, validate whether your business case justifies them. In many cases, EF Core plus a table of stored events will provide more than enough capability. Another pitfall is creating too many abstractions, ICommand, ICommandHandler, IEvent, IEventHandler, and IUnitOfWork interfaces everywhere. While interfaces can be valuable, premature generalisation is the enemy of clarity. Start concrete, refactor when duplication emerges. CQRS and event sourcing can be empowering when applied sensibly. They give you clarity of intention, reliable audit trails, and flexible projections. But when they are applied indiscriminately or with too much ceremony, they can suffocate a system. In .NET, you have the advantage of mature libraries, lightweight abstractions, and a strong network of messaging and persistence tools. The pragmatic path is to start small, separate your reads and writes, capture meaningful events, and only introduce additional infrastructure when your business needs it. If you do that, you will get the benefits of these patterns without inheriting the complexity that has given them a mixed reputation.

CQRS and Event Sourcing in .NET Without Over Engineering