Event sourcing stores state as a sequence of events instead of current values. Learn how to implement it with Spring Boot, the trade-offs versus CRUD, and when it's actually worth the complexity.
JOptimize Team
In a traditional CRUD system, when an order status changes from PENDING to SHIPPED, you run UPDATE orders SET status='SHIPPED'. The history is gone. You know the current state but not how you got there — or why.
Event sourcing inverts this: instead of storing the current state, you store the sequence of events that produced it. The current state is derived by replaying events.
CRUD approach:
orders table: { id: 1, status: "SHIPPED", total: 150.00 }
← History lost
Event sourcing approach:
events table:
{ orderId: 1, type: "OrderCreated", data: { total: 150.00 }, ts: 10:00 }
{ orderId: 1, type: "OrderPaid", data: { method: "CARD" }, ts: 10:01 }
{ orderId: 1, type: "OrderShipped", data: { carrier: "DHL" }, ts: 14:30 }
← Full history preserved
Current state = replay all events for orderId=1 in order. You get both the current state AND the complete history for free.
Good fit:
Bad fit:
// Event base class public abstract class DomainEvent { private final String aggregateId; private final String eventType; private final LocalDateTime occurredAt; private final long sequenceNumber; protected DomainEvent(String aggregateId, long sequenceNumber) { this.aggregateId = aggregateId; this.eventType = getClass().getSimpleName(); this.occurredAt = LocalDateTime.now(); this.sequenceNumber = sequenceNumber; } } // Domain events public class OrderCreated extends DomainEvent { private final Long customerId; private final BigDecimal total; private final String currency; // ... constructor, getters } public class OrderPaid extends DomainEvent { private final String paymentMethod; private final String transactionId; } public class OrderShipped extends DomainEvent { private final String carrier; private final String trackingNumber; }
// Order aggregate — derives state from events public class Order { private Long id; private Long customerId; private BigDecimal total; private OrderStatus status; private List<DomainEvent> uncommittedEvents = new ArrayList<>(); private long version = 0; // Apply events to rebuild state public void apply(OrderCreated event) { this.customerId = event.getCustomerId(); this.total = event.getTotal(); this.status = OrderStatus.PENDING; this.version++; } public void apply(OrderPaid event) { this.status = OrderStatus.PAID; this.version++; } public void apply(OrderShipped event) { if (status != OrderStatus.PAID) { throw new IllegalStateException("Order must be paid before shipping"); } this.status = OrderStatus.SHIPPED; this.version++; } // Command methods — create events, apply them public void ship(String carrier, String tracking) { OrderShipped event = new OrderShipped(carrier, tracking, this.id, this.version + 1); apply(event); uncommittedEvents.add(event); } // Rebuild from event history public static Order reconstitute(List<DomainEvent> events) { Order order = new Order(); events.forEach(e -> { if (e instanceof OrderCreated oc) order.apply(oc); else if (e instanceof OrderPaid op) order.apply(op); else if (e instanceof OrderShipped os) order.apply(os); }); return order; } }
// Event store repository @Repository @RequiredArgsConstructor public class OrderEventStore { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; public void save(String aggregateId, List<DomainEvent> events, long expectedVersion) { // Optimistic concurrency check Long currentVersion = jdbc.queryForObject( "SELECT MAX(sequence_number) FROM events WHERE aggregate_id = ?", Long.class, aggregateId); if (currentVersion != null && currentVersion != expectedVersion) { throw new OptimisticConcurrencyException( "Expected version " + expectedVersion + " but was " + currentVersion); } events.forEach(event -> jdbc.update( "INSERT INTO events(aggregate_id, event_type, data, occurred_at, sequence_number) VALUES(?,?,?,?,?)", aggregateId, event.getEventType(), serialize(event), event.getOccurredAt(), event.getSequenceNumber() )); } public Order load(String aggregateId) { List<DomainEvent> events = jdbc.query( "SELECT * FROM events WHERE aggregate_id = ? ORDER BY sequence_number", this::mapEvent, aggregateId); if (events.isEmpty()) throw new OrderNotFoundException(aggregateId); return Order.reconstitute(events); } }
CREATE TABLE events ( id BIGSERIAL PRIMARY KEY, aggregate_id VARCHAR(100) NOT NULL, event_type VARCHAR(100) NOT NULL, data JSONB NOT NULL, occurred_at TIMESTAMP NOT NULL, sequence_number BIGINT NOT NULL, UNIQUE (aggregate_id, sequence_number) -- Optimistic concurrency ); CREATE INDEX idx_events_aggregate ON events(aggregate_id, sequence_number);
Replaying events on every read is too slow for queries. Build read-optimized projections:
@Component @RequiredArgsConstructor public class OrderProjection { private final OrderSummaryRepository summaryRepo; // Listen to events published after each command @EventListener(OrderCreated.class) public void on(OrderCreated event) { summaryRepo.save(new OrderSummary( event.getAggregateId(), event.getCustomerId(), event.getTotal(), "PENDING" )); } @EventListener(OrderShipped.class) public void on(OrderShipped event) { summaryRepo.updateStatus(event.getAggregateId(), "SHIPPED", event.getTrackingNumber()); } }
The read model (order_summaries table) is always up to date and optimized for queries. If you need a different view, add a new projection and replay all events to build it.
Event sourcing stores state as an immutable sequence of events. This gives you a complete audit trail, temporal queries ("what was the state at time T?"), and the ability to rebuild any read model from scratch. The complexity cost is real — it's not for simple CRUD. But for domains where history matters (finance, orders, compliance), it's the most robust architecture available.
JOptimize reviews your service layer for missing audit trails, mutable state that should be event-sourced, and domain events that are being lost in CRUD updates.
Build systems that never lose state history — free architecture scan.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.