Skip to main content
event sourcing and cqrs in practice

CQRS: Separating the Write Model from the Read Model and the Consistency Trade-off That Comes With It

17 min read Chapter 3 of 17

CQRS: Separating the Write Model from the Read Model

CQRS separation diagram

CQRS splits the application into two distinct paths. Commands flow through the write side: command handlers validate business rules, aggregates produce events, and the event store persists them. Queries flow through the read side: projections consume events and populate optimized read models. The two sides are connected by the event stream and are eventually consistent.

The orders table in a CRUD system serves two masters. The order placement endpoint writes to it. The admin dashboard queries it. The fulfilment service reads from it. The analytics pipeline aggregates it. Each consumer has different needs: the write path needs fast inserts with constraint validation, the admin dashboard needs full-text search with pagination, the fulfilment service needs a join with inventory, and the analytics pipeline needs aggregations by date range.

The table’s schema is a compromise. Adding an index for the admin dashboard’s search slows down the write path. Adding a computed column for analytics increases the cost of every insert. The fulfilment service’s join pattern conflicts with the admin dashboard’s sort order. Every optimization for one consumer is a degradation for another.

CQRS resolves this tension by separating the write model from the read model. Commands (writes) go to one model optimized for enforcing business invariants. Queries (reads) go to a different model optimized for the specific query pattern. The two models are different representations of the same data, updated asynchronously or synchronously depending on the consistency requirements.

This separation is valuable independent of event sourcing. A system that writes to a normalized relational schema and projects to denormalized read tables via triggers or change data capture is a CQRS system. Event sourcing adds capabilities on top of CQRS, but CQRS does not require event sourcing.

The Write Model

The Problem

In a CRUD system, the service layer retrieves the current entity state, validates the command, and updates the entity in place. The entity is a mutable object that mirrors the database row. The validation logic is interleaved with the persistence logic. When the validation rules become complex (an order can only be cancelled if payment has not been captured, fulfilment has not started, and the cancellation window has not expired), the service method becomes a tangle of conditional checks, database reads, and updates.

The write model in CQRS isolates the decision logic from the query logic. The write model does not serve queries. It accepts commands, validates them against the current state, and produces state changes. In an event-sourced system, those state changes are events. In a CQRS system without event sourcing, those state changes are database writes.

The Mechanism

A command is a request to change state. It contains the intent and the data needed to fulfill it.

// FROM SCRATCH
public sealed interface OrderCommand {
    String orderId();
}

public record PlaceOrder(
    String orderId,
    String customerId,
    List<LineItem> lineItems,
    Address shippingAddress
) implements OrderCommand {}

public record ConfirmOrder(
    String orderId
) implements OrderCommand {}

public record CancelOrder(
    String orderId,
    String reason,
    String cancelledBy
) implements OrderCommand {}

public record ChangeShippingAddress(
    String orderId,
    Address newAddress,
    String reason
) implements OrderCommand {}

Commands are named in imperative mood: PlaceOrder, not OrderPlaced. A command is a request. It might be rejected. An event is a fact. It has already been accepted. This naming distinction prevents the confusion between “what we want to happen” and “what did happen” that plagues systems where commands and events share naming conventions.

The From-Scratch Implementation

A command handler receives a command, loads the relevant aggregate state, validates the command against that state, and produces events.

// FROM SCRATCH
public class OrderCommandHandler {

    private final JdbcEventStore eventStore;
    private final EventTypeRegistry registry;

    public OrderCommandHandler(JdbcEventStore eventStore, EventTypeRegistry registry) {
        this.eventStore = eventStore;
        this.registry = registry;
    }

    public List<OrderEvent> handle(PlaceOrder command) {
        // New order: no existing stream
        var event = new OrderPlaced(
            command.orderId(),
            command.customerId(),
            command.lineItems(),
            calculateTotal(command.lineItems()),
            command.shippingAddress(),
            Instant.now()
        );

        eventStore.append("order-" + command.orderId(), 0, List.of(event));
        return List.of(event);
    }

