Software engineering

CQRS for Backend Engineers: A FastAPI Case Study

Read-heavy APIs and write-heavy logic have very different needs. Why I separated them using CQRS in FastAPI and how it improved clarity, performance, and mental overhead.

For a long time, CQRS was one of those architectural patterns that I thought I understood. I knew what the acronym stood for, I could repeat the definition in interviews, and I had seen plenty of diagrams separating commands and queries. But whenever I encountered CQRS in real systems, it either felt unnecessarily complex or so abstract that it was hard to connect it to day-to-day backend work.

Eventually, I stopped trying to understand CQRS through theory. Instead, I decided to build something small, realistic, and intentionally opinionated using FastAPI. That decision is what finally made CQRS feel practical instead of academic.

The quiet limitations of CRUD

Most backend systems start with CRUD, and there’s nothing wrong with that. I’ve built many production systems using a single set of models backed by a single database, and for a while everything usually feels clean and manageable. But as the system grows, the cracks start to show. Write paths accumulate validations, conditional logic, and side effects, while read paths demand joins, aggregations, and increasingly denormalized data.

You start seeing handlers like this:

@app.post("/orders/{id}/confirm") def confirm_order(id: UUID, db: Session = Depends(get_db)): order = db.query(Order).get(id) if order.status != "DRAFT": raise HTTPException(400, "Invalid state") if not order.items: raise HTTPException(400, "Empty order") order.status = "CONFIRMED" db.commit()

At first, this looks fine. Over time, this endpoint grows. Discounts, fraud checks, notifications, audit logs—all of it piles up here. One model, one database, too many responsibilities.

Choosing a problem that deserves separation

I didn’t want to learn CQRS using a trivial example. I wanted a domain where separating reads and writes felt justified rather than forced. Order management turned out to be a perfect fit. Orders have state transitions, business rules, and historical importance. At the same time, they are read far more often than they are written.

That insight led to a simple rule in my head: orders are written rarely, but queried constantly. Once I accepted that, designing two separate models stopped feeling excessive and started feeling honest.

Understanding CQRS without ceremony

At its core, CQRS is not complicated. Commands change state. Queries return state. They don’t share models, and they don’t share storage. I stopped thinking of CQRS as a “pattern stack” and started treating it as a boundary.

In FastAPI, that boundary became explicit very quickly:

# commands @app.post("/commands/orders") def create_order(cmd: CreateOrderCommand): ... # queries @app.get("/queries/orders/{id}") def get_order(id: UUID): ...

The URLs alone made the intent clear. This wasn’t just an API style change; it was an architectural commitment.

The write side as a place for rules

I started with the write side because that’s where correctness matters most. Instead of treating orders as ORM entities with setters, I modeled them as domain objects with behavior. An order knew how to add items, how to calculate totals, and when it was allowed to transition states.

That led to code that looked more like this:

class Order: def confirm(self) -> None: if not self.items: raise DomainError("Cannot confirm empty order") if self.status != OrderStatus.DRAFT: raise DomainError("Invalid state transition") self.status = OrderStatus.CONFIRMED

This logic no longer lived in controllers or repositories. It lived where it belonged: inside the domain.

Turning changes into events safely

Every successful command produced a domain event. But I didn’t want to publish events directly from the request thread. That’s where subtle consistency problems creep in.

Instead, I used the outbox pattern. When the order changed, I wrote both the new state and an event into the same transaction:

db.add(order) db.add( OutboxEvent( aggregate_type="order", aggregate_id=str(order.id), event_type="OrderConfirmed", payload_json={"order_id": str(order.id)}, ) ) db.commit()

This guaranteed that if an order change existed, the event describing it existed too. Reliability was no longer something I had to “handle later.”

Redis as the bridge between worlds

Once events were safely stored, Redis became the transport layer between the write and read sides. I used Redis Streams because they naturally support consumer groups and replay.

Publishing an event was straightforward:

redis.xadd( "cqrs:events", {"event_id": str(event.id), "type": event.event_type} )

From this point on, the API was done. It didn’t care when or how the read side updated. That responsibility belonged elsewhere.

The read side, intentionally boring

The read side is where CQRS becomes almost relaxing. There are no business rules here, no validations, and no complex logic. The read database is shaped exactly for the queries it serves.

Instead of joins and aggregates at query time, the data is already prepared:

SELECT order_id, status, total_cents FROM order_summary WHERE customer_id = :customer_id ORDER BY created_at DESC;

The read model doesn’t pretend to be normalized or reusable. It exists to answer questions quickly and predictably.

Projecting events into queries

A background worker consumes events from Redis and updates the read database. Each event is applied idempotently so retries are safe.

Conceptually, the projector looks like this:

for event in redis_events: if already_applied(event.id): continue apply_projection(event) mark_applied(event.id)

If the worker crashes, it resumes. If an event is processed twice, nothing breaks. Eventual consistency stops being theoretical and starts being observable.

Why FastAPI fit naturally

FastAPI stayed out of the way. It handled HTTP concerns cleanly while allowing application and domain logic to remain explicit. Dependency injection made it easy to route commands to the write database and queries to the read database without leaking responsibilities.

CQRS isn’t about the framework, but FastAPI made expressing the separation feel natural rather than forced.

What building CQRS taught me

The biggest lesson wasn’t about Redis or SQLAlchemy. It was about clarity. Once I stopped forcing one model to serve competing needs, the system became easier to reason about. Business rules had a home. Read optimizations stopped bleeding into write logic.

CQRS didn’t make the system smaller, but it made it calmer.

When CQRS actually makes sense

CQRS is not a default choice. For simple CRUD apps or internal tools, it’s unnecessary. But for systems with complex workflows, heavy read traffic, and long-lived data, CQRS feels less like overengineering and more like acknowledging reality.

Once you accept that reads and writes want different things, the architecture starts designing itself.

Closing thoughts

Building CQRS from scratch changed how I think about backend design, not because it introduced new abstractions, but because it enforced boundaries. If you’re curious about CQRS, don’t start with diagrams. Start with a small system and let the problems guide the architecture.

That’s what finally worked for me.