← Back to the section

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, no FOR 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 to OrderRepository for 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:

  1. Started as a simple CRUD — Tier 1.
  2. Real domain logic appeared — moved to Tier 2 with UseCase/Handler and markers.
  3. Read and write started diverging in shape — moved to Tier 3 split with OrderViewRepository.
  4. 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/Query markers at Tier 1 without a read-only session and separate paths — pretty words with no effect.
  • At Tier 3 event-driven, keeping a single OrderRepository for 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/Query markers are mandatory together with a read-only session on the query handlers — without that the marker is decoration.
  • Tier 3 split: OrderViewRepository returns a read-DTO, OrderRepository returns 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".
  • 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.