    public List<OrderEvent> handle(CancelOrder command) {
        // Load current state from events
        List<StoredEvent> storedEvents = eventStore.readStream("order-" + command.orderId());
        if (storedEvents.isEmpty()) {
            throw new OrderNotFoundException(command.orderId());
        }

        OrderState state = rebuildState(storedEvents);

        if (state.status() == OrderStatus.CANCELLED) {
            throw new OrderAlreadyCancelledException(command.orderId());
        }
        if (state.status() == OrderStatus.FULFILLED) {
            throw new OrderCannotBeCancelledException(
                command.orderId(), "Order has been fulfilled"
            );
        }

        var event = new OrderCancelled(
            command.orderId(),
            command.reason(),
            command.cancelledBy(),
            Instant.now()
        );

        eventStore.append(
            "order-" + command.orderId(),
            storedEvents.size(),
            List.of(event)
        );
        return List.of(event);
    }

    private OrderState rebuildState(List<StoredEvent> storedEvents) {
        OrderState state = OrderState.initial();
        for (StoredEvent stored : storedEvents) {
            OrderEvent event = registry.deserialize(stored.eventType(), stored.payload());
            state = state.apply(event);
        }
        return state;
    }

    private BigDecimal calculateTotal(List<LineItem> lineItems) {
        return lineItems.stream()
            .map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

The rebuildState method folds over the event stream to produce the current state. This is the core of event sourcing: state is not loaded from a row, it is computed from events. The OrderState record captures the computed state.

// FROM SCRATCH
public record OrderState(
    String orderId,
    String customerId,
    OrderStatus status,
    BigDecimal total,
    Address shippingAddress,
    List<LineItem> lineItems
) {
    public static OrderState initial() {
        return new OrderState(null, null, OrderStatus.NEW, BigDecimal.ZERO, null, List.of());
    }

    public OrderState apply(OrderEvent event) {
        return switch (event) {
            case OrderPlaced e -> new OrderState(
                e.orderId(), e.customerId(), OrderStatus.PLACED,
                e.total(), e.shippingAddress(), e.lineItems()
            );
            case OrderConfirmed e -> new OrderState(
                orderId, customerId, OrderStatus.CONFIRMED,
                total, shippingAddress, lineItems
            );
            case PaymentAuthorized e -> new OrderState(
                orderId, customerId, OrderStatus.PAYMENT_AUTHORIZED,
                total, shippingAddress, lineItems
            );
            case ShippingAddressChanged e -> new OrderState(
                orderId, customerId, status,
                total, e.newAddress(), lineItems
            );
            case OrderCancelled e -> new OrderState(
                orderId, customerId, OrderStatus.CANCELLED,
                total, shippingAddress, lineItems
            );
        };
    }
}

public enum OrderStatus {
    NEW, PLACED, CONFIRMED, PAYMENT_AUTHORIZED, FULFILLED, CANCELLED, REFUNDED
}

The apply method uses Java 21’s pattern matching for switch expressions. The sealed interface on OrderEvent ensures the switch is exhaustive. Adding a new event type without adding a case to apply is a compile-time error. This is a significant advantage over visitor patterns or if-else chains.

What the Implementation Reveals

The command handler has a clear shape: load, validate, decide, persist. This shape is the same for every command handler in an event-sourced system. The specifics change (different validation rules, different events), but the structure is constant.

The command handler never queries a read model. It loads the aggregate from the event store, makes its decision, and persists the result. This is the write side of CQRS. If the command handler also needed to check whether the customer has outstanding orders (a query concern), it would need to read from a projection or execute a query against the event store. The former couples the write side to the read side. The latter is expensive. Chapter 4 covers how aggregates encapsulate the state needed for command validation.

The rebuildState method reads every event in the stream to produce the current state. For a new order with 3 events, this is trivial. For an order with 10,000 events (high-frequency updates in a trading system), this becomes a performance problem. Chapter 6 introduces snapshotting to address this.

The Production Path

The Spring Boot command handler uses the same structure with Spring’s transactional support.

// PRODUCTION
@Service
public class OrderCommandService {

    private final SpringEventStore eventStore;
    private final EventTypeRegistry registry;

    public OrderCommandService(SpringEventStore eventStore, EventTypeRegistry registry) {
        this.eventStore = eventStore;
        this.registry = registry;
    }

    @Transactional
    public List<OrderEvent> handle(PlaceOrder command) {
        var event = new OrderPlaced(
            command.orderId(),
            command.customerId(),
            command.lineItems(),
            calculateTotal(command.lineItems()),
            command.shippingAddress(),
            Instant.now()
        );

        eventStore.append("order-" + command.orderId(), 0, List.of(event));
        return List.of(event);
    }

    @Transactional
    public List<OrderEvent> handle(CancelOrder command) {
        List<StoredEvent> storedEvents = eventStore.readStream("order-" + command.orderId());
        if (storedEvents.isEmpty()) {
            throw new OrderNotFoundException(command.orderId());
        }

        OrderState state = rebuildState(storedEvents);
        validateCancellation(state, command);

        var event = new OrderCancelled(
            command.orderId(),
            command.reason(),
            command.cancelledBy(),
            Instant.now()
        );

        eventStore.append("order-" + command.orderId(), storedEvents.size(), List.of(event));
        return List.of(event);
    }

    private void validateCancellation(OrderState state, CancelOrder command) {
        if (state.status() == OrderStatus.CANCELLED) {
            throw new OrderAlreadyCancelledException(command.orderId());
        }
        if (state.status() == OrderStatus.FULFILLED) {
            throw new OrderCannotBeCancelledException(command.orderId(), "Already fulfilled");
        }
    }

    private OrderState rebuildState(List<StoredEvent> storedEvents) {
        OrderState state = OrderState.initial();
        for (StoredEvent stored : storedEvents) {
            OrderEvent event = registry.deserialize(stored.eventType(), stored.payload());
            state = state.apply(event);
        }
        return state;
    }

    private BigDecimal calculateTotal(List<LineItem> lineItems) {
        return lineItems.stream()
            .map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

In Axon Framework, the command handler is an @Aggregate class with @CommandHandler methods and @EventSourcingHandler methods. The aggregate loading, event application, and concurrency control are handled by the framework. The developer writes the validation logic and the event production. Axon eliminates the boilerplate but hides the loading and concurrency mechanics.

The Test

// FROM SCRATCH
@Testcontainers
class OrderCommandHandlerTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("cqrs_test")
        .withInitScript("event_store_schema.sql");

    private OrderCommandHandler handler;
    private JdbcEventStore eventStore;

    @BeforeEach
    void setUp() {
        var dataSource = new org.postgresql.ds.PGSimpleDataSource();
        dataSource.setUrl(postgres.getJdbcUrl());
        dataSource.setUser(postgres.getUsername());
        dataSource.setPassword(postgres.getPassword());

        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        eventStore = new JdbcEventStore(dataSource, mapper);
        var registry = new EventTypeRegistry(mapper);
        handler = new OrderCommandHandler(eventStore, registry);
    }

    @Test
    void placeOrderCreatesOrderPlacedEvent() {
        var command = new PlaceOrder(
            "order-1", "customer-1",
            List.of(new LineItem("prod-1", "Widget", 2, new BigDecimal("19.99"))),
            new Address("123 Main St", "Springfield", "IL", "62701", "US")
        );

        List<OrderEvent> produced = handler.handle(command);

        assertEquals(1, produced.size());
        assertInstanceOf(OrderPlaced.class, produced.get(0));
        OrderPlaced placed = (OrderPlaced) produced.get(0);
        assertEquals(new BigDecimal("39.98"), placed.total());

        // Verify persistence
        List<StoredEvent> stored = eventStore.readStream("order-order-1");
        assertEquals(1, stored.size());
        assertEquals("OrderPlaced", stored.get(0).eventType());
    }

    @Test
    void cancelOrderAfterPlacementSucceeds() {
        handler.handle(new PlaceOrder(
            "order-2", "customer-1", List.of(), BigDecimal.ZERO,
            new Address("1 St", "City", "ST", "00000", "US")
        ));

        var cancel = new CancelOrder("order-2", "Changed my mind", "customer-1");
        List<OrderEvent> produced = handler.handle(cancel);

        assertEquals(1, produced.size());
        assertInstanceOf(OrderCancelled.class, produced.get(0));
    }

    @Test
    void cancelAlreadyCancelledOrderFails() {
        handler.handle(new PlaceOrder(
            "order-3", "customer-1", List.of(), BigDecimal.ZERO,
            new Address("1 St", "City", "ST", "00000", "US")
        ));
        handler.handle(new CancelOrder("order-3", "Reason", "customer-1"));

        assertThrows(OrderAlreadyCancelledException.class, () ->
            handler.handle(new CancelOrder("order-3", "Reason again", "customer-1"))
        );
    }
}

The Read Model

The Problem

The command handler in the previous section reads from the event store to load aggregate state. This works for command validation, where you need the complete state of a single aggregate. It does not work for queries.

A customer wants to see their order history: all orders, sorted by date, with status and total. A CRUD system answers this with SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC. An event-sourced system has no orders table. The events are stored in the event store, organized by stream. Answering the query means reading every order stream for a given customer, replaying events for each, and sorting the results. This is not viable at scale.

The Mechanism

The read model is a denormalized table optimized for a specific query pattern. It is populated by processing events and storing the derived state. The read model is a projection of the event stream into a queryable format.

-- FROM SCRATCH
CREATE TABLE order_summary (
    order_id        VARCHAR(255) PRIMARY KEY,
    customer_id     VARCHAR(255) NOT NULL,
    status          VARCHAR(50) NOT NULL,
    total           DECIMAL(10, 2) NOT NULL,
    item_count      INT NOT NULL,
    shipping_city   VARCHAR(255),
    placed_at       TIMESTAMPTZ,
    last_updated_at TIMESTAMPTZ NOT NULL,

    CONSTRAINT chk_status CHECK (status IN ('PLACED', 'CONFIRMED', 'PAYMENT_AUTHORIZED', 'FULFILLED', 'CANCELLED', 'REFUNDED'))
);

CREATE INDEX idx_order_summary_customer ON order_summary (customer_id, placed_at DESC);
CREATE INDEX idx_order_summary_status ON order_summary (status);

This table is not the source of truth. The event store is the source of truth. This table is a materialized view that can be rebuilt from scratch by replaying all events. If the table is corrupted, dropped, or redesigned, the data is recoverable.

The From-Scratch Implementation

// FROM SCRATCH
public class OrderSummaryProjection {

    private final DataSource dataSource;
    private final EventTypeRegistry registry;

    public OrderSummaryProjection(DataSource dataSource, EventTypeRegistry registry) {
        this.dataSource = dataSource;
        this.registry = registry;
    }

    public void process(StoredEvent storedEvent) {
        OrderEvent event = registry.deserialize(storedEvent.eventType(), storedEvent.payload());

        switch (event) {
            case OrderPlaced e -> insertOrderSummary(e);
            case OrderConfirmed e -> updateStatus(e.orderId(), "CONFIRMED", e.occurredAt());
            case PaymentAuthorized e -> updateStatus(e.orderId(), "PAYMENT_AUTHORIZED", e.occurredAt());
            case OrderCancelled e -> updateStatus(e.orderId(), "CANCELLED", e.occurredAt());
            case ShippingAddressChanged e -> updateShippingCity(e.orderId(), e.newAddress().city(), e.occurredAt());
        }
    }

    private void insertOrderSummary(OrderPlaced event) {
        String sql = """
            INSERT INTO order_summary (order_id, customer_id, status, total, item_count, shipping_city, placed_at, last_updated_at)
            VALUES (?, ?, 'PLACED', ?, ?, ?, ?, ?)
            ON CONFLICT (order_id) DO UPDATE SET
                status = EXCLUDED.status,
                total = EXCLUDED.total,
                last_updated_at = EXCLUDED.last_updated_at
            """;

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setString(1, event.orderId());
            stmt.setString(2, event.customerId());
            stmt.setBigDecimal(3, event.total());
            stmt.setInt(4, event.lineItems().size());
            stmt.setString(5, event.shippingAddress().city());
            stmt.setTimestamp(6, Timestamp.from(event.occurredAt()));
            stmt.setTimestamp(7, Timestamp.from(event.occurredAt()));
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new ProjectionException("Failed to insert order summary", e);
        }
    }

    private void updateStatus(String orderId, String status, Instant occurredAt) {
        String sql = """
            UPDATE order_summary SET status = ?, last_updated_at = ? WHERE order_id = ?
            """;

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setString(1, status);
            stmt.setTimestamp(2, Timestamp.from(occurredAt));
            stmt.setString(3, orderId);
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new ProjectionException("Failed to update order status", e);
        }
    }

    private void updateShippingCity(String orderId, String city, Instant occurredAt) {
        String sql = """
            UPDATE order_summary SET shipping_city = ?, last_updated_at = ? WHERE order_id = ?
            """;

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setString(1, city);
            stmt.setTimestamp(2, Timestamp.from(occurredAt));
            stmt.setString(3, orderId);
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new ProjectionException("Failed to update shipping city", e);
        }
    }
}

