Skip to main content
event sourcing and cqrs in practice

Events as the Source of Truth: Modeling Domain Events, Immutability, and What an Event Store Actually Is

17 min read Chapter 2 of 17

Events as the Source of Truth

Event stream timeline

An event stream is a time-ordered sequence of immutable facts. Each event records a state transition with its sequence number, payload, and timestamp. The current state of any aggregate is computed by replaying these events from left to right. The diagram shows a single order’s lifecycle as five events on a timeline.

A domain event is a fact. It is something that happened. It cannot be un-happened.

OrderPlaced is a fact. PaymentAuthorized is a fact. InventoryReserved is a fact. These events are not commands (requests to do something) and they are not queries (requests to know something). They are the record of what the system did, captured at the moment it happened, immutable from that point forward.

In a CRUD system, the database stores the current state of an entity. The orders table has a row for order 12345 with its current status, current total, current shipping address. The history of how order 12345 reached that state is lost. In an event-sourced system, the database stores the sequence of events that produced the current state. The current state is derived, not stored. The events are the source of truth.

This distinction has consequences that ripple through every layer of the system. The way you model events determines the quality of your audit trail. The way you serialize events determines whether you can evolve your schema. The way you store events determines your concurrency guarantees. This chapter establishes the foundations: what events look like, how they are serialized, and what the event store table looks like at the PostgreSQL level.

Modeling Domain Events

The Problem

In a CRUD system, a database row represents the current state of an entity. When a customer changes their shipping address, the shipping_address column is updated. The previous address is gone. If a developer later needs to know what address was on the order when fulfilment started, they must reconstruct that information from application logs, if those logs exist.

The deeper problem is that the CRUD model conflates two concerns: the record of what happened and the current state derived from what happened. These are different things with different lifecycles, different consumers, and different retention requirements.

The Mechanism

A domain event captures a single state transition as an immutable record. It answers three questions: what happened, to which entity, and when.

Events follow specific conventions:

Named in past tense. OrderPlaced, not PlaceOrder. The event records what already happened. A command (PlaceOrder) is a request that might be rejected. An event is a fact that has already been accepted.

Scoped to an aggregate. OrderPlaced belongs to the Order aggregate. PaymentAuthorized belongs to the Payment aggregate. An event never spans multiple aggregates. Cross-aggregate coordination happens through event publication and subscription, not through shared events.

Business-meaningful. OrderPlaced contains the order ID, the customer ID, the line items, the total, and the shipping address. It contains everything needed to understand what happened without querying another system. An event that contains only an entity ID and requires a lookup to understand is an anemic event. Anemic events defeat the purpose of event sourcing.

Immutable. Once stored, an event is never modified. If the business meaning of an event changes, a new event type is created. If an event was stored with incorrect data, a compensating event is appended to the stream. The original event remains.

The From-Scratch Implementation

The event model starts with a base interface and concrete event classes for the order domain.

// FROM SCRATCH
public sealed interface OrderEvent {
    String orderId();
    Instant occurredAt();
}

public record OrderPlaced(
    String orderId,
    String customerId,
    List<LineItem> lineItems,
    BigDecimal total,
    Address shippingAddress,
    Instant occurredAt
) implements OrderEvent {}

public record OrderConfirmed(
    String orderId,
    Instant occurredAt
) implements OrderEvent {}

public record PaymentAuthorized(
    String orderId,
    String paymentId,
    BigDecimal amount,
    String authorizationCode,
    Instant occurredAt
) implements OrderEvent {}

public record ShippingAddressChanged(
    String orderId,
    Address previousAddress,
    Address newAddress,
    String reason,
    Instant occurredAt
) implements OrderEvent {}

public record OrderCancelled(
    String orderId,
    String reason,
    String cancelledBy,
    Instant occurredAt
) implements OrderEvent {}

public record LineItem(
    String productId,
    String productName,
    int quantity,
    BigDecimal unitPrice
) {}

