in

What Is CQRS and When Do You Need It? Spring Boot Guide

CQRS is often mistaken for 'two databases and event sourcing'; it's really the separation of command and query models, and it can be applied at three levels. Which symptoms call for CQRS, separate models on the same database with Spring Boot, read-model projections fed by an outbox, how to present eventual consistency to users and the pitfalls in the field.

CQRS architecture diagram showing the command side, events and the read-model projection

There are two common misconceptions about CQRS (Command Query Responsibility Segregation). The first: "it requires two separate databases and event sourcing" — it doesn't. The second: "it only makes sense in microservices" — it doesn't. The essence of CQRS is simple: operations that change the system (commands) and operations that read it (queries) don't have to share the same model. Creating an order needs a rich domain model; the order-list screen needs a flat DTO. Trying to serve both with a single JPA entity graph is a major source of performance and complexity problems in enterprise applications.

This article covers what CQRS is and isn't, the three implementation levels, which symptoms call for it, separate models on the same database with Spring Boot, read-model projections fed by an outbox, presenting eventual consistency to users and the pitfalls we see in the field.

What CQRS is, and what it isn't

A command is an intent: ApproveOrder, UpdatePrice. It is validated, passes through business rules, changes state and returns little (an id, a version). A query returns data and has no side effects: OrderList(filter, page). CQRS means these two go through separate code paths, separate models when needed, separate stores when needed. Event sourcing (storing state as a sequence of events) is a separate decision; most CQRS implementations don't use it. Microservices aren't required either; it can be applied in a single module of a modular monolith.

The three levels

Level

Write side

Read side

Consistency

Complexity

When

1. Separation in code

Aggregates + JPA, command handlers

Same tables, plain SQL/JOOQ straight into DTOs

Immediate (same DB)

Low

Almost every enterprise application; ends N+1 and DTO-conversion overhead on list and report screens

2. Derived structures for reads

Same

Views, materialized views or denormalized read tables in the same DB; updated in the same transaction or with a short delay

Immediate or seconds

Medium

Heavily joined lists, dashboards, search filters

3. Separate read store

Same

Elasticsearch, Redis or another DB; a projection fed by events (outbox)

Eventual

High

Different scaling needs, full-text search, systems where reporting chokes OLTP

Level 1 is free and should be the default; level 2 is the answer to a measured query; level 3 to a measured scaling problem. The order isn't skipped: projects that start at level 3 on day one pay the price of eventual consistency without needing it.

Symptoms: when do you need CQRS?

  • Entity graphs are loaded and converted to DTOs for list screens; N+1 and memory problems show up on lists.

  • The write model is complex (aggregates, invariants, state machines), the read model is flat (tables, filters, pages).

  • The read/write ratio is skewed (1000:1) and the read side wants to scale separately.

  • Reports and dashboards slow down the production database.

  • Different representations of the same data are needed: a search index, summary cards, exports.

Where it isn't needed: simple CRUD screens, a small team, no measured problem. "Queries are slow" isn't a measurement; query plans and indexes come first.

Level 1: separation in code with Spring Boot

order/
  command/    ApproveOrderCommand, OrderCommandHandler, Order (aggregate), OrderRepository (JPA)
  query/      OrderListQuery, OrderQueryService (JdbcClient/JOOQ), OrderSummaryDto (record)
  api/        OrderController (routes commands and queries to separate services)
// Command side: intent + aggregate + a small return
public record ApproveOrderCommand(OrderId id, UserId approver) {}

@Service
public class OrderCommandHandler {
    @Transactional
    public long approve(ApproveOrderCommand c) {
        Order o = repo.findById(c.id()).orElseThrow(() -> new OrderNotFound(c.id()));
        o.approve(c.approver(), clock.instant());            // business rules inside the aggregate
        events.publish(new OrderApproved(o.id(), o.version()));   // lands in the outbox (see level 3)
        return o.version();                                  // the client can ask "has my read model reached this version"
    }
}
// Query side: no entities, straight into DTOs; only the columns the screen needs
public record OrderSummaryDto(long id, String customerName, BigDecimal total, String status, LocalDate date, int lineCount) {}

@Service
public class OrderQueryService {
    private final JdbcClient jdbc;