The query side reads from the projection table, not from the event store.

// FROM SCRATCH
public class OrderQueryHandler {

    private final DataSource dataSource;

    public OrderQueryHandler(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public List<OrderSummaryDto> findByCustomer(String customerId, int limit, int offset) {
        String sql = """
            SELECT order_id, customer_id, status, total, item_count, shipping_city, placed_at, last_updated_at
            FROM order_summary
            WHERE customer_id = ?
            ORDER BY placed_at DESC
            LIMIT ? OFFSET ?
            """;

        List<OrderSummaryDto> results = new ArrayList<>();
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setString(1, customerId);
            stmt.setInt(2, limit);
            stmt.setInt(3, offset);
            try (ResultSet rs = stmt.executeQuery()) {
                while (rs.next()) {
                    results.add(new OrderSummaryDto(
                        rs.getString("order_id"),
                        rs.getString("customer_id"),
                        rs.getString("status"),
                        rs.getBigDecimal("total"),
                        rs.getInt("item_count"),
                        rs.getString("shipping_city"),
                        rs.getTimestamp("placed_at").toInstant(),
                        rs.getTimestamp("last_updated_at").toInstant()
                    ));
                }
            }
        } catch (SQLException e) {
            throw new QueryException("Failed to query order summaries", e);
        }
        return results;
    }
}

