Mert Özen Further With Every Line
Software Architecture CQRS and Event Sourcing — #01

What Is CQRS? From CQS to Command Query Responsibility Segregation

Mert Özen Nov 7, 2026 12 min 5 views
What Is CQRS? From CQS to Command Query Responsibility Segregation

CQRS splits one model into two, one for writing (commands) and one for reading (queries); Greg Young took Meyer's method-level CQS principle up to the model level. We show with a real compiler error and a load count why an aggregate does not fit a list screen, then cover the three levels of CQRS, how it differs from Event Sourcing and MediatR, and Fowler's 'only where it pays off' warning.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

On Bookstore's Ordering side everything lives in one OrderService: the class that places orders also cancels them and fills the customer's "My Orders" screen. In the DDD (Domain-Driven Design) series we built the Order aggregate to protect its invariants; the order lines sit in a private list and only behavior is exposed. Now the team has a new task: a list of the customer's recent orders in pages of 20 rows. Each row shows the date, the status, how many books were bought, the total, and the title of the first book.

using Bookstore.Ordering;

namespace Bookstore.Before;

public interface IOrderRepository
{
    Order? Find(OrderId id);
    IReadOnlyList<Order> FindByCustomer(CustomerId customerId);
    void Add(Order order);
}

public sealed record OrderListItem(Guid OrderId, DateTimeOffset PlacedAt, string Status, int ItemCount, decimal Total, string FirstTitle);

// One service that both writes and reads
public sealed class OrderService(IOrderRepository orders, TimeProvider clock)
{
    public OrderId PlaceOrder(CustomerId customerId, IReadOnlyList<CartLine> lines)
    {
        Order order = Order.Place(customerId, lines, clock);
        orders.Add(order);
        return order.Id;
    }

    public void CancelOrder(OrderId id, CancellationReason reason) =>
        (orders.Find(id) ?? throw new KeyNotFoundException($"Order {id.Value} not found.")).Cancel(reason);

    // "My Orders" screen: load aggregates, page in memory, map to DTOs
    public IReadOnlyList<OrderListItem> GetCustomerOrders(CustomerId customerId, int page, int pageSize) =>
        [.. orders.FindByCustomer(customerId)
            .OrderByDescending(o => o.PlacedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(o => new OrderListItem(
                o.Id.Value, o.PlacedAt, o.Status.ToString(),
                o.Items.Sum(i => i.Quantity),
                o.Items.Sum(i => i.UnitPrice * i.Quantity),
                o.Items[0].Title))];
}

Two questions. Does this code compile, and if not, what is the shortest way to make it compile and what does that shortcut cost? Suppose it compiles: when Ada has 120 orders, how many aggregates and how many order lines are loaded into memory to show the first page?

One Model Pulled in Two Directions

The compiler's answer is clear:

Before.cs(35,19): error CS1061: 'Order' does not contain a definition for 'Items' and no accessible extension method 'Items' accepting a first argument of type 'Order' could be found
Before.cs(36,19): error CS1061: 'Order' does not contain a definition for 'Items' and no accessible extension method 'Items' accepting a first argument of type 'Order' could be found
Before.cs(37,19): error CS1061: 'Order' does not contain a definition for 'Items' and no accessible extension method 'Items' accepting a first argument of type 'Order' could be found

Order does not hand out its lines, because the aggregate's job is not to feed screens; it is to protect rules. In the DDD series post "Aggregate Design: Consistency Boundaries, Small Aggregates, and Referencing by Id" we defined the aggregate as a consistency boundary: rules such as at most 10 copies of the same book per order, or a shipped order cannot be cancelled, live inside it. The shortest way to make the code compile is the one everybody knows, opening a getter:

    // Opened up for the list screen
    public IReadOnlyList<OrderItem> Items => _items;

It compiles. Let's ask for the first page for Ada, who has 120 orders, and count what the repository loads into memory:

using Bookstore.Before;
using Bookstore.Demo;
using Bookstore.Ordering;
using Bookstore.Ordering.Commands;

CountingOrderRepository repository = new();
OrderService service = new(repository, TimeProvider.System);
CustomerId ada = new(Guid.CreateVersion7());
string[] titles = ["Dune", "Emma", "The Hobbit", "Middlemarch"];
Random random = new(42);

// Ada's 120 orders, each with 1-4 lines
for (int i = 0; i < 120; i++)
{
    CartLine[] lines = [.. Enumerable.Range(0, random.Next(1, 5))
        .Select(n => new CartLine(new BookId(Guid.CreateVersion7()), titles[(i + n) % titles.Length], 12.5m, random.Next(1, 3)))];
    service.PlaceOrder(ada, lines);
}

IReadOnlyList<OrderListItem> page = service.GetCustomerOrders(ada, page: 1, pageSize: 20);
Console.WriteLine($"Rows on screen: {page.Count}");
Console.WriteLine($"Aggregates loaded: {repository.AggregatesLoaded}, items loaded: {repository.ItemsLoaded}");
Rows on screen: 20
Aggregates loaded: 120, items loaded: 305

A 20-row screen loaded 120 aggregates and 305 order lines. Against a real database that means fetching every aggregate with its lines (with Include in EF Core), the change tracker watching all those objects, and paging in memory. Performance is not the only problem either. As the screen grows, Order gains a Total, a FirstTitle, maybe a field for a tracking number that belongs to another context; the repository fills up with read methods like FindByCustomer, FindByCustomerPaged, FindAwaitingShipment. Greg Young lists these symptoms in CQRS Documents, which he published in 2010 on CQRS (Command Query Responsibility Segregation): repositories bloated with paging and sorting parameters, getters that expose internal state to build DTOs (Data Transfer Objects), relationships preloaded for read scenarios, and loading several aggregates to build one screen DTO. They share a single root: the same model is pulled into enforcing rules and feeding screens, and those two jobs have very different needs.

CQS: A Rule at the Method Level

The idea starts with CQS (Command-Query Separation) from Bertrand Meyer's (designer of the Eiffel language) 1988 book Object-Oriented Software Construction: a method either changes state (a command) or returns a value (a query), never both. Asking a question should not change the answer. We covered this principle and its exceptions (Stack<T>.Pop, HashSet<T>.Add returning bool, ConcurrentDictionary.GetOrAdd) in the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post that also covers GRASP (General Responsibility Assignment Software Patterns), "Lesser-Known Principles: Separation of Concerns, Least Astonishment, Fail Fast, CQS and GRASP", so I won't repeat it here.

You use the same split every day at the HTTP (Hypertext Transfer Protocol) level. RFC (Request for Comments) 9110 defines GET as a "safe" method: the client does not request a state change on the server. Caches, browser prefetching and crawlers rely on that contract. Updating an order inside an ASP.NET Core MapGet endpoint is the protocol-level version of a CQS violation.

The limit of CQS is that it talks about the methods of a single object. On Order, Cancel() returns void and the Status property changes nothing; from a CQS point of view all is well. Yet the problem above is still there, because it is not a method-level problem but a model-level one.

Greg Young's Step: Two Objects Instead of One

CQRS was discussed for a long time as "CQS at a higher level"; in CQRS Documents Greg Young explains that after much confusion it was recognized as a separate pattern. His definition is surprisingly plain: CQRS is "the creation of two objects where there was previously only one", one holding the commands and the other the queries. In his own example CustomerService is split into CustomerWriteService and CustomerReadService. Udi Dahan's 2009 article "Clarified CQRS" and Martin Fowler's 2011 CQRS bliki entry describe the same idea, crediting Young as the source.

The value of the split comes from the two sides having genuinely different needs. In Young's list, the command side wants to process transactions on consistent data and likes a normalized schema; the query side can tolerate eventual consistency in most systems, likes a denormalized schema with few joins, and in most web systems does by far the larger share of the work. For the read side Young proposes a "Thin Read Layer": a thin layer that bypasses the domain entirely and produces the DTO the screen needs straight from the database. In his view it is not even a problem if that layer is tied to the database vendor or uses stored procedures. On the command side, repositories shrink to little more than loading an aggregate by id.

.NET itself uses the "same data, a separate shape for reading" idea. FrozenDictionary<TKey, TValue> in the System.Collections.Frozen namespace, added in .NET 8, is an immutable collection that is more expensive to create than a regular Dictionary but optimized for lookups. You build the data with a Dictionary and freeze it with ToFrozenDictionary() once it is ready to be read. CQRS makes the same trade at the application level: one model for writing, another for reading.

The Split Version: Command Side and Query Side

In Bookstore the first step is to split OrderService in two. On the write side, commands are records that name the intent; they say "cancel the order" instead of "update the field" and map naturally to what Young calls a task-based UI (user interface). The repository only loads and adds aggregates:

using Bookstore.Ordering;

namespace Bookstore.Ordering.Commands;

// Write side: commands that name the intent
public sealed record PlaceOrder(CustomerId CustomerId, IReadOnlyList<CartLine> Lines);
public sealed record CancelOrder(OrderId OrderId, CancellationReason Reason);

// The repository only loads and adds aggregates; no list queries
public interface IOrderRepository
{
    Order? Find(OrderId id);
    void Add(Order order);
}

public sealed class OrderCommandHandler(IOrderRepository orders, TimeProvider clock)
{
    public OrderId Handle(PlaceOrder command)
    {
        Order order = Order.Place(command.CustomerId, command.Lines, clock);
        orders.Add(order);
        return order.Id;
    }