public record Address(
    String street,
    String city,
    String state,
    String postalCode,
    String country
) {}

Several things about this design are deliberate.

The sealed interface restricts which classes can implement OrderEvent. The compiler enforces exhaustive pattern matching when processing events. If a new event type is added and a switch expression does not handle it, the code does not compile. This is stronger than a visitor pattern and requires no framework.

Every event carries its own occurredAt timestamp. The event store also records a timestamp, but the domain event’s timestamp represents when the business event occurred, which may differ from when it was persisted. A batch import of historical events would have occurredAt in the past but a storage timestamp of now.

ShippingAddressChanged carries both the previous and new address. This is a design choice. The alternative is to carry only the new address and derive the previous address from the event stream. Carrying both makes the event self-describing: a consumer can understand the change without replaying earlier events. The trade-off is larger event payloads. For most domains, the clarity is worth the bytes.

What the Implementation Reveals

The sealed interface forces a decision at design time: which events belong to this aggregate? This seems obvious for OrderPlaced and OrderConfirmed. It becomes less obvious for RefundRequested. Does a refund belong to the Order aggregate or to a separate Refund aggregate? The sealed interface makes you answer this question upfront, and the answer has consequences for concurrency boundaries, transaction scope, and event stream size.

The record types are immutable by default in Java. No setters. No mutation. This is exactly what events require, but it means that event evolution (adding a field, renaming a field) requires careful handling. A record deserialized from an older JSON payload that lacks a new field will have null for that field unless the deserialization is configured to handle it. Chapter 12 covers this in depth.

The Production Path

In a Spring Boot application, the event model remains the same. Records work correctly with Jackson for serialization. The production consideration is ObjectMapper configuration.

// NAIVE - Default ObjectMapper
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
String json = mapper.writeValueAsString(event);

// PRODUCTION - Configured for event compatibility
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
String json = mapper.writeValueAsString(event);

The naive configuration will break when an event gains a new field and an older consumer encounters it. FAIL_ON_UNKNOWN_PROPERTIES = false is non-negotiable for event sourcing. Events are immutable but consumers evolve. A consumer running version N of the application must be able to read events written by version N+1 without crashing.

WRITE_DATES_AS_TIMESTAMPS = false serializes Instant as ISO-8601 strings instead of numeric timestamps. Numeric timestamps are ambiguous (seconds? milliseconds?) and unreadable in query results.

NON_NULL inclusion means that optional fields are omitted from the JSON rather than serialized as null. This makes additive schema changes backward-compatible: an older event without the new field produces JSON that simply lacks the key, which the deserializer handles by using the default value.

In a Spring Boot context, this ObjectMapper is configured as a @Bean and injected wherever event serialization occurs.

// PRODUCTION
@Configuration
public class EventSerializationConfig {

    @Bean
    @Qualifier("eventObjectMapper")
    public ObjectMapper eventObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
        return mapper;
    }
}

The @Qualifier annotation is deliberate. The application’s general-purpose ObjectMapper might have different settings (strict validation for API requests, for example). The event ObjectMapper is lenient by necessity. Mixing the two causes subtle bugs that surface months after deployment when an event schema changes.

The Test

