- Published on
- · Updated
Event Sourcing with Spring Boot and PostgreSQL: Event Store, Projections, and Kafka
- Authors

- Name
- Maria
Event sourcing stores an aggregate's state changes as an ordered stream of immutable domain events. The current state is reconstructed by replaying that stream, optionally starting from a snapshot.
That is a larger commitment than publishing Kafka messages from a CRUD service. The event format becomes long-lived source data, every command must handle concurrent stream versions, and read models must be rebuildable from history.
This guide builds the core mechanics with Spring Boot and PostgreSQL:
- an append-only event table;
- optimistic concurrency per aggregate stream;
- deterministic rehydration;
- versioned event payloads;
- snapshots as an optimization;
- idempotent projections;
- transactional publication to Kafka through an outbox.
Version note
The design was reviewed against Spring Boot 4.1, Hibernate ORM 7.1, PostgreSQL 18, current Apache Kafka documentation, and current Axon Framework concepts on August 10, 2026.
TL;DR
- Use event sourcing only when historical state, auditability, temporal reasoning, or complex aggregate behavior justifies the operational cost.
- Enforce one monotonically increasing version per aggregate stream with a database constraint and compare-and-set update.
- Keep event application pure and deterministic; no network calls, current time lookups, or random values during replay.
- Version serialized events and keep upcasters for every historical shape that must still replay.
- Treat snapshots as disposable acceleration data, not replacements for events.
- Store a Kafka outbox row in the same transaction as the event append. Publishing directly creates a dual-write gap.
Event sourcing is not the same as event-driven architecture
These terms are related but independent.
| Pattern | Source of truth | Main purpose |
|---|---|---|
| CRUD plus events | Current rows | Notify other components about changes |
| Audit log | Current rows | Record who changed what |
| Event sourcing | Ordered event streams | Reconstruct state and make decisions from history |
| CQRS | Separate command and query models | Optimize different consistency and data-shape needs |
An application can publish Kafka events without being event-sourced. It can also use event sourcing without Kafka by storing events and updating projections inside one process.
In an event-sourced aggregate, deleting or editing a historical event changes the source of truth. Corrections are normally represented by new compensating events, while serialization migrations are handled through explicit version transformation.
Decide whether the tradeoff is justified
Event sourcing is a good candidate when the domain needs several of these capabilities:
- reconstruct state at a prior point in time;
- explain how a decision was reached;
- rebuild new projections from old facts;
- model behavior as meaningful domain transitions;
- preserve a legally and operationally useful history;
- test business rules as event sequences;
- support multiple read models with different shapes.
It is usually a poor fit when:
- the service is straightforward reference-data CRUD;
- historical event contracts cannot be maintained;
- the team lacks projection and replay operations;
- low-latency cross-aggregate transactions dominate the model;
- deleting personal data from immutable history has no approved design;
- a normal audit table already satisfies the requirement.
Do not adopt it only because “Kafka is already available.” Kafka storage, ordering, compaction, and retention can support event-log designs, but aggregate concurrency, point stream reads, snapshots, and transactional command handling still need deliberate implementation.
Model one ordered stream per aggregate
An aggregate stream is identified by type and ID:
(aggregate_type = Order, aggregate_id = 7d...)
version 1: OrderPlaced
version 2: OrderItemAdded
version 3: ShippingAddressChanged
version 4: OrderConfirmed
The stream version is not an event schema version:
stream_versionorders facts for one aggregate and detects concurrent commands;event_versionidentifies the serialized payload shape for one event type.
Keep both.
Create a PostgreSQL event-store schema
A separate stream row provides a compare-and-set concurrency gate:
create table aggregate_stream (
aggregate_type varchar(100) not null,
aggregate_id uuid not null,
current_version bigint not null,
updated_at timestamptz not null,
primary key (aggregate_type, aggregate_id),
constraint ck_stream_version_nonnegative
check (current_version >= 0)
);
create table domain_event (
event_id uuid primary key,
aggregate_type varchar(100) not null,
aggregate_id uuid not null,
stream_version bigint not null,
event_type varchar(150) not null,
event_version integer not null,
payload jsonb not null,
metadata jsonb not null,
occurred_at timestamptz not null,
recorded_at timestamptz not null default now(),
causation_id uuid,
correlation_id uuid,
unique (aggregate_type, aggregate_id, stream_version),
constraint fk_domain_event_stream
foreign key (aggregate_type, aggregate_id)
references aggregate_stream (aggregate_type, aggregate_id),
constraint ck_event_versions_positive
check (stream_version > 0 and event_version > 0)
);
create index ix_domain_event_recorded
on domain_event (recorded_at, event_id);
The primary access path is already covered by the unique stream-version index. Do not add broad GIN indexes to every JSON payload unless a measured operational query needs them. Normal application queries should use projections rather than scan the event store.
occurred_at belongs to the domain event. recorded_at is when PostgreSQL stored it. They can differ for imports or offline processes.
Metadata can carry bounded technical context such as actor type, trace ID, tenant ID, command ID, and producer version. Do not copy authentication tokens or entire HTTP headers.
Append with optimistic concurrency
Every command reads an aggregate at an expected stream version. The append succeeds only if no other command has advanced that stream.
Represent new events without assigning stream versions in domain code:
public record NewDomainEvent(
UUID eventId,
String eventType,
int eventVersion,
JsonNode payload,
JsonNode metadata,
Instant occurredAt,
UUID causationId,
UUID correlationId
) {}
public record StreamId(String aggregateType, UUID aggregateId) {}
Use one PostgreSQL transaction for stream advancement and event inserts:
@Repository
public class JdbcEventStore {
private final JdbcTemplate jdbc;
public JdbcEventStore(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public long append(
StreamId stream,
long expectedVersion,
List<NewDomainEvent> events
) {
if (events.isEmpty()) {
return expectedVersion;
}
jdbc.update("""
insert into aggregate_stream (
aggregate_type, aggregate_id, current_version, updated_at
) values (?, ?, 0, now())
on conflict (aggregate_type, aggregate_id) do nothing
""",
stream.aggregateType(),
stream.aggregateId()
);
long newVersion = Math.addExact(expectedVersion, events.size());
int advanced = jdbc.update("""
update aggregate_stream
set current_version = ?, updated_at = now()
where aggregate_type = ?
and aggregate_id = ?
and current_version = ?
""",
newVersion,
stream.aggregateType(),
stream.aggregateId(),
expectedVersion
);
if (advanced != 1) {
throw new ConcurrentStreamWrite(
stream,
expectedVersion
);
}
int index = 0;
for (NewDomainEvent event : events) {
long streamVersion = expectedVersion + ++index;
jdbc.update("""
insert into domain_event (
event_id,
aggregate_type,
aggregate_id,
stream_version,
event_type,
event_version,
payload,
metadata,
occurred_at,
causation_id,
correlation_id
) values (?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?)
""",
event.eventId(),
stream.aggregateType(),
stream.aggregateId(),
streamVersion,
event.eventType(),
event.eventVersion(),
event.payload().toString(),
event.metadata().toString(),
event.occurredAt(),
event.causationId(),
event.correlationId()
);
}
return newVersion;
}
}
For a new aggregate, expectedVersion is zero. INSERT ... ON CONFLICT DO NOTHING ensures a stream row exists, then the conditional update decides which concurrent writer wins. If event insertion fails, PostgreSQL rolls back the stream version too.
The unique (aggregate_type, aggregate_id, stream_version) constraint is a second line of defense. Application checks alone cannot enforce concurrency across instances.
For throughput, batch the event inserts after the conditional stream update. Preserve explicit versions and keep all statements inside the same transaction.
Rehydrate an aggregate deterministically
Load the stream in version order:
public List<StoredEvent> load(StreamId stream, long afterVersion) {
return jdbc.query("""
select event_id,
stream_version,
event_type,
event_version,
payload,
metadata,
occurred_at,
recorded_at
from domain_event
where aggregate_type = ?
and aggregate_id = ?
and stream_version > ?
order by stream_version
""",
storedEventRowMapper,
stream.aggregateType(),
stream.aggregateId(),
afterVersion
);
}
An aggregate applies historical events to rebuild state:
public final class Order {
private UUID id;
private OrderStatus status;
private final List<OrderLine> lines = new ArrayList<>();
private long version;
private final List<Object> uncommitted = new ArrayList<>();
public static Order rehydrate(List<Object> history) {
Order order = new Order();
history.forEach(order::applyHistorical);
return order;
}
public void addItem(UUID productId, int quantity, Money unitPrice) {
if (status != OrderStatus.DRAFT) {
throw new OrderAlreadyConfirmed(id);
}
if (quantity <= 0) {
throw new InvalidQuantity(quantity);
}
raise(new OrderItemAdded(id, productId, quantity, unitPrice));
}
private void raise(Object event) {
apply(event);
uncommitted.add(event);
}
private void applyHistorical(Object event) {
apply(event);
version++;
}
private void apply(Object event) {
switch (event) {
case OrderPlaced placed -> {
id = placed.orderId();
status = OrderStatus.DRAFT;
}
case OrderItemAdded added -> lines.add(
new OrderLine(
added.productId(),
added.quantity(),
added.unitPrice()
)
);
case OrderConfirmed ignored -> status = OrderStatus.CONFIRMED;
default -> throw new UnsupportedOrderEvent(event.getClass());
}
}
}
Event application must be deterministic. Do not call Instant.now(), generate a UUID, query another service, send email, or read mutable configuration while replaying. Put every value required to reproduce the state in the event.
Command handling can use time and random values, but it must capture the chosen values in the new event before append.
Separate domain events from serialized records
Java class names are poor long-term contracts. Packages are renamed, classes are split, and fields evolve.
Store stable names:
event_type = OrderItemAdded
event_version = 2
At the storage boundary:
- Read
event_typeandevent_version. - Upcast old JSON to the current logical shape.
- Deserialize into the current domain event type.
- Apply it to the aggregate.
Example transformation:
public JsonNode upcastOrderItemAdded(int version, JsonNode payload) {
return switch (version) {
case 1 -> ((ObjectNode) payload.deepCopy())
.put("currency", "USD");
case 2 -> payload;
default -> throw new UnsupportedEventVersion(
"OrderItemAdded",
version
);
};
}
A hard-coded default such as USD is valid only if historical business rules prove it. Otherwise enrich from an immutable historical source or create a different migration strategy.
Keep replay fixtures containing the oldest supported event shapes. A deployment that can write new events but cannot replay old ones is not a valid event-store deployment.
Axon Framework formalizes this concept with revision metadata and upcaster chains. Even when building a small custom store, its versioning guidance is useful: events are retained, so applications must continue to understand old representations.
Use snapshots only after measuring replay cost
A snapshot stores aggregate state at a specific stream version:
create table aggregate_snapshot (
aggregate_type varchar(100) not null,
aggregate_id uuid not null,
stream_version bigint not null,
snapshot_version integer not null,
state jsonb not null,
created_at timestamptz not null default now(),
primary key (aggregate_type, aggregate_id, stream_version)
);
create index ix_aggregate_snapshot_latest
on aggregate_snapshot (
aggregate_type,
aggregate_id,
stream_version desc
);
Loading becomes:
- Read the latest compatible snapshot.
- Restore aggregate state at its version.
- Replay events after that version.
Snapshots are derived data. Keep the underlying events if the system promises full temporal replay or projection rebuilds. If a snapshot format becomes incompatible, discard it and create a new one from events.
Create snapshots based on measured stream length or load duration, not on every write. Snapshotting itself adds serialization, storage, and race conditions. Include the stream version and ignore a snapshot that is newer than the event boundary being reconstructed.
Build idempotent projections
Read models are normally eventually consistent with the command stream. A projector consumes events and updates a query-friendly table:
create table order_summary (
order_id uuid primary key,
customer_id uuid not null,
status varchar(30) not null,
item_count integer not null,
total_amount numeric(19, 2) not null,
currency char(3) not null,
stream_version bigint not null
);
create table projection_event (
projection_name varchar(100) not null,
event_id uuid not null,
processed_at timestamptz not null default now(),
primary key (projection_name, event_id)
);
Insert the projection marker and update the read model in one transaction. If the same event is delivered again, the unique marker prevents a second non-idempotent effect.
For a latest-state projection, also compare stream_version:
update order_summary
set status = :status,
item_count = :item_count,
total_amount = :total_amount,
stream_version = :new_version
where order_id = :order_id
and stream_version = :expected_version;
Zero updated rows indicate a gap, duplicate, or out-of-order delivery. Do not silently overwrite a newer projection with older data.
Expose projection lag and rebuild status to operators. A successful command response does not mean every read model is already current.
Publish to Kafka through the same database transaction
Appending events to PostgreSQL and directly calling Kafka is another dual write. One can succeed while the other fails.
Insert an outbox row beside each stored event in the event-store transaction:
transaction
- compare and advance stream version
- insert domain_event rows
- insert outbox_event rows with the same event IDs
commit
A relay publishes outbox rows to Kafka at least once. Use:
aggregate_idas the Kafka key when per-aggregate order matters;- the same
event_idon every publication attempt; - stable external event names and versions;
- idempotent consumers;
- relay and consumer lag monitoring.
PostgreSQL remains the source of truth for command-side aggregate streams in this design. Kafka is the integration and projection distribution log. That separation makes point stream reads and optimistic writes explicit while still supporting scalable consumers.
The transactional outbox implementation guide covers polling and Debezium relays. The idempotent consumer guide covers the downstream transaction.
Handle cross-aggregate workflows explicitly
Optimistic stream concurrency protects one aggregate. A business rule spanning many aggregates cannot simply lock all event streams indefinitely.
Options include:
- redesigning the consistency boundary;
- reserving a scarce resource in its owning aggregate;
- a process manager or saga reacting to events;
- compensating events for a later failure;
- a PostgreSQL transaction that appends to several streams in a deterministic lock order when the boundaries truly belong together.
Multi-stream append increases contention and deadlock risk. If used, lock or conditionally advance stream rows in a stable order and ensure the entire set of inserts is one transaction.
Do not call an eventually consistent projection to validate a strong command invariant. It may be behind the event stream.
Protect personal and regulated data
Immutable history complicates correction and deletion requirements. Address this before writing real customer data:
- store stable subject IDs rather than duplicating personal fields in every event;
- keep sensitive attributes in a separately governed store when possible;
- encrypt selected payload fields with managed, rotatable keys;
- minimize metadata and payload content;
- define retention, legal hold, export, and deletion procedures;
- restrict event-store and Kafka access separately;
- prevent payloads from leaking into logs and dead-letter records.
Cryptographic erasure or tokenization can be part of a design, but whether it satisfies a specific obligation is a compliance decision, not a generic technical guarantee.
Operate the event store as critical data
Monitor:
- append latency and concurrency-conflict rate;
- events per stream and streams requiring snapshots;
- replay duration by aggregate type;
- projector lag and failed event ID;
- outbox backlog age;
- unknown event type or version failures;
- event and snapshot table growth;
- backup, restore, and point-in-time recovery status;
- integrity gaps in stream versions.
Back up the event store and test restoration together with projection rebuilds and Kafka publication checkpoints. A database backup that restores rows but leaves every projector and relay at an incompatible position is not a complete recovery.
For very large stores, PostgreSQL partitioning can improve retention and maintenance, but unique constraints on partitioned tables must include the partition key. That can conflict with global event_id or per-stream-version uniqueness. Model those invariants before partitioning rather than weakening them accidentally.
Test with event histories
Aggregate tests naturally follow given-when-then:
Given: OrderPlaced, OrderItemAdded
When: ConfirmOrder
Then: OrderConfirmed
Test at least:
- Every command rule from representative event histories.
- Replay from the oldest serialized event version.
- Two concurrent appends with the same expected version; exactly one wins.
- A multi-event append rolls back completely on one insert failure.
- Snapshot plus tail replay equals full replay.
- A projection can rebuild from an empty database.
- Duplicate and out-of-order projection delivery is safe.
- Outbox replay preserves event IDs and order per aggregate.
- Unknown event types and versions stop visibly rather than being skipped.
- Backup restoration can resume projectors and relays without gaps.
Use Testcontainers or another real PostgreSQL environment for constraints, transactions, JSON mapping, and concurrent append tests.
Build or use a framework?
A custom store can be reasonable for a small number of aggregates and a deliberately narrow feature set. It becomes a framework project once it needs:
- upcaster chains;
- snapshot scheduling and compatibility filters;
- subscription positions and replay controls;
- dead-letter processing;
- sagas or process managers;
- aggregate repositories and unit-of-work behavior;
- distributed command routing;
- schema management and observability tooling.
Axon Framework and dedicated event-store products already model many of these concerns. Evaluate them before expanding a custom JDBC repository into infrastructure that the team must maintain indefinitely.
Common mistakes
Using events as a second audit table
If current-state tables remain authoritative and events cannot rebuild them, the system is not event-sourced. That may be perfectly fine; call it an audit log or integration log.
Omitting the expected stream version
Last-write-wins event appends can accept two commands based on stale state and violate aggregate invariants.
Mutating historical payloads in place
It changes past meaning and makes backup, replay, and audit behavior inconsistent. Upcast on read or append a correction event.
Putting side effects in event application
Replay sends emails, calls APIs, or generates different state. Keep apply deterministic and side-effect free.
Treating Kafka as a free event store
Retention, compaction, partitioning, aggregate lookup, concurrency, and snapshots still need a complete design.
Deleting events after a snapshot without changing the guarantee
A snapshot accelerates current-state loading. It does not preserve arbitrary historical reconstruction when prior events are removed.
Rebuilding a projection that sends notifications
Projection replay must not repeat irreversible business side effects. Separate state reconstruction from external actions.
Production checklist
- Is event sourcing justified by domain and historical requirements?
- Is every stream append guarded by an expected version and database constraint?
- Are event type and serialization version stable and explicit?
- Can every retained event still be upcast and replayed?
- Is event application deterministic?
- Are snapshots disposable and verified against full replay?
- Can projections rebuild and tolerate duplicate or out-of-order delivery?
- Does Kafka publication use an outbox in the append transaction?
- Are personal-data handling and deletion requirements approved?
- Have concurrency, replay, backup restore, and unknown-version failures been rehearsed?
The core of event sourcing is not the event class or Kafka topic. It is the permanent contract between an ordered stream, deterministic domain behavior, and every future version of the application that must still understand the past.