    public Page<OrderSummaryDto> list(OrderFilter f, int page, int size) {
        return jdbc.sql("""
                SELECT o.id, c.name AS customer_name, o.total_amount, o.status, o.order_date,
                       (SELECT count(*) FROM order_line l WHERE l.order_id = o.id) AS line_count
                FROM orders o JOIN customer c ON c.id = o.customer_id
                WHERE o.tenant_id = :tenant AND (:status IS NULL OR o.status = :status)
                ORDER BY o.order_date DESC LIMIT :size OFFSET :offset
                """)
            .param("tenant", TenantContext.current()).param("status", f.status())
            .param("size", size).param("offset", page * size)
            .query(OrderSummaryDto.class).list()
            .transform(list -> Page.of(list, page, size));
    }
}

The gain is immediate: the list query is a single SQL statement, the memory cost is the size of the DTOs, the query plan is in your hands. The write side keeps its domain rules. Same transaction, same tables — no consistency question.

Level 3: a read model fed by events

A separate read store (for example an order search index in Elasticsearch, or a denormalized "order card" table) is updated by events from the write side. To make sure no event is lost, the outbox pattern is mandatory; the projection consumer must be idempotent (the read model isn't corrupted if the same event arrives twice) and must tolerate reordering through the event version.

@Component
public class OrderCardProjection {

    @KafkaListener(topics = "order-events", groupId = "order-card-projection")
    @Transactional
    public void apply(OrderEvent event) {
        OrderCard card = cardRepo.findById(event.orderId()).orElseGet(() -> OrderCard.empty(event.orderId()));
        if (event.version() <= card.lastAppliedVersion()) return;      // idempotent: stale or repeated events are skipped
        switch (event) {
            case OrderCreated e  -> card.created(e);
            case OrderApproved e -> card.status("APPROVED");
            case OrderShipped e  -> card.shipped(e.trackingNumber());
        }
        card.lastAppliedVersion(event.version());
        cardRepo.save(card);
    }
}

Every projection must be rebuildable: it must be possible to drop the read store and rebuild it by replaying events (or by a bulk load from the write model). Without that path, the first bug in a projection is permanent data inconsistency.

Eventual consistency and the user

The user approves an order, returns to the list and sees the old status: this is level 3's inevitable question. The answers: (1) hand the version from the command response to the client, and on the list request use ?minVersion= to wait for the projection to reach that version (a short wait, then return stale data); (2) optimistic updates in the UI — the user sees the row they approved updated immediately; (3) on critical screens (payment result) query the write model, not the read model. Whichever is used, the lag (event time → projection time) is measured and watched on a dashboard.

Common mistakes in the field

  1. Level 3 on day one: A separate read store without a measured problem; the team wrestles with eventual consistency for no gain.

  2. Query logic in the command handler: The approval operation computes 40 fields for the screen; the command returns little, the screen calls the query separately.

  3. Entities on the query side: If the "query service" loads JPA entities and converts them to DTOs, there is no CQRS, just renaming.

  4. Non-idempotent projections: Kafka's at-least-once delivery ends in double counting.

  5. No rebuild path: The broken read model is fixed by hand; then it breaks again.

  6. Spreading validation across both sides: Business rules live in the aggregate; the read model only reflects.

  7. The UI ignoring eventual consistency: The "I approved it but the list shows it unapproved" support ticket.

Frequently asked questions

Do we need a framework such as Axon for CQRS?

Not for levels 1 and 2; a few packages and discipline are enough. At level 3 a framework can speed up event publishing, projection and replay infrastructure; but a hand-built setup with outbox + Kafka + idempotent consumers is also common and more transparent. A framework becomes valuable if you move to event sourcing.

Should we use CQRS together with event sourcing?

Separate decisions. Event sourcing gives a full audit trail and time travel, but schema evolution, query difficulty and the learning curve are serious. The vast majority of enterprise applications implement CQRS with classic state storage; event sourcing only where the audit trail is a business requirement (financial ledgers, contract lifecycles).

Which store for the read model?

By the shape of the problem: full-text and faceted search → Elasticsearch/OpenSearch; fast key-value access → Redis; heavy reporting → a separate PostgreSQL replica or an analytical store. In most cases a denormalized table in the same PostgreSQL is the first and sufficient step.

Conclusion

CQRS is not an architectural revolution but a discipline of separation: commands go through the domain model, queries through flat DTOs. Level 1 is free in every enterprise application; levels 2 and 3 are answers to measured problems and come bundled with eventual consistency, idempotent projections and a rebuild path. Choosing the right level matters more than applying the pattern correctly.

To separate your application's read and write paths or design a scalable read model, see our software design and architecture service.