// FROM SCRATCH
@Testcontainers
class EventSerializationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("event_store_test");

    private ObjectMapper mapper;

    @BeforeEach
    void setUp() {
        mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
    }

    @Test
    void eventRoundTripsThroughJson() throws Exception {
        var event = new OrderPlaced(
            "order-123",
            "customer-456",
            List.of(new LineItem("prod-1", "Widget", 2, new BigDecimal("19.99"))),
            new BigDecimal("39.98"),
            new Address("123 Main St", "Springfield", "IL", "62701", "US"),
            Instant.now()
        );

        String json = mapper.writeValueAsString(event);
        OrderPlaced deserialized = mapper.readValue(json, OrderPlaced.class);

        assertEquals(event.orderId(), deserialized.orderId());
        assertEquals(event.customerId(), deserialized.customerId());
        assertEquals(event.total(), deserialized.total());
        assertEquals(event.lineItems().size(), deserialized.lineItems().size());
    }

    @Test
    void unknownFieldsAreIgnoredDuringDeserialization() throws Exception {
        // Simulate a future version of OrderPlaced with an extra field
        String futureJson = """
            {
                "orderId": "order-123",
                "customerId": "customer-456",
                "lineItems": [],
                "total": 39.98,
                "shippingAddress": {
                    "street": "123 Main St",
                    "city": "Springfield",
                    "state": "IL",
                    "postalCode": "62701",
                    "country": "US"
                },
                "occurredAt": "2026-01-15T10:30:00Z",
                "loyaltyTier": "GOLD"
            }
            """;

        OrderPlaced deserialized = mapper.readValue(futureJson, OrderPlaced.class);
        assertEquals("order-123", deserialized.orderId());
        // loyaltyTier is silently ignored
    }
}

The second test is the important one. It verifies that a consumer can process an event produced by a newer version of the system. This forward compatibility is essential in any system where producers and consumers are deployed independently.

The Event Store Table

The Problem

Events need a storage mechanism that guarantees three properties: events within a stream are ordered, events are immutable once stored, and concurrent appends to the same stream are detected and handled. A general-purpose relational table does not enforce any of these properties without explicit constraints and design.

The Mechanism

The event store is a PostgreSQL table with a specific structure. Every column exists for a reason.

-- FROM SCRATCH
CREATE TABLE event_store (
    global_position BIGSERIAL PRIMARY KEY,
    stream_id       VARCHAR(255) NOT NULL,
    sequence_number BIGINT NOT NULL,
    event_type      VARCHAR(255) NOT NULL,
    payload         JSONB NOT NULL,
    metadata        JSONB DEFAULT '{}',
    occurred_at     TIMESTAMPTZ NOT NULL,
    stored_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_stream_sequence UNIQUE (stream_id, sequence_number)
);

CREATE INDEX idx_event_store_stream ON event_store (stream_id, sequence_number);
CREATE INDEX idx_event_store_type ON event_store (event_type);
CREATE INDEX idx_event_store_occurred ON event_store (occurred_at);

global_position is a monotonically increasing sequence across all streams. It defines the global ordering of all events in the store. Projections that consume events across multiple streams use this column to process events in the order they were stored.

stream_id identifies the aggregate instance. For the Order aggregate, this is order-{orderId}. Each aggregate instance has its own stream. Loading an aggregate means reading all events from its stream.

sequence_number is the position within a stream. The first event in a stream has sequence number 0. Each subsequent event increments by 1. The UNIQUE constraint on (stream_id, sequence_number) prevents duplicate events within a stream. This constraint is also the mechanism for optimistic concurrency control, which chapter 5 covers in depth.

event_type is the fully qualified class name or a string alias for the event type. This column is used for deserialization: the reader needs to know which class to deserialize the JSON payload into. Using a string alias (e.g., OrderPlaced instead of com.example.orders.events.OrderPlaced) decouples the stored event from the package structure. Renaming a package should not break event deserialization.

payload is the event data serialized as JSONB. JSONB instead of JSON because PostgreSQL can index inside JSONB and query it efficiently. This matters for ad-hoc debugging queries against the event store.

metadata stores cross-cutting concerns: correlation IDs, causation IDs, user IDs, tenant IDs. Separating metadata from the domain payload keeps the domain events clean and the operational data accessible.

occurred_at versus stored_at: the event’s business timestamp versus the storage timestamp. These differ during event replays, batch imports, and clock skew scenarios. Using occurred_at for business logic and stored_at for operational ordering prevents subtle bugs.

The From-Scratch Implementation

// FROM SCRATCH
public class JdbcEventStore {

    private final DataSource dataSource;
    private final ObjectMapper mapper;

    public JdbcEventStore(DataSource dataSource, ObjectMapper mapper) {
        this.dataSource = dataSource;
        this.mapper = mapper;
    }

