CQRS with Spring Boot, Kafka & MongoDB — Part 1: What is CQRS and why you need it
Understanding Command Query Responsibility Segregation, when it makes sense, and how Spring Boot, Kafka, and MongoDB fit together in a CQRS architecture.
This is Part 1 of a series on building a CQRS architecture with Spring Boot, Kafka, and MongoDB.
- What is CQRS and why you need it (this post)
- Command side — writes done right (coming soon)
- Query side — reads at scale (coming soon)
- The hard parts (coming soon)
- Putting it all together (coming soon)
The problem with one service doing everything
You have a Spring Boot service. It handles placing orders, updating order status, fetching order details, listing orders for a customer dashboard, and generating reports. One service, one model, one MongoDB collection.
It works. Customers place orders, the dashboard loads, reports generate. Ship it.
Then traffic grows. The dashboard page is hit 50 times for every 1 order placed. Your read queries need data joined from orders, products, and customer profiles — so you’re running aggregation pipelines on every request. Your write path needs strict validation, inventory checks, and payment confirmation before saving.
Now you have a problem. The read path wants denormalized, pre-joined, fast-to-query data. The write path wants normalized, validated, consistent data. These two concerns are pulling your single model in opposite directions.
You start adding caching layers. You add read replicas. You optimize queries. But the core issue remains — you’re using one model to serve two fundamentally different purposes.
Separate the reads from the writes
That’s what CQRS is. Command Query Responsibility Segregation. Split your application into two sides:
- Command side — handles writes. Accepts commands like “place order” or “cancel order”. Validates, applies business logic, persists to the database, and publishes an event saying what happened.
- Query side — handles reads. Listens to events, builds read-optimized views (projections), and serves queries. No business logic, no validation — just fast reads.
These are separate services with separate responsibilities. The command service doesn’t serve any read APIs. The query service doesn’t accept any writes.
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Client │────▶│ Command Service │────▶│ Kafka │
│ (write) │ │ (validates, │ │ (events) │
│ │ │ persists, │ │ │
│ │ │ publishes) │ │ │
└─────────────┘ └──────────────────┘ └──────┬──────┘
│
▼
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Client │◀────│ Query Service │◀────│ Consumes │
│ (read) │ │ (projections, │ │ events │
│ │ │ fast reads) │ │ │
└─────────────┘ └──────────────────┘ └─────────────┘
The command service writes to MongoDB’s primary node and waits for acknowledgement from all replica set members (w: majority). This guarantees the data is durable before publishing an event to Kafka.
The query service reads from MongoDB’s secondary nodes using read preference secondaryPreferred. This offloads read traffic from the primary and lets the query side scale independently.
Same MongoDB cluster. Different access patterns. Different services.
This is not event sourcing
A common confusion. Let’s clear it up.
CQRS says: separate your read and write models into different services.
Event sourcing says: don’t store current state — store every event that ever happened, and rebuild state by replaying events.
You can use CQRS without event sourcing. That’s exactly what we’re doing in this series. The command service stores the current state of an order in MongoDB — not a log of events. It just publishes events to Kafka so the query side knows what changed.
Event sourcing is a powerful pattern, but it adds significant complexity — event versioning, snapshots, replay infrastructure. You don’t need it to benefit from CQRS.
Where Kafka fits
Kafka is the bridge between the two sides. When the command service successfully persists an order, it publishes an event like OrderPlaced to a Kafka topic.
The query service consumes these events and updates its read-optimized projections. Maybe it denormalizes the order with product names and customer details so the dashboard query is a single document lookup instead of an aggregation pipeline.
Why Kafka and not just a REST call from command to query service?
- Decoupling — the command service doesn’t know or care about the query service. It publishes events. Whoever needs them, consumes them.
- Resilience — if the query service goes down, events are retained in Kafka. When it comes back up, it catches up.
- Scalability — you can add more consumers. A reporting service, an analytics service, a notification service — all consuming the same events without changing the command service.
- Ordering — Kafka guarantees ordering within a partition. Partition by order ID and you get ordered events per order.
When CQRS makes sense
CQRS is not a default architecture. It adds operational complexity — two services, Kafka infrastructure, eventual consistency. Don’t use it because it sounds cool.
Use it when:
- Read and write patterns are significantly different — your reads need denormalized, joined data while your writes need strict validation and consistency.
- Read traffic vastly outweighs writes — you need to scale reads independently without impacting write performance.
- Multiple consumers need write events — other services (notifications, analytics, search indexing) need to react to changes.
- Your single service is becoming a mess — the read and write logic is tangled together, making the codebase hard to reason about and test.
Don’t use it when:
- Your app is a simple CRUD — if reads and writes use the same model naturally, CQRS adds complexity for no gain.
- You can’t afford eventual consistency — the query side will always be slightly behind the command side. If your domain requires instant read-after-write consistency, CQRS needs careful handling.
- Your team is small and the system is young — start simple. Extract into CQRS when the pain becomes real, not preventively.
The order management example
Throughout this series, we’ll build CQRS for an order management system. Here’s the scenario:
- Customers place orders, update shipping addresses, and cancel orders — commands.
- The customer dashboard shows order history with product names, prices, and delivery status — queries.
- The operations team has a different dashboard showing order volumes, revenue, and pending shipments — queries with a completely different shape.
One write model. Multiple read models. Each optimized for its consumer.
What’s next
In Part 2, we’ll build the command side. A Spring Boot service in Kotlin that accepts commands, validates them, persists to MongoDB with write concern majority, and publishes domain events to Kafka.
We’ll set up the project structure, define our commands and events, and write tests before writing implementation — because that’s how we do things around here.