    public void Handle(CancelOrder command) =>
        (orders.Find(command.OrderId) ?? throw new KeyNotFoundException($"Order {command.OrderId.Value} not found."))
            .Cancel(command.Reason);
}

On the read side Order does not appear at all. The query is a record too, and its result is a DTO in the shape the screen wants:

namespace Bookstore.Ordering.Queries;

// Read side: the shape the screen needs, no domain logic
public sealed record GetCustomerOrders(Guid CustomerId, int Page, int PageSize);

public sealed record CustomerOrderRow(
    Guid OrderId, DateTimeOffset PlacedAt, string Status, int ItemCount, decimal Total, string FirstTitle);

public interface ICustomerOrderQueries
{
    Task<IReadOnlyList<CustomerOrderRow>> HandleAsync(GetCustomerOrders query, CancellationToken ct);
}

Let's run the command side; when we try to cancel the same order twice, the aggregate protects the rule:

// Command side: returns void or the new id, rule violations are DomainException
OrderCommandHandler commands = new(new InMemoryOrderRepository(), TimeProvider.System);
OrderId placed = commands.Handle(new PlaceOrder(ada, [new CartLine(new BookId(Guid.CreateVersion7()), "Dune", 12.5m, 1)]));
commands.Handle(new CancelOrder(placed, CancellationReason.CustomerRequest));
try
{
    commands.Handle(new CancelOrder(placed, CancellationReason.CustomerRequest));
}
catch (DomainException ex)
{
    Console.WriteLine($"{ex.Code}: {ex.Message}");
}
order.cancelled: Order is already cancelled.

Notice a few details. The PlaceOrder handler returns the new order's id; under a strict reading of CQS even that is a violation, but the client needs to know the address of the resource it created, and as long as the sender of the command does not generate the id, this is the practical solution. The command does not return a DTO; "the latest state of the order" is a read concern. ICustomerOrderQueries has no implementation yet, because how you write it depends on which level of CQRS you choose. FindByCustomer is gone from the repository as well; read needs are no longer the write model's business.

The Three Levels of CQRS

The CQRS pattern page on Microsoft Learn describes two approaches: separate models on the same data store, and separate data stores. In practice there is one more step in between, which gives three levels:

01-cqrs-levels-en.png

At level 1 the database and the tables are the same; what is separated is the code path. The command side loads the aggregate through EF Core's change tracker and saves it, the query side projects from the same tables with AsNoTracking or runs raw SQL (Structured Query Language) with Dapper. Consistency is immediate and there is no extra infrastructure. This is the cheapest form of CQRS and, for most teams, the most useful one; it is the subject of the next post in the series.

At level 2 there is a separate model, a separate shape, for reading, but still in the same database: a SQL view, a materialized view, or a denormalized table updated in the same transaction as the command. Expensive joins move from read time to write time, and consistency is still immediate.

At level 3 the read model lives in a separate store: a document database, a search index, or a separate PostgreSQL. The write side publishes an event for every change and a projector reads those events to update the read store. You can scale the read side independently and choose a different technology for it; in return you get eventual consistency, an outbox so events are not lost, a projector that does not break when the same event arrives twice, and a new operational load. Microsoft Learn also recommends the Transactional Outbox and idempotent consumers for separate stores. Levels 2 and 3 are covered in the third post of the series.

Each level builds on the previous one and each one pays a price. The reason to move up a level should be a measured need, not curiosity about technology: a slow query, locked tables, a read load that has to scale on its own.

What CQRS Is Not

Greg Young wrote his 2010 post "CQRS, Task Based UIs, Event Sourcing agh!" precisely because of this confusion. In his view CQRS is not an architecture but a small pattern that opens up architectural options. It is not eventual consistency, it is not eventing or messaging, and it is not event sourcing. Those are separate decisions that often show up next to CQRS.

Event Sourcing means storing what happened instead of state, and it is our topic from the fourth post of this series onward. The two are often mentioned together because events are a natural source for feeding read models; but CQRS without event sourcing, and (rarely in practice) event sourcing without CQRS, are both possible.

MediatR is not CQRS either. MediatR is a mediator implementation: it delivers a request object to its handler and makes no distinction between commands and queries; both are IRequest<T>. The code above implements CQRS without any library. We discussed the options with and without a mediator in the Vertical Slice Architecture series post "Slices with a Mediator: Comparing MediatR, Source-Generated Mediator, and Wolverine", and whether you really need a mediator in the Design Patterns series post "Mediator Pattern: Centralizing Object Conversations and Do I Really Need MediatR?".

Two databases are not required either. "We are setting up CQRS" is often heard as "we are adding a second database and a message broker"; yet level 1 is full CQRS on a single database with zero new infrastructure.

Common Pitfalls

Applying CQRS to the whole system

This is the clearest warning in Fowler's bliki entry: apply CQRS not to a system as a whole but to specific parts of it, inside a boundary such as a bounded context in DDD. In Bookstore, Ordering, with rich write rules and varied read screens, is a good candidate; for the Catalog admin screens that manage book and author records, CRUD (Create, Read, Update, Delete) is enough.

Returning a read model from a command

Having the handler return an OrderDetailsDto after PlaceOrder to refresh the screen looks harmless. After a while command handlers start taking the shape of the screens' needs and the read side's responsibility leaks back into the write side. Let the command return an id, a version or a result code; let the screen read its data from a query.

Recomputing business rules on the read side

Recomputing the total on the list screen as "list price times quantity" is the quickest way to miss a campaign discount. The rule runs once on the write side and its result (UnitPrice and the total) is stored; the read side only shows what was stored.

Starting at level 3

Setting up a separate read store, a broker and a projector before you have a single slow query means buying all the UX (user experience) and operational problems of eventual consistency and getting nothing back. Start with level 1, and let a problem you have measured move you to level 2.

When Not to Use It

In 2011 Fowler wrote that CQRS should be approached very cautiously, that most of the cases he had come across had not gone well, and that a CRUD model fits the large majority of systems better; he considered CQRS suitable for a small minority of complex domains and high-performance systems where read and write loads differ greatly. Microsoft Learn likewise says the pattern may not be suitable when the domain and business rules are simple and a CRUD-style interface and data access are enough. For Bookstore specifically: template management in the Notification context, Catalog's category list, and the Payments adapter that merely forwards requests to the payment provider gain nothing from the split. With a team of three, the maintenance cost of keeping two models in sync belongs in the calculation too. Even level 1 is an unnecessary second class where the read and write screens are identical.

A Small Experiment

★☆☆ Add an "orders awaiting shipment" screen to the old OrderService. Which method is the repository forced to gain, and which getter opens up on Order? Design the same screen in the split version as a new query interface like ICustomerOrderQueries, and compare how many files change in each design.

★★☆ Make the PlaceOrder handler return nothing instead of the id: let the client that creates the command generate the id (with Guid.CreateVersion7()). How does that make it easier to recognize the same command sent twice, and what new risk does it bring?

★★★ Classify Bookstore's six contexts (Catalog, Ordering, Inventory, Payments, Shipping, Notification) as "no CQRS, level 1, level 2, level 3". For each, write a one-sentence justification using the numbers from the Introduction to Architecture series (~800 orders a day, ~120 orders a minute during campaigns, ~200 search requests a second).

In the next post we will build level 1 on a real PostgreSQL database: the PlaceOrder command with an aggregate and SaveChanges, the "My Orders" screen with an EF Core projection, the daily sales screen with Dapper and raw SQL; and we will guarantee that the query side does not change state not just through code discipline but by having the database itself enforce it.