    public void append(String streamId, long expectedSequence, List<OrderEvent> events) {
        String sql = """
            INSERT INTO event_store (stream_id, sequence_number, event_type, payload, metadata, occurred_at)
            VALUES (?, ?, ?, ?::jsonb, '{}'::jsonb, ?)
            """;

        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);
            try (PreparedStatement stmt = conn.prepareStatement(sql)) {
                long seq = expectedSequence;
                for (OrderEvent event : events) {
                    stmt.setString(1, streamId);
                    stmt.setLong(2, seq);
                    stmt.setString(3, event.getClass().getSimpleName());
                    stmt.setString(4, mapper.writeValueAsString(event));
                    stmt.setTimestamp(5, Timestamp.from(event.occurredAt()));
                    stmt.addBatch();
                    seq++;
                }
                stmt.executeBatch();
                conn.commit();
            } catch (SQLException e) {
                conn.rollback();
                if (isUniqueViolation(e)) {
                    throw new ConcurrentStreamModificationException(
                        "Stream " + streamId + " was modified concurrently at sequence " + expectedSequence
                    );
                }
                throw e;
            }
        } catch (SQLException | JsonProcessingException e) {
            throw new EventStoreException("Failed to append events to stream " + streamId, e);
        }
    }

    public List<StoredEvent> readStream(String streamId) {
        String sql = """
            SELECT global_position, stream_id, sequence_number, event_type, payload, metadata, occurred_at, stored_at
            FROM event_store
            WHERE stream_id = ?
            ORDER BY sequence_number ASC
            """;

        List<StoredEvent> events = new ArrayList<>();
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setString(1, streamId);
            try (ResultSet rs = stmt.executeQuery()) {
                while (rs.next()) {
                    events.add(new StoredEvent(
                        rs.getLong("global_position"),
                        rs.getString("stream_id"),
                        rs.getLong("sequence_number"),
                        rs.getString("event_type"),
                        rs.getString("payload"),
                        rs.getString("metadata"),
                        rs.getTimestamp("occurred_at").toInstant(),
                        rs.getTimestamp("stored_at").toInstant()
                    ));
                }
            }
        } catch (SQLException e) {
            throw new EventStoreException("Failed to read stream " + streamId, e);
        }
        return events;
    }

    private boolean isUniqueViolation(SQLException e) {
        return "23505".equals(e.getSQLState());
    }
}

public record StoredEvent(
    long globalPosition,
    String streamId,
    long sequenceNumber,
    String eventType,
    String payload,
    String metadata,
    Instant occurredAt,
    Instant storedAt
) {}

public class ConcurrentStreamModificationException extends RuntimeException {
    public ConcurrentStreamModificationException(String message) {
        super(message);
    }
}

public class EventStoreException extends RuntimeException {
    public EventStoreException(String message, Throwable cause) {
        super(message, cause);
    }
}

What the Implementation Reveals

The append method takes an expectedSequence parameter. This is the optimistic concurrency control. The caller states: “I believe the stream currently has N events, and I want to append starting at position N.” If another writer appended events between the caller’s read and write, the UNIQUE constraint on (stream_id, sequence_number) causes a constraint violation. The isUniqueViolation check detects this specific failure (PostgreSQL error code 23505) and throws a domain-specific exception.

The readStream method orders by sequence_number ASC. This is not optional. Events must be applied in order. A misordererd event stream produces an incorrect aggregate state. The index on (stream_id, sequence_number) ensures this query is efficient even for streams with thousands of events.

The StoredEvent record separates storage concerns from domain concerns. The domain events (OrderPlaced, OrderConfirmed) are the business model. The StoredEvent is the storage model. The caller deserializes the payload JSON into the appropriate domain event type using the eventType discriminator. This separation is deliberate: the storage layer does not depend on the domain model.

