When Are CQRS and Event Sourcing Unnecessary? Anti-Patterns and a Decision Guide
CQRS and event sourcing are unnecessary for plain CRUD, MVPs, static reference data, real-time consistent views, and inexperienced teams; the decision is made separately for each bounded context. Bookstore's context map, a decision flow for CQRS levels 1/2/3 and event sourcing, and four anti-patterns: CQRS ceremony on a single database, event sourcing everywhere, state-change events, and using internal events as an integration contract.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
A developer who just joined has made the Catalog screen that changes a book's description "CQRS (Command Query Responsibility Segregation) compliant". The pull request contains these files:
// A "CQRS" Catalog: a command, query, handler, DTO, and dispatcher for every CRUD operation
public interface ICommand;
public interface IQuery<TResult>;
public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command);
}
public interface IQueryHandler<in TQuery, TResult> where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(TQuery query);
}
public sealed record UpdateBookDescription(Guid BookId, string Description) : ICommand;
public sealed record GetBookById(Guid BookId) : IQuery<BookDto>;
public sealed record BookDto(Guid Id, string Title, string Description);
public sealed class UpdateBookDescriptionHandler(Dictionary<Guid, BookDto> books) : ICommandHandler<UpdateBookDescription>
{
public Task HandleAsync(UpdateBookDescription command)
{
books[command.BookId] = books[command.BookId] with { Description = command.Description };
return Task.CompletedTask;
}
}
public sealed class GetBookByIdHandler(Dictionary<Guid, BookDto> books) : IQueryHandler<GetBookById, BookDto>
{
public Task<BookDto> HandleAsync(GetBookById query) => Task.FromResult(books[query.BookId]);
}
public sealed class Dispatcher(IServiceProvider services)
{
public Task SendAsync<TCommand>(TCommand command) where TCommand : ICommand =>
((ICommandHandler<TCommand>)services.GetService(typeof(ICommandHandler<TCommand>))!).HandleAsync(command);
public Task<TResult> QueryAsync<TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult> =>
((IQueryHandler<TQuery, TResult>)services.GetService(typeof(IQueryHandler<TQuery, TResult>))!).HandleAsync(query);
}
The old version of the same job:
// Same job, same table: a single service
public sealed record Book(Guid Id, string Title, string Description);
public sealed class BookCatalog(Dictionary<Guid, Book> books)
{
public void UpdateDescription(Guid bookId, string description) =>
books[bookId] = books[bookId] with { Description = description };
public Book Get(Guid bookId) => books[bookId];
}
I ran both and printed the result and the number of types:
Dictionary<Guid, BookDto> dtos = new() { [dune] = new BookDto(dune, "Dune", "") };
ServiceProvider services = new ServiceCollection()
.AddSingleton(dtos)
.AddScoped<ICommandHandler<UpdateBookDescription>, UpdateBookDescriptionHandler>()
.AddScoped<IQueryHandler<GetBookById, BookDto>, GetBookByIdHandler>()
.BuildServiceProvider();
Dispatcher dispatcher = new(services);
await dispatcher.SendAsync(new UpdateBookDescription(dune, "Desert planet, spice, politics."));
BookDto viaCeremony = await dispatcher.QueryAsync<GetBookById, BookDto>(new GetBookById(dune));
Dictionary<Guid, Book> books = new() { [dune] = new Book(dune, "Dune", "") };
BookCatalog catalog = new(books);
catalog.UpdateDescription(dune, "Desert planet, spice, politics.");
Book direct = catalog.Get(dune);
Would you approve this pull request? What problem does the new code solve? Were reads and writes really separated? Before answering, look at the output:
Ceremony: Dune - Desert planet, spice, politics.
Direct: Dune - Desert planet, spice, politics.
Ceremony types: 10
Direct types: 2
The behavior is identical. Two types became ten, but reads and writes still look at the same table in the same shape: BookDto is a renamed copy of Book. This is not CQRS; these are CQRS's words. The last post of the series is about telling that difference: when CQRS and event sourcing are unnecessary, which signal justifies which level, and what the four most common anti-patterns look like.
The Question First: Which Problem Are We Solving?
In post 01 we defined CQRS at three levels: a separate code path in the same database (level 1), a separate read model in the same database (level 2), and a separate read store (level 3). From post 04 on, we treated event sourcing as a separate decision: storing what happened instead of state. These four options are not a ladder but answers to independent questions. Each relieves a particular pain, and each has a price:
| Choice | Pain it relieves | Price you pay |
|---|---|---|
| CQRS level 1 | Loading aggregates for list screens, N+1, needless change tracking | Almost none: two separate classes |
| CQRS level 2 | Expensive joins and calculations at read time | Managing the read model's schema and its updates |
| CQRS level 3 | Scaling the read load separately, needing a different technology | Eventual consistency, outbox, idempotent projector, a second piece of infrastructure |
| Event sourcing | Losing history and intent, not being able to answer "why" | Versioning, projections, GDPR (General Data Protection Regulation), team experience, harder querying |
The warning in Martin Fowler's 2011 CQRS article still holds: most systems fit a CRUD (Create, Read, Update, Delete) model better, and outside the rare cases where it helps, the complexity CQRS adds increases risk. Fowler's advice is the same: apply CQRS not to the whole system but to the bounded contexts that need it. The Event Sourcing pattern page on Microsoft Learn (updated March 2026) is even more direct: for most systems and most parts of a system, traditional data management is enough.
When They Are Unnecessary
Applying Microsoft Learn's "this pattern might not be suitable when" list and Fowler's warnings to Bookstore gives five signals.
Plain CRUD
The author card in Catalog, the category tree, the campaign calendar: the shape you read is the shape you write, and there are almost no business rules. The opening code is exactly this example. Here even CQRS level 1 is usually unnecessary; Entity Framework Core's (EF Core) AsNoTracking and a Select projection are enough (the "EF Core Performance Basics: N+1, Projection, AsNoTracking and Compiled Queries" post of the EF Core series).
MVPs and short-lived systems
A landing page that will ship in two weeks for a campaign, or a prototype testing product-market fit. The system retires before the investment in event design, a versioning strategy, and projection infrastructure pays back. Learn first, model later.
Static reference data
The list of countries, shipping zones, VAT (value-added tax) rates. They rarely change and their change history means nothing to the business; if it does, a ValidFrom column is enough. Event sourcing finds no story to store here.
A requirement for real-time, consistent views
A requirement that says "the moment the user saves, every screen must show the new value, and even one second in between is unacceptable" rules out level 3 and Async projections from the start. Levels 1 and 2 stay current in the same transaction; in event sourcing that is why the Inline projection from post 07 exists. But a separate read store and immediate consistency cannot be bought together.
An inexperienced team
Event sourcing changes testing, debugging, and operating habits: the silent rename bug from post 08, the read model left empty in post 07, the Turkish culture alias in post 06. A team that learns these for the first time in production pays the price before seeing the gain. Starting selectively (one context, one aggregate) and growing the team's experience from there is safer than spreading it across the whole system at once.
Selective Adoption: Bookstore's Map
Throughout this series we did not put CQRS or event sourcing everywhere in Bookstore. As Microsoft Learn also recommends, the decision was made piece by piece, not for the whole system:
| Bounded context | Choice | Why |
|---|---|---|
| Catalog | CRUD, level 1 for lists and search | Few rules; if search load grows, a separate search index is a topic for the upcoming System Design series |
| Ordering | CQRS levels 1 and 2 (02–03) | The customer's "My orders" screen and sales reports do not fit the aggregate |
| Inventory | Event sourcing (04–09) | Count discrepancies, lost copies, and heavy reservations during campaigns: history and concurrency are first-class for the business |
| Payments | CRUD and an Anti-Corruption Layer | The real ledger is at the payment provider; a generic subdomain |
| Shipping | CRUD and an Anti-Corruption Layer | Status comes from the carrier; few decisions are ours |
| Notification | None | Send and forget; the record is in the log |
This table is today's decision. If the signal changes, so does the decision: if the history of price changes in Catalog one day becomes a subject of audit, an audit table comes first, and event sourcing is discussed only if it is really needed. Writing down the reasoning behind decisions like these in an ADR (Architecture Decision Record) is the subject of the upcoming Architecture Decisions series.
The Decision Flow
Putting the signals above into a flow looks like this. Ask it separately for each bounded context:

The first question eliminates the most: if the read screens do not really need a different shape than the write model, you need no level of CQRS. The second question makes measuring mandatory before moving up a level: if a well-written query on the same tables (and perhaps an index) is enough, adding a separate read model only adds maintenance. The reason to move to level 3 should not be curiosity about a technology but a measured read load that must scale separately, or a query that really needs another engine (full-text search, a document store). The event sourcing question is independent of CQRS: Inventory is event-sourced, but its read models live in the same PostgreSQL.
Common Pitfalls: Four Anti-Patterns
Setting up a "CQRS framework" and staying on one database
The opening code. Command, query, handler, dispatcher, and DTO (Data Transfer Object) layers have been set up, but the read side uses the same model, the same table, and the same shape as the write side. What you get is the cost of CQRS without its gain. Using a mediator is not wrong in itself; in the "Slices with a Mediator: Comparing MediatR, Source-Generated Mediator, and Wolverine" post of the Vertical Slice series we discussed when pipeline behaviors add value. The problem is mistaking a file structure for an architectural decision. A simple test: if the read model has no field, shape, or source that differs from the write model, there are not two models.
Event sourcing everywhere
Spreading event sourcing across the whole system because it worked in one context is the most common and most expensive mistake. An event store for the author card, a projection for the category tree, an upcaster for the country list... Each pays all the costs from 06–09 again, with no story to store. Having a single event-sourced context on Bookstore's map is not a gap but a decision.
State-change events: StockItemUpdated
The way to keep event sourcing's vocabulary and lose its spirit is to write events as "here is the latest state":
// Anti-pattern: an event that says "what the state is now" instead of "what happened"
public sealed record StockItemUpdated(StockItemId StockItemId, int OnHand, int Reserved, DateTimeOffset At);
I recorded the same day both ways and asked the warehouse manager's question: how many copies were shipped in September, and how many turned out damaged?
// One day in September: 10 received, 2 shipped to Ada, 1 damaged, 1 extra found in a count
StockEvent[] businessEvents =
[
new StockReceived(dune, 10, sep),
new StockReserved(dune, ada, 2, sep.AddHours(1)),
new ReservedStockShipped(dune, ada, 2, sep.AddHours(2)),
new StockAdjusted(dune, -1, AdjustmentReason.Damaged, sep.AddHours(3)),
new StockAdjusted(dune, 1, AdjustmentReason.CountCorrection, sep.AddHours(4))
];
// The same day, with state-change events
StockItemUpdated[] stateEvents =
[
new(dune, 10, 0, sep), new(dune, 10, 2, sep.AddHours(1)), new(dune, 8, 0, sep.AddHours(2)),
new(dune, 7, 0, sep.AddHours(3)), new(dune, 8, 0, sep.AddHours(4))
];
int damaged = businessEvents.OfType<StockAdjusted>().Where(a => a.Reason == AdjustmentReason.Damaged).Sum(a => -a.Delta);
int shipped = businessEvents.OfType<ReservedStockShipped>().Sum(s => s.Quantity);
Console.WriteLine($"Business events: shipped={shipped}, damaged={damaged}");
IEnumerable<string> deltas = stateEvents.Zip(stateEvents.Skip(1), (a, b) => $"{b.OnHand - a.OnHand:+0;-0;0}");
Console.WriteLine($"State events: OnHand changes {string.Join(" ", deltas)}, reason unknown");
Business events: shipped=2, damaged=1
State events: OnHand changes 0 -2 -1 +1, reason unknown
With state events you only know how the number changed; you cannot tell whether the −1 was damage, a loss, or a wrong shipment. The Event Sourcing page on Microsoft Learn makes exactly this distinction: an event that says "two seats were reserved" is more valuable than one that says "remaining seats changed to 42". Names like OrderUpdated, StockItemUpdated, and CustomerChanged turn the event store into a change log with no business meaning; at that point you are where an audit table would leave you, but you are paying the full price of event sourcing.
Exposing events as an integration contract
Internal events feed an aggregate's decisions and evolve; their shape changes along with that aggregate's internal model (like OutOfStock becoming ReservationRejected in post 08). If you publish these events as they are to another context or service, every internal refactoring turns into a broken external contract. The right way is to expose a separate, small, versioned contract and translate the internal event into it:
// The contract exposed to the outside: small, versioned, independent of the internal model
public sealed record StockAvailabilityChanged(Guid BookId, int Available, DateTimeOffset At);
public static class StockContractTranslator
{
// Internal event + current state -> public event; not every internal event goes out
public static StockAvailabilityChanged? Translate(StockEvent internalEvent, StockState stateAfter) => internalEvent switch
{
StockReceived or StockReserved or ReservationReleased or StockAdjusted =>
new StockAvailabilityChanged(stateAfter.BookId.Value, stateAfter.Available, internalEvent.At),
_ => null
};
}
StockEvent[] history =
[
new StockItemRegistered(dune, duneBook, at),
new StockReceived(dune, 5, at),
new StockReserved(dune, new OrderId(Guid.CreateVersion7()), 2, at.AddMinutes(5))
];
StockState state = StockDecider.InitialState;
foreach (StockEvent e in history)
{
state = StockDecider.Evolve(state, e);
StockAvailabilityChanged? published = StockContractTranslator.Translate(e, state);
Console.WriteLine($"{e.GetType().Name,-20} -> {(published is null ? "(not published)" : JsonSerializer.Serialize(published))}");
}
// For comparison: if the internal event were published as is
Console.WriteLine($"Internal StockReserved: {JsonSerializer.Serialize<object>(history[^1])}");
StockItemRegistered -> (not published)
StockReceived -> {"BookId":"01a1b859-4b5d-7dbc-8edf-92e38d04abd6","Available":5,"At":"2026-09-01T09:00:00+00:00"}
StockReserved -> {"BookId":"01a1b859-4b5d-7dbc-8edf-92e38d04abd6","Available":3,"At":"2026-09-01T09:05:00+00:00"}
Internal StockReserved: {"OrderId":{"Value":"01a1b859-4b5d-7b2e-b5be-081e6b0cd498"},"Quantity":2,"StockItemId":{"Value":"01a1b859-4b5d-731f-869e-c7b20bea9bc5"},"At":"2026-09-01T09:05:00+00:00"}
All that Catalog's "in stock" badge needs is how many copies of the book are available. The internal event carries the order id, the stock item id, and the reservation details; the day it is exposed, all of them become another team's dependency. Microsoft Learn also says that event sourcing events are typically low level and that separate integration events may be needed. It is no accident that StockAvailabilityChanged sits in a V1 namespace; it is the Published Language idea from the Domain-Driven Design (DDD) series. Carrying integration events reliably (outbox, idempotent consumers) is the subject of the Messaging and Event-Driven Architecture series.
After the Series: Where to Go Next
Over ten posts we saw two separate ideas and the relationship between them. CQRS was about separating the models where reads and writes need different ones, and it had three levels. Event sourcing was about storing what happened instead of state: a pure decision function with the decider, an event store on PostgreSQL with Marten, read models shaped by questions with projections, a history that changes over time with versioning and snapshots, and problems that do not fit in a single stream with crypto-shredding and DCB (Dynamic Consistency Boundary). The reading order I suggest: first Greg Young's CQRS Documents from 2010 and Martin Fowler's CQRS bliki article (short, and with its warnings), then the CQRS and Event Sourcing pattern pages on Microsoft Learn (especially the "might not be suitable" lists), Jérémie Chassaing's 2021 Functional Event Sourcing Decider post, Greg Young's book Versioning in an Event Sourced System, and finally dcb.events. Marten's documentation should be read together with its release notes; most of the defaults we saw in this series changed with Marten 9.
A Small Experiment
★☆☆ Find three "queries" in your own project that use the same type for reading and writing. For each, answer the first two questions of the decision flow: which one really needs a separate model?
★★☆ Think of a system that publishes an OrderUpdated event (or find one in your own system). Split that event into three or four events that carry business intent, and for each write a read model question as in post 07: which question can you now answer?
★★★ Debate the Catalog row of Bookstore's map: if the history of price changes had to become auditable because of a legal regulation, which box in the decision flow would you end up in? Compare event sourcing with a "price history table" in a draft ADR (Architecture Decision Record).
We have reached the end of the CQRS and Event Sourcing series. We started with a single model struggling with both reads and writes, saw CQRS's levels and event sourcing's decider, event store, projections, versioning, and hard parts, and in this post weighed all of it against when not to use it. Inventory's events are now written safely; the next step is carrying them and the integration events between contexts without losing any. That journey starts with the outbox in the upcoming Messaging and Event-Driven Architecture series.