Back to Blog
spring-bootevent-sourcingaxonkafkaarchitecturejava

Event Sourcing with Spring Boot: Build an Audit Trail That Never Lies (2026)

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.

J

JOptimize Team

May 25, 2026· 10 min read

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.


The Core Idea

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.


When Event Sourcing Is Worth It

Good fit:

  • Finance and payments (full audit trail required by regulation)
  • Order management (customers ask "what happened to my order?")
  • Collaborative tools (undo/redo history)
  • Complex business domains with many state transitions

Bad fit:

  • Simple CRUD (user profiles, product catalog without history needs)
  • High-frequency updates to single aggregates (every write adds an event — log tables get huge)
  • Teams new to DDD and event-driven patterns (high learning curve)

Implementation: Manual Event Store

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

Flyway Migration for Event Store

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

CQRS: Separate Read Models

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.


Common Mistakes to Avoid

  • Storing mutable objects in events — events are immutable; once stored, never modify them; schema changes require versioned event types
  • No snapshot mechanism — for aggregates with thousands of events, rebuild time grows; take periodic snapshots at event 1000, 2000, etc.
  • Projections not idempotent — if a projection handler processes the same event twice (on replay or retry), it should produce the same result; use upsert not insert
  • Skipping the read model — using the event store directly for queries is slow; always build projections optimized for the read patterns

Summary

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.


Analyze Your Domain Architecture

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.