Batched inserts within a single transaction are important. If an aggregate produces three events from a single command (an order placement that triggers OrderPlaced, InventoryReservationRequested, and PaymentAuthorizationRequested), all three must be stored atomically. If two succeed and one fails, the stream is corrupted.

The Production Path

The Spring Boot version uses JdbcTemplate for event store operations. Spring Data JPA is deliberately not used for the event store. JPA’s entity model assumes mutable state with lifecycle callbacks. Events are immutable and append-only. The impedance mismatch between JPA and an append-only store creates more problems than it solves.

// PRODUCTION
@Repository
public class SpringEventStore {

    private final JdbcTemplate jdbc;
    private final ObjectMapper eventMapper;

    public SpringEventStore(JdbcTemplate jdbc,
                            @Qualifier("eventObjectMapper") ObjectMapper eventMapper) {
        this.jdbc = jdbc;
        this.eventMapper = eventMapper;
    }

    @Transactional
    public void append(String streamId, long expectedSequence, List<OrderEvent> events) {
        String sql = """
            INSERT INTO event_store (stream_id, sequence_number, event_type, payload, metadata, occurred_at)
            VALUES (?, ?, ?, ?::jsonb, '{}'::jsonb, ?)
            """;

        long seq = expectedSequence;
        for (OrderEvent event : events) {
            try {
                jdbc.update(sql,
                    streamId,
                    seq,
                    event.getClass().getSimpleName(),
                    eventMapper.writeValueAsString(event),
                    Timestamp.from(event.occurredAt())
                );
            } catch (DuplicateKeyException e) {
                throw new ConcurrentStreamModificationException(
                    "Stream " + streamId + " was modified concurrently at sequence " + expectedSequence
                );
            } catch (JsonProcessingException e) {
                throw new EventStoreException("Failed to serialize event", e);
            }
            seq++;
        }
    }

    public List<StoredEvent> readStream(String streamId) {
        return jdbc.query(
            """
            SELECT global_position, stream_id, sequence_number, event_type, payload, metadata, occurred_at, stored_at
            FROM event_store
            WHERE stream_id = ?
            ORDER BY sequence_number ASC
            """,
            (rs, rowNum) -> new StoredEvent(
                rs.getLong("global_position"),
                rs.getString("stream_id"),
                rs.getLong("sequence_number"),
                rs.getString("event_type"),
                rs.getString("payload"),
                rs.getString("metadata"),
                rs.getTimestamp("occurred_at").toInstant(),
                rs.getTimestamp("stored_at").toInstant()
            ),
            streamId
        );
    }
}

Spring’s @Transactional replaces the manual conn.setAutoCommit(false) and conn.commit(). Spring’s DuplicateKeyException replaces the manual SQL state check. The logic is identical. The boilerplate is reduced. The understanding of what the boilerplate was doing is what makes the production code trustworthy.

Axon Framework’s EventStore interface provides this functionality with additional features: automatic snapshotting, event upcasting, and subscription-based event processing. If you are using Axon, you do not write this class. But you configure Axon’s JpaEventStorageEngine or JdbcEventStorageEngine, and that configuration requires understanding what the storage engine does. The from-scratch implementation provides that understanding.

The Test

// FROM SCRATCH
@Testcontainers
class JdbcEventStoreTest {

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