public record OrderSummaryDto(
    String orderId,
    String customerId,
    String status,
    BigDecimal total,
    int itemCount,
    String shippingCity,
    Instant placedAt,
    Instant lastUpdatedAt
) {}

What the Implementation Reveals

The read model is eventually consistent with the write model. When an OrderPlaced event is written to the event store, the order_summary table is not updated until the projection processes the event. The delay between write and read update is the consistency window.

For a synchronous projection (the projection runs in the same transaction as the event store write), the consistency window is zero. The read model is always up to date. But the write path is slower because it includes the projection update, and the write and read models share a transactional boundary.

For an asynchronous projection (the projection polls the event store or receives events via a message broker), the consistency window is non-zero. The read model might show stale data. But the write path is faster, and the write and read models are independently scalable.

Most production systems use asynchronous projections. The consistency window is typically milliseconds. The user experience implications of this delay are addressed in the next section.

The Production Path

// PRODUCTION
@Repository
public class OrderSummaryRepository {

    private final JdbcTemplate jdbc;

    public OrderSummaryRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public List<OrderSummaryDto> findByCustomer(String customerId, int limit, int offset) {
        return jdbc.query(
            """
            SELECT order_id, customer_id, status, total, item_count, shipping_city, placed_at, last_updated_at
            FROM order_summary
            WHERE customer_id = ?
            ORDER BY placed_at DESC
            LIMIT ? OFFSET ?
            """,
            (rs, rowNum) -> new OrderSummaryDto(
                rs.getString("order_id"),
                rs.getString("customer_id"),
                rs.getString("status"),
                rs.getBigDecimal("total"),
                rs.getInt("item_count"),
                rs.getString("shipping_city"),
                rs.getTimestamp("placed_at").toInstant(),
                rs.getTimestamp("last_updated_at").toInstant()
            ),
            customerId, limit, offset
        );
    }
}

