RO EN

CQRS + Event Sourcing, Scaling and Custom Command Bus

CQRS + Event Sourcing, Scaling and Custom Command Bus
Doru Bulubasa
13 October 2025

After a CQRS-based application has been stabilized — with mediation through MediatR, clear repositories, and optimized queries — it’s time to think about the next level: scalable, resilient architectures completely separated between Command and Query. This is the stage where CQRS truly becomes an enterprise architecture, suitable for distributed applications and microservices.


πŸ” 1. CQRS + Event Sourcing – reconstructing state from events

One of the most elegant, yet complex principles in CQRS is Event Sourcing. Instead of storing only the final state of an entity (for example, a blog post), you apply a historical approach: you keep all the events that led to that state.

For example, instead of saving a Post entity with title and content, you save events such as:

  • PostCreatedEvent

  • PostTitleUpdatedEvent

  • PostContentEditedEvent

When reconstructing a domain object, you apply these events in chronological order. Thus, the Aggregate Root becomes the source of truth ("single source of truth").

public class PostAggregate
{
    public Guid Id { get; private set; }
    public string Title { get; private set; }
    public string Content { get; private set; }

    private readonly List<IDomainEvent> _events = new();

    public void Apply(PostCreatedEvent @event)
    {
        Id = @event.PostId;
        Title = @event.Title;
        Content = @event.Content;
    }

    public void Apply(PostTitleUpdatedEvent @event)
    {
        Title = @event.NewTitle;
    }
}

Through this model, state is a projection of events, not a static entity.

The advantages are significant:

  • βœ… Complete and auditable history of actions.

  • βœ… Ability to reconstruct state at any point in time.

  • βœ… Natural compatibility with microservices and distributed messaging.

However, Event Sourcing involves increased complexity: you must manage versioning schemes for events, migrations, and an asynchronous projection strategy for reading.


βš™οΈ 2. Scaling: separate databases for Write and Read

Once the application starts to have high traffic or significant data volume, physical separation between Write DB and Read DB becomes essential.

In logical CQRS (the classic one), the separation is only conceptual – meaning the same data but in different contexts.

In physical CQRS, distinct databases are used:

  • Write DB (e.g., SQL Server, PostgreSQL) for commands, transactions, validations.

  • Read DB (e.g., ElasticSearch, MongoDB, SQLite) for optimized query projections.

Example implementation:

  • PostCommandHandler writes to BlogWriteDbContext.

  • GetPostsQueryHandler reads from BlogReadDbContext, where data is synchronized periodically or via events.

// Command side
public class BlogWriteDbContext : DbContext
{
    public DbSet<Post> Posts { get; set; }
}

// Query side
public class BlogReadDbContext : DbContext
{
    public DbSet<PostReadModel> Posts { get; set; }
}

Benefits of this separation:

  • πŸš€ Independent scaling of databases.

  • ⚑ Better performance on complex queries.

  • πŸ”’ Complete isolation between write and read operations.

This approach is often encountered in large enterprise systems or in event-driven architectures where reading is asynchronous relative to writing.


🧩 3. Custom Command Bus / Query Bus – total isolation

The last component in this advanced stage is the Command Bus or custom Query Bus, which provides complete separation between application layers.

Although MediatR is excellent for most cases, in a distributed architecture you might need more controlled routing of commands.

Minimal example implementation for a custom Command Bus:

public interface ICommand { }

public interface ICommandHandler<TCommand>
    where TCommand : ICommand
{
    Task HandleAsync(TCommand command);
}

public class CommandBus
{
    private readonly IServiceProvider _serviceProvider;

    public CommandBus(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task SendAsync<TCommand>(TCommand command)
        where TCommand : ICommand
    {
        var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand>>();
        await handler.HandleAsync(command);
    }
}

The advantage is clear: you can route commands to other processes, microservices, or even RabbitMQ messages, without affecting domain code.

This becomes a natural step towards DDD + Microservices architectures.


🧾 4. Event Publishing and Audit Logging

Additionally, Domain Events and Integration Events remain the key for communication between modules.

Domain Events are domain-local and can be triggered from aggregate roots:

public record PostCreatedDomainEvent(Guid PostId, string Title) : INotification;

On the other hand, Integration Events are published to other applications (via message broker).

In parallel, Audit Logging can be implemented as a MediatR behavior (Decorator) that saves metadata such as:

  • Type of executed command

  • UserId who initiated the action

  • Execution time and result

Thus, you have a fully transparent and extensible system.


🧠 Conclusion

The advanced stage of CQRS is not just an optimization, but an architectural maturation.

Event Sourcing brings traceability and consistency, separate databases provide real scalability, and the custom Command Bus ensures perfect isolation between layers.

In the long term, these principles transform an ordinary application into a robust, scalable, and distribution-ready system, without compromising clarity and logical separation between Command and Query.