    private JdbcEventStore eventStore;
    private ObjectMapper mapper;

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

        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);
    }

    @Test
    void appendAndReadEvents() {
        var placed = new OrderPlaced(
            "order-1", "customer-1",
            List.of(new LineItem("prod-1", "Widget", 2, new BigDecimal("19.99"))),
            new BigDecimal("39.98"),
            new Address("123 Main St", "Springfield", "IL", "62701", "US"),
            Instant.now()
        );

        var confirmed = new OrderConfirmed("order-1", Instant.now());

        eventStore.append("order-order-1", 0, List.of(placed, confirmed));

        List<StoredEvent> events = eventStore.readStream("order-order-1");
        assertEquals(2, events.size());
        assertEquals("OrderPlaced", events.get(0).eventType());
        assertEquals("OrderConfirmed", events.get(1).eventType());
        assertEquals(0, events.get(0).sequenceNumber());
        assertEquals(1, events.get(1).sequenceNumber());
        assertTrue(events.get(0).globalPosition() < events.get(1).globalPosition());
    }

    @Test
    void concurrentAppendToSameStreamFails() {
        var event1 = new OrderPlaced(
            "order-2", "customer-1", List.of(), BigDecimal.ZERO,
            new Address("1 St", "City", "ST", "00000", "US"), Instant.now()
        );

        eventStore.append("order-order-2", 0, List.of(event1));

        var event2 = new OrderConfirmed("order-2", Instant.now());
        var event3 = new OrderConfirmed("order-2", Instant.now());

        // First append at sequence 1 succeeds
        eventStore.append("order-order-2", 1, List.of(event2));

        // Second append at sequence 1 fails: conflict
        assertThrows(ConcurrentStreamModificationException.class, () ->
            eventStore.append("order-order-2", 1, List.of(event3))
        );
    }

    @Test
    void eventsAreOrderedBySequenceNumber() {
        var events = new ArrayList<OrderEvent>();
        for (int i = 0; i < 100; i++) {
            events.add(new OrderConfirmed("order-3", Instant.now()));
        }

        eventStore.append("order-order-3", 0, events);

        List<StoredEvent> stored = eventStore.readStream("order-order-3");
        assertEquals(100, stored.size());
        for (int i = 0; i < 100; i++) {
            assertEquals(i, stored.get(i).sequenceNumber());
        }
    }
}

The concurrency test is the critical one. It verifies that two writers attempting to append at the same sequence number result in one success and one failure. This is the foundation of optimistic concurrency control. Without this guarantee, concurrent commands against the same aggregate can produce an inconsistent event stream.

The ordering test verifies that 100 events appended in a single batch are read back in sequence order. This seems trivial, but bugs in event ordering produce aggregate state corruption that is extremely difficult to diagnose in production.

Event Type Registration

Domain events need a mapping between their string representation (stored in the event_type column) and their Java class. This mapping must be maintained as new event types are added.

// FROM SCRATCH
public class EventTypeRegistry {

    private final Map<String, Class<? extends OrderEvent>> typeMap = new HashMap<>();
    private final ObjectMapper mapper;

    public EventTypeRegistry(ObjectMapper mapper) {
        this.mapper = mapper;
        register("OrderPlaced", OrderPlaced.class);
        register("OrderConfirmed", OrderConfirmed.class);
        register("PaymentAuthorized", PaymentAuthorized.class);
        register("ShippingAddressChanged", ShippingAddressChanged.class);
        register("OrderCancelled", OrderCancelled.class);
    }

    private void register(String typeName, Class<? extends OrderEvent> clazz) {
        typeMap.put(typeName, clazz);
    }

    public OrderEvent deserialize(String eventType, String payload) {
        Class<? extends OrderEvent> clazz = typeMap.get(eventType);
        if (clazz == null) {
            throw new UnknownEventTypeException("No class registered for event type: " + eventType);
        }
        try {
            return mapper.readValue(payload, clazz);
        } catch (JsonProcessingException e) {
            throw new EventDeserializationException(
                "Failed to deserialize event of type " + eventType, e
            );
        }
    }
}

The registry is explicit rather than reflective. A reflection-based approach that scans the classpath for OrderEvent implementations is fragile: it breaks when events are refactored into different packages, it depends on classpath ordering, and it is difficult to test. An explicit registry fails fast when an event type is missing and makes the set of known event types visible in code.

The production version of this registry can use Spring’s @Component scanning to auto-register event types, but the principle remains: every event type must have an explicit mapping, and an unmapped event type must fail loudly, not silently.

This chapter established the event model, the serialization strategy, and the event store schema. The next chapter separates the write model from the read model and introduces the consistency trade-off that defines CQRS.