Skip to content
Slicekit
All posts
· Slicekit Team

CQRS is not your mediator, and the outbox is not magic

Two misconceptions trip up most CQRS posts: that routing commands through a mediator is CQRS, and that an outbox gives exactly-once delivery. Here is what each pattern actually is, and what Slicekit relies on.

Most CQRS posts are wrong about what CQRS is. They open a CreateOrderCommand, hand it to a mediator, and announce that the system “does CQRS now.” It does not. Routing a request through a dispatcher is plumbing. CQRS is a claim about your models, and the two ideas keep getting welded together until the acronym means nothing. The same fate has befallen the transactional outbox, which gets sold as a box you switch on to make messaging reliable, as if reliability were a setting rather than a set of guarantees you have to understand.

Slicekit uses both patterns, so it is worth being precise about what they are, where they apply, and what they cost, before correcting the two misconceptions that do the most damage.

React SPA

TanStack · typed client

.NET 10 API

vertical slices · CQRS

PostgreSQL

EF Core

RabbitMQ

outbox · events

Grafana

traces · logs · metrics

One typed client between the apps; the API owns persistence, messaging and telemetry.

What CQRS actually is (and when not to use it)

Command Query Responsibility Segregation, coined by Greg Young on top of Bertrand Meyer’s older Command Query Separation, is one idea: separate the model you write through from the model you read through. Martin Fowler puts it plainly, that “you can use a different model to update information than the model you use to read information” (Fowler). That is the whole pattern. The write model exists to enforce invariants; the read model exists to shape data for a screen and has no rules to protect.

Notice what is not in that definition. CQRS does not require two databases. The read and write models can sit on the same tables; the separation is in the code, not the storage. And CQRS is not a synonym for “send commands and queries through a mediator.” Having SendInvoiceHandler and GetInvoiceHandler classes dispatched by convention is useful dispatch plumbing, but on its own it is just message routing. You can route every request through a mediator and still read and write through one shared model, which is not CQRS at all. The myth that the two are the same is common enough that it has its own debunking (event-driven.io).

The judgment call matters more than the label. Fowler’s caution is blunt: CQRS belongs to specific bounded contexts, never a whole system, and “for most systems CQRS adds risky complexity” (Fowler). Default to CRUD. Reach for a genuinely separate read model only where the read and write shapes have diverged enough to earn it. Slicekit leans on Wolverine for the dispatch (see why we picked it over MediatR) and keeps the command/query split as a discipline, but it does not pretend every slice needs a bespoke read model. Most do not.

The dual-write problem and the outbox

The second pattern solves a narrower, sharper problem. A handler changes state in Postgres and then publishes a message to RabbitMQ. Those are two writes to two systems, and the process can crash in the gap between them. Publish then commit, and a consumer may react to a change that never became durable. Commit then publish, and the change is real but the message never goes out. The fault is not the ordering. It is that there are two writes at all. This is the dual-write problem (Richardson).

The transactional outbox removes the second write. Instead of sending to the broker inside the handler, you persist the outgoing message into an outbox table in the same local transaction as the business change. State row and message row commit together or not at all. A separate relay reads the outbox afterward and forwards the message to the broker. The message is published if and only if the transaction committed (Richardson).

Aggregate raises

records a fact in the domain

One transaction

state + outbox row commit together

Relay forwards

resumes after a crash

RabbitMQ

integration events

Handlers react

at least once

Publishing and persistence never drift apart: if the process dies mid-flight, the relay picks up where it left off: no lost messages, and consumers are idempotent so a redelivery is harmless.

Wolverine ships this as a built-in durable outbox (and inbox), persisting outgoing messages in the same transaction as your change, with durable backings on PostgreSQL, SQL Server, or RavenDB via Marten or EF Core (Wolverine). Slicekit wires the EF Core and Postgres variant: the domain events an aggregate raises are published through this durable path when the change commits, and the same outbox is wired to forward anything you publish to the slicekit.integration exchange for other services. (The template ships the integration-event wiring as an extension point rather than shipped integration events of its own, so out of the box it is your domain events that ride the durable path.) No distributed transaction, no two-phase commit across the database and the broker.

The at-least-once reality, and idempotent consumers

Here is the misconception that causes real outages. The outbox does not give you exactly-once delivery. It gives at-least-once. The relay can publish the same message more than once, for instance when it forwards a message, crashes before recording that it did, and forwards it again on restart (Richardson). The outbox guarantees the message is not lost; it makes no promise that it arrives exactly once.

So the effective-once behavior people actually want does not come from the outbox. It comes from idempotent consumers. Applying the same event twice has to land in the same place as applying it once: a redelivery is a no-op, not a second charge or a duplicate welcome email. This is why a consumer that does anything externally visible should be written to be idempotent rather than assuming the broker will deduplicate for them. The honest formula is at-least-once delivery plus idempotent handlers. Treat the outbox as exactly-once and you will eventually charge a customer twice and have the logs swear it only happened once.

What it costs

None of this is free, and pretending otherwise is its own kind of dishonesty. The durable outbox adds an extra insert per outgoing message, because the message now lives in a table before it lives on the wire. It adds a relay step that polls and forwards, which is more moving parts to run and observe. You are trading a little write latency and some database work for a guarantee that your state and your events can never disagree. Whether that trade is worth it depends on the slice. For a fire-and-forget notification nobody will miss, it may not be. For an integration event another service bills against, it almost certainly is.

That is the through-line for both patterns. CQRS is a model separation you apply to the contexts that have earned it, not a mediator you bolt onto everything. The outbox is an at-least-once durability guarantee you pair with idempotent consumers, not a magic exactly-once switch. Used with that judgment, they are exactly the right tools. Used as cargo cult, they add risk and buy nothing.

For how commands, queries, and events actually flow through Wolverine in Slicekit, including the raise vs publish distinction and the wiring behind the outbox, read the CQRS and domain events guide.