The four previous articles in the series laid the building blocks for a distributed system to stay consistent: Saga to orchestrate workflows, Outbox to publish reliable events, Inbox to consume them without duplicates, Idempotency Keys to protect the API. One question is left: what do you do with events once they are published?
The most common answer: you use them to build read views. That is exactly what the CQRS pattern (Command Query Responsibility Segregation) proposes: separate the write model from the read model when both diverge enough that forcing them into a single structure costs more than splitting them.
This article closes the series with the same guiding line: concrete code, no dogma, and explicitly no Event Sourcing. Pragmatic CQRS on Django boils down to “two models, two paths, one event bridge”, and that is enough for the vast majority of cases.
The problem: reads and writes do not want the same thing
An e-commerce order, on the write side, is a rich business object with invariants: a status that cannot regress, lines that must match available stock, an amount derived from rules. The Django model reflects these constraints.
class Order(models.Model):
status = models.CharField(choices=VALID_STATUSES)
customer = models.ForeignKey(Customer, on_delete=PROTECT)
shipping_address = models.ForeignKey(Address, on_delete=PROTECT)
def confirm(self):
if self.status != "cart":
raise InvalidTransition(...)
...
On the read side, the same data takes another shape. The “My orders” screen wants to show: number, date, total, human-readable status (“In preparation”), item count, image of the first product. To render that screen, the server joins Order, OrderLine, Product, ProductImage, StatusLabel, on tens of thousands of rows, every time the dashboard opens.
As the app grows, two opposite pressures appear. The write model wants to stay normalized, constrained, consistent. The read model wants to be flat, fast, sometimes ahead of the database at the cost of eventual consistency. Trying to serve both needs from the same tables ends up sacrificing one of them.
The principle: two models, two paths
CQRS says: stop chasing the compromise, split. Commands mutate state in the write model. Queries read from a read model, structurally different, updated in the background from events emitted by commands.
Client → Command API → Write model (Django ORM) → event (Outbox)
↓
Broker (Kafka)
↓
Client → Query API ← Read model (flat table) ← Inbox + denormalizer
The two paths have nothing in common anymore. The write model stays a clean DDD domain with its invariants. The read model is a projection optimized for the actual screens of the app. No complex joins on the read path, no read-model constraint leaking into business logic.
The read model: a flat table fed by events
A concrete example for the “My orders” screen:
class OrderReadModel(models.Model):
order_id = models.UUIDField(primary_key=True)
customer_id = models.BigIntegerField(db_index=True)
number = models.CharField(max_length=32)
created_at = models.DateTimeField()
status = models.CharField(max_length=32)
status_label = models.CharField(max_length=64)
total_cents = models.IntegerField()
item_count = models.IntegerField()
preview_image_url = models.URLField(blank=True)
last_update = models.DateTimeField()
class Meta:
indexes = [
models.Index(fields=["customer_id", "-created_at"]),
]
No ForeignKey. No relation. No logic. It is deliberately a “flat denormalized view” that serves a single use: listing a customer’s orders, sorted by date. The read query becomes trivial:
def list_orders(customer_id):
return OrderReadModel.objects.filter(customer_id=customer_id)[:50]
A single indexed SELECT, no JOIN, no N+1. Constant performance regardless of how many rows or products per order.
The bridge: a consumer that denormalizes
The read model is built and maintained by a consumer that listens to events emitted by the write model through the Outbox. Each event triggers a read-model update, protected by the Inbox pattern to avoid duplicates.
def handle_order_confirmed(event: dict) -> None:
try:
with transaction.atomic():
InboxEvent.objects.create(
event_id=event["event_id"],
consumer="orders_read_model",
)
OrderReadModel.objects.update_or_create(
order_id=event["order_id"],
defaults={
"customer_id": event["customer_id"],
"number": event["number"],
"created_at": event["created_at"],
"status": "confirmed",
"status_label": "Confirmed",
"total_cents": event["total_cents"],
"item_count": event["item_count"],
"preview_image_url": event["preview_image_url"],
"last_update": timezone.now(),
},
)
except IntegrityError:
return
update_or_create is intentional: the same event may refer to an order that was already inserted into the read model by a previous event (for instance OrderCreated then OrderConfirmed). We want the latest state, not a primary-key conflict.
The event payload must contain everything the read model needs. That is a structural choice: we avoid having the consumer re-query the write model, because that recouples both models and erases the benefit of the split.
Three ways to materialize the read model
The read model is not necessarily a regular Django table. Three options coexist depending on volume and freshness requirements.
Denormalized table maintained by the consumer. The default approach, as above. Simple, transactional, readable through the Django ORM. The right call for the large majority of cases.
PostgreSQL materialized view. Instead of maintaining the table by hand through events, declare a MATERIALIZED VIEW that aggregates write-model data. A periodic REFRESH MATERIALIZED VIEW CONCURRENTLY updates it. No Outbox, no consumer needed. The trade-off: freshness is bounded by refresh frequency, and refresh cost grows with volume.
Elasticsearch or OpenSearch index. When queries become full-text search or analytics aggregations, a read model in a dedicated engine becomes relevant. The consumer writes to Elasticsearch instead of a SQL table. The pattern is identical, only the destination changes.
The choice depends on the query to serve, not on an abstract principle. On a single project, several read models can coexist, each optimized for its own usage.
Eventual consistency, and what it implies
The read model lags behind the write model. Between the moment an order is confirmed and the moment it appears in the read model, a few milliseconds to a few seconds elapse, depending on broker throughput and consumer load. That is eventual consistency, and that is the fundamental CQRS trade-off.
Three practical consequences.
After placing an order, the customer must see it. If the confirmation page reads the read model, it can show “No orders” during the 200 ms while the event has not yet propagated. The fix: read the write model right after a command, and switch to the read model for generic pages. The trade-off is handled page by page, not globally.
Admin reports can live with a few seconds of lag. That is the ideal read-model case: a “today’s sales” dashboard refreshing every 30 seconds does not suffer from 2 seconds of lag relative to the write model.
The read model can be rebuilt from scratch. If the projection is buggy or you want to add a field, you wipe the table and replay every event from the beginning. That mechanism requires long broker retention, or a secondary source to replay from. When possible, it turns the read model into a rebuildable cache instead of a fragile source of truth.
CQRS is not Event Sourcing
The most common confusion. CQRS says “two models, two paths”. Event Sourcing says “stop storing state, store the sequence of events that produced that state”. Both can be combined, but it is not required.
The pragmatic CQRS shown here keeps a regular Django write model with an Order table that contains current state. Events emitted to the read model describe what changed, but they are not the source of truth. The source of truth stays the ORM, like in any Django project.
Event Sourcing implies storing events themselves as primary state, and rebuilding any information by replay. It is a radically different approach, with its own challenges (event versioning, snapshots, replay performance). It deserves its own article, even its own series.
When not to do CQRS
The pattern adds infrastructure: Outbox, broker, consumer, denormalized table, eventual consistency handling. For an app serving a few thousand users with simple queries, it is overengineering.
CQRS becomes relevant once:
- read queries cost significantly more than writes (heavy joins, aggregations)
- write and read models diverge to the point where DRF
Serializers become unreadable - you want to scale reads horizontally without touching the write model
- you have already adopted the Outbox for other reasons, and the read model becomes a free consumption of existing events
Conversely, for a classic CRUD with screens mapping almost 1-1 to models, the Django ORM with a few select_related and prefetch_related calls stays far superior in simplicity and consistency.
Conclusion
Pragmatic CQRS is an optimization pattern, not an architectural dogma. You introduce it where the read/write divergence hurts, not on principle. The benefit is being able to give each side what it wants: a constrained, consistent write model, and a flat, fast read model.
The distributed architecture pattern series ends here, with a system where every boundary has its protection and every read path is optimized for its use. Saga, Outbox, Inbox, Idempotency Keys and CQRS together form a toolbox you assemble according to real needs, not according to an idealized architecture vision.