The Test

// FROM SCRATCH
@Testcontainers
class CqrsIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("cqrs_test")
        .withInitScript("cqrs_schema.sql");

    private OrderCommandHandler commandHandler;
    private OrderSummaryProjection projection;
    private OrderQueryHandler queryHandler;
    private JdbcEventStore eventStore;

    @BeforeEach
    void setUp() {
        var dataSource = new org.postgresql.ds.PGSimpleDataSource();
        dataSource.setUrl(postgres.getJdbcUrl());
        dataSource.setUser(postgres.getUsername());
        dataSource.setPassword(postgres.getPassword());

        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        var registry = new EventTypeRegistry(mapper);
        eventStore = new JdbcEventStore(dataSource, mapper);
        commandHandler = new OrderCommandHandler(eventStore, registry);
        projection = new OrderSummaryProjection(dataSource, registry);
        queryHandler = new OrderQueryHandler(dataSource);
    }

    @Test
    void writeAndReadSidesSynchronize() {
        // Write side: place an order
        var command = new PlaceOrder(
            "order-1", "customer-1",
            List.of(new LineItem("prod-1", "Widget", 2, new BigDecimal("19.99"))),
            new Address("123 Main St", "Springfield", "IL", "62701", "US")
        );
        commandHandler.handle(command);

        // Process events through projection
        List<StoredEvent> events = eventStore.readStream("order-order-1");
        for (StoredEvent event : events) {
            projection.process(event);
        }

        // Read side: query order summary
        List<OrderSummaryDto> summaries = queryHandler.findByCustomer("customer-1", 10, 0);
        assertEquals(1, summaries.size());
        assertEquals("PLACED", summaries.get(0).status());
        assertEquals(new BigDecimal("39.98"), summaries.get(0).total());
    }

    @Test
    void cancellationUpdatesReadModel() {
        commandHandler.handle(new PlaceOrder(
            "order-2", "customer-1",
            List.of(new LineItem("prod-1", "Widget", 1, new BigDecimal("9.99"))),
            new Address("1 St", "City", "ST", "00000", "US")
        ));

        commandHandler.handle(new CancelOrder("order-2", "Changed my mind", "customer-1"));

        // Process all events
        List<StoredEvent> events = eventStore.readStream("order-order-2");
        for (StoredEvent event : events) {
            projection.process(event);
        }

        List<OrderSummaryDto> summaries = queryHandler.findByCustomer("customer-1", 10, 0);
        assertEquals(1, summaries.size());
        assertEquals("CANCELLED", summaries.get(0).status());
    }
}

