CQRS is not an "on/off" switch. It's a scale: you can take the simplest option and gradually add complexity as the system grows. Starting at the maximum is expensive and pointless.
Tier 1 — just FastAPI, no CQRS
A small service: a few routers, a service layer, one AsyncSession for everything. Reads and writes go through one class, with no special markers.
# routers/orders.py
@router.post("/orders", response_model=OrderResponse)
async def create_order(
payload: CreateOrderRequest,
session: AsyncSession = Depends(get_session),
) -> OrderResponse:
return await OrderService(session).create(payload)
@router.get("/orders/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: UUID,
session: AsyncSession = Depends(get_session),
) -> OrderResponse:
return await OrderService(session).get(order_id)
This is fine for internal utilities and simple CRUD services. Adding Command/Query markers here without real separation is extra complexity with no benefit.
Tier 2 — lightweight CQRS with markers
The service has grown: real business logic has appeared, commands and queries are starting to diverge. You introduce the Use Case Pattern — separate command classes and query classes with explicit markers.
# core/order/usecase/create_order.py
@dataclass(frozen=True)
class CreateOrder: # this is a command — it changes state
customer_id: CustomerId
items: tuple[OrderItemDto, ...]
class CreateOrderHandler:
def __init__(self, orders: OrderRepository, uow: UnitOfWork) -> None:
self._orders = orders
self._uow = uow
async def handle(self, cmd: CreateOrder) -> OrderId:
async with self._uow:
order = Order.place(cmd.customer_id, cmd.items)
await self._orders.save(order)
await self._uow.commit()
return order.id
# core/order/usecase/get_order.py
@dataclass(frozen=True)
class GetOrder: # this is a query — it only reads
order_id: OrderId
class GetOrderHandler:
def __init__(self, orders: OrderRepository) -> None:
self._orders = orders
async def handle(self, query: GetOrder) -> OrderJson:
order = await self._orders.by_id_readonly(query.order_id)
return OrderMapper.to_json(order)
The main difference from Tier 1 isn't just the words "command/query", but a real separation in behavior:
- The read handler works with a read-only session: no
commit, noFOR UPDATE. - The write handler works through a Unit of Work with an explicit
commit. - Different validation paths: commands check business rules, queries check pagination parameters and filters.
The repository, meanwhile, is a single one — OrderRepository. Splitting the interfaces at this tier is premature.
Tier 3 split — a separate repository for reading
When the read side starts living its own life: the UI wants projections, analytics, summaries — everything that doesn't match the shape of the aggregate. Then a second protocol appears — OrderViewRepository.
# core/order/port/out/order_repository.py
class OrderRepository(Protocol):
async def by_id(self, order_id: OrderId) -> Order: ... # FOR UPDATE
async def save(self, order: Order) -> None: ...
# core/order/port/out/order_view_repository.py
class OrderViewRepository(Protocol):
async def find_by_id(self, order_id: OrderId) -> OrderSummary | None: ...
async def search(
self,
customer_id: CustomerId,
status: OrderStatus | None,
page: int,
size: int,
) -> Page[OrderSummary]: ...
# core/order/port/view/order_summary.py
@dataclass(frozen=True)
class OrderSummary:
order_id: OrderId
customer_name: str
total_amount: Decimal
status: OrderStatus
created_at: datetime
What changes:
- The write side (
OrderRepository) returns the aggregate and locks the row for changes. - The read side (
OrderViewRepository) returns a lightweight read-DTO tailored to a specific screen or API. - The query handler goes only to
OrderViewRepository— turning toOrderRepositoryfor read data has become wrong.
Physically it's still one PostgreSQL. The separation is so far only at the code level, not at the infrastructure level. More about the read side — in the article Query side.
Tier 3 event-driven — a separate store for the read-model
The most complex option. It's needed when the read load starts getting in the way of the write side, or when the projections require a fundamentally different data structure — for example, full-text search or analytical summaries.
write-side: read-side:
PostgreSQL order_summary (a separate table)
├── order (aggregate) ├── denormalized schema
└── outbox └── indexes for specific queries
↓
outbox-relay (asyncio task)
↓
Kafka (order.events)
↓
read-side consumer (aiokafka)
↓
UPSERT order_summary
The write side writes into the aggregate table and puts an event into the outbox. The relay task reads the outbox and sends to Kafka. The consumer receives the event and updates the read table:
# adapter/messaging/order_summary_consumer.py
async def handle_order_confirmed(event: OrderConfirmed, session: AsyncSession) -> None:
await session.execute(
insert(OrderSummaryRow)
.values(order_id=event.order_id, status="CONFIRMED", version=event.aggregate_version, ...)
.on_conflict_do_update(
index_elements=["order_id"],
set_={"status": "CONFIRMED", "version": event.aggregate_version},
where=OrderSummaryRow.version < event.aggregate_version,
)
)
await session.commit()
on_conflict_do_update with a version check is protection against applying a stale event on top of a fresher one.
What is added to the previous tier:
- An outbox table in the write database.
- An asyncio task for reading the outbox (with
SKIP LOCKED). - A Kafka topic with domain events.
- A consumer to update the read table.
- A mechanism to rebuild the read store on first launch or after a failure.
The price of this architecture: data on the read side lags slightly behind the write side (usually 100–500 ms). New points of failure appear: consumer lag, a stuck outbox, desynchronization. Before the pain becomes real — a read replica and a cache solve it more cheaply.
How to move through the tiers correctly
The path runs only one way: 1 → 2 → 3-split → 3-event-driven. Skipping tiers is not worth it — each next tier solves a specific pain you haven't yet felt at the previous one.
A typical service history:
- Started as a simple CRUD — Tier 1.
- Real domain logic appeared — moved to Tier 2 with UseCase/Handler and markers.
- Read and write started diverging in shape — moved to Tier 3 split with
OrderViewRepository. - Read requests started getting in the way of writes, or full-text search was needed — moved to Tier 3 event-driven with a separate table.
Moving back happens, but rarely: two services were merged, complex projections were removed. Usually it means that a higher-than-needed tier was taken from the start.
Common mistakes:
- Adding
Command/Querymarkers at Tier 1 without a read-only session and separate paths — pretty words with no effect. - At Tier 3 event-driven, keeping a single
OrderRepositoryfor reading and writing — write and read have different infrastructure, so the interfaces should be different too. - Launching an event-driven read-model without a rebuild mechanism — on a consumer failure the data desynchronizes forever.
In short
- CQRS is a scale of four positions: a flat service, lightweight CQRS with markers, split repositories, an event-driven read-model.
- At each tier you take exactly as much infrastructure as is really needed at the current load.
- Tier 2:
Command/Querymarkers are mandatory together with a read-only session on the query handlers — without that the marker is decoration. - Tier 3 split:
OrderViewRepositoryreturns a read-DTO,OrderRepositoryreturns the aggregate; the paths don't cross. - Tier 3 event-driven: the read-model is updated through outbox → Kafka → consumer; the data lags a little, but the write side is free of read load.
- Move through the tiers only forward, and only by real pain — not because "that's how it's done".
What to read next
- Command side — the write handler, UnitOfWork, commands as a frozen dataclass.
- Query side — the read handler with a
ViewRepository, a read-only session. - Read-model — where to store a separate projection at the event-driven tier.
- Sync via events — outbox and aiokafka step by step.
- When CQRS is justified — the thresholds for moving between tiers.