The Consistency Trade-off

The gap between the write model and the read model is the consistency trade-off of CQRS. After a command is processed and events are stored, the read model is stale until the projection catches up.

For a user who just placed an order and immediately navigates to “My Orders,” the read model might not yet contain the order. This is not a bug. It is the expected behavior of an eventually consistent system.

There are three strategies to handle this.

Read-your-writes consistency. After a successful command, return the produced events to the caller. The caller uses the events to update its local view without querying the read model. The read model catches up in the background. This is the preferred approach for user-facing interactions.

Synchronous projection. Update the read model within the same transaction as the event store write. This eliminates the consistency gap but couples the write and read paths. A slow projection blocks the command response. A projection failure rolls back the event store write. Use this only for read models that must be immediately consistent and have fast, reliable updates.

Polling with stale tolerance. The client polls the read model and accepts that the data might be slightly behind. This is appropriate for dashboards, analytics, and batch-processing consumers where seconds of lag are acceptable.

The choice between these strategies is not global. Different read models in the same system can use different strategies. The order summary that the customer sees after placement uses read-your-writes consistency. The analytics dashboard uses polling. The fulfilment queue uses synchronous projection because picking must not start before the read model confirms the order.

CQRS Without Event Sourcing

CQRS does not require event sourcing. A system that writes to a normalized orders table and projects to a denormalized order_summary table via PostgreSQL triggers or Debezium change data capture is a CQRS system. The write model is the normalized table. The read model is the denormalized table. The projection is the trigger or CDC pipeline.

This approach has advantages over event sourcing for certain use cases:

Lower operational complexity. No event store to manage. No event serialization. No aggregate replay. The write model is a standard relational schema that the team already knows how to operate.

Familiar tooling. Existing monitoring, backup, and migration tools work without modification. The read model rebuild is a materialized view refresh or a CDC replay, not an event stream replay.

Immediate consistency option. A PostgreSQL trigger updates the read model synchronously within the write transaction. There is no consistency gap. This is not possible with event sourcing unless the projection is synchronous, which defeats the scalability benefit.

The cost is that you lose the three capabilities event sourcing provides: the complete audit trail, the ability to reconstruct state at any point in time, and the ability to build new read models from historical events. If you need any of these, event sourcing is justified. If you do not, CQRS with a traditional write model and change data capture is the simpler, correct choice.

The decision rule: if your requirements include “show me what this entity looked like last Tuesday,” “replay all state changes for compliance,” or “build a new read model from historical data without a backfill migration,” you need event sourcing. If your requirements are “separate the write path from the read path for performance and scalability,” CQRS without event sourcing is sufficient.

This chapter established the separation between write and read models, the consistency trade-off, and the conditions under which CQRS alone is enough. The next chapter builds the write side in depth: aggregates that enforce invariants against an event stream.