Event Sourcing Pattern in Microservices: Store What Happened, Not Just the Result
The event sourcing pattern stores every change to your data as an event in an append-only log, instead of overwriting a row with the latest values. To find the current state, you replay the events from the beginning. Your database stops answering only "what is true now?" and can also answer "how did it get like this?".
Banks, ledgers, version control systems and shipping trackers have worked this way for a long time. In microservices, event sourcing gives each service a full audit trail and a natural stream of events that other services can react to.
1. The Problem With Overwriting Data
Think of a prepaid metro card. A typical service keeps one row per card: card_id, owner, balance. When a passenger passes a gate, the service runs UPDATE cards SET balance = 3.90. The old balance is gone.
Now a passenger complains: "I topped up €10 this morning and my card is almost empty." The row says 3.90, and that's all it can say. Was a fare charged twice? Did the top-up fail? You can add a history table, but it is a side copy: nothing forces it to match the real balance, and sooner or later a bug lets them drift apart.
Event sourcing turns this around. The history is the data. The service never stores "balance = 3.90". It stores CardToppedUp 10.00, FareCharged 2.70, FareCharged 3.40, and the balance is calculated from them. Because the balance comes from the history, the two can never disagree.
2. Key Terms
- Event: a record of something that has already happened, named in the past tense, such as
FareCharged. Events are never changed or deleted. - Stream: all events that belong to one thing, here one card. Each stream has an id like
TC-1001. - Event store: the database that keeps streams. It only supports appending events and reading them back in order. It can be a dedicated product like EventStoreDB or Axon Server, or an ordinary table in PostgreSQL.
- Aggregate: the object whose rules you protect, rebuilt from its stream, here the card. It decides whether a new event is allowed.
- Command: a request to change something, such as "charge a fare". A command can be rejected; an event cannot, because it already happened.
- Projection (read model): a separate view built from events and shaped for queries, such as "revenue per station".
- Snapshot: a saved copy of an aggregate's state at some version, so replay can start from there instead of from event 1.
3. How the Event Sourcing Pattern Works
- A command arrives, for example "charge €2.70 at Central station to card TC-1001".
- The handler loads the card's stream and replays it to rebuild the current state.
- It checks the rules (is the card blocked? is the balance high enough?) and, if they pass, appends one or more new events. Nothing is updated in place.
- The new events are delivered to projectors, usually asynchronously.
- Projectors update read models: a balance table for the mobile app, revenue figures for finance.
- Queries read the read models, never the raw event stream.
state = apply(state, event). The apply method must not make decisions or call other services; it only records what the event says. All decisions happen before the event is written. That is why replaying an old event years later always gives the same result, even if the business rules have changed since.Reading from read models instead of the stream is the CQRS pattern: separate models for writing and reading. Event sourcing and CQRS are often used together, but CQRS works without event sourcing too.
4. Code Examples
These are JDK-only Java 21 programs that show what frameworks like Axon do for you.
4.1 Example 1: Rebuilding State by Replaying Events
A card's events go into a list that is only ever appended to. The current state, and any earlier state, is rebuilt by replay.
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
public class TransitCardDemo {
// Events are facts that already happened, so they are named in the past tense
sealed interface CardEvent permits CardIssued, CardToppedUp, FareCharged, CardBlocked {
Instant at();
}
record CardIssued(String cardId, String owner, Instant at) implements CardEvent {}
record CardToppedUp(int cents, Instant at) implements CardEvent {}
record FareCharged(String station, int cents, Instant at) implements CardEvent {}
record CardBlocked(String reason, Instant at) implements CardEvent {}
// The current state is derived from the events, never saved on its own
record Card(String id, String owner, int balanceCents, boolean blocked, int version) {
static final Card EMPTY = new Card(null, null, 0, false, 0);
Card apply(CardEvent e) {
return switch (e) {
case CardIssued i -> new Card(i.cardId(), i.owner(), 0, false, version + 1);
case CardToppedUp t -> new Card(id, owner, balanceCents + t.cents(), blocked, version + 1);
case FareCharged f -> new Card(id, owner, balanceCents - f.cents(), blocked, version + 1);
case CardBlocked b -> new Card(id, owner, balanceCents, true, version + 1);
};
}
}
static Card replay(List<CardEvent> events) {
Card card = Card.EMPTY;
for (CardEvent e : events) {
card = card.apply(e); // left fold: state = apply(state, event)
}
return card;
}
// A command checks the rules against the current state and returns a new event
static FareCharged chargeFare(Card card, String station, int cents, Instant at) {
if (card.blocked()) throw new IllegalStateException("card is blocked");
if (card.balanceCents() < cents) throw new IllegalStateException("balance too low: " + money(card.balanceCents()));
return new FareCharged(station, cents, at);
}
static String money(int cents) {
return String.format("%d.%02d", cents / 100, cents % 100);
}
static String describe(CardEvent e) {
String time = e.at().atZone(ZoneOffset.UTC).toLocalTime().toString();
return time + " " + switch (e) {
case CardIssued i -> "CardIssued owner=" + i.owner();
case CardToppedUp t -> "CardToppedUp +" + money(t.cents());
case FareCharged f -> "FareCharged -" + money(f.cents()) + " at " + f.station();
case CardBlocked b -> "CardBlocked " + b.reason();
};
}
static String summary(Card c) {
return "balance=" + money(c.balanceCents()) + ", blocked=" + c.blocked() + ", version=" + c.version();
}
public static void main(String[] args) {
List<CardEvent> stream = new ArrayList<>(); // this card's stream: we only ever append
Instant t = Instant.parse("2026-03-02T08:00:00Z");
stream.add(new CardIssued("TC-1001", "Asha", t));
stream.add(new CardToppedUp(1000, t.plusSeconds(60)));
stream.add(chargeFare(replay(stream), "Central", 270, t.plusSeconds(900)));
stream.add(chargeFare(replay(stream), "Harbour", 340, t.plusSeconds(4_500)));
stream.add(new CardBlocked("reported lost", t.plusSeconds(30_000)));
System.out.println("Stream TC-1001:");
for (int i = 0; i < stream.size(); i++) {
System.out.println(" " + (i + 1) + ". " + describe(stream.get(i)));
}
Card now = replay(stream);
System.out.println("Current state: " + summary(now));
Card earlier = replay(stream.subList(0, 4)); // "time travel": replay only the first 4 events
System.out.println("State after event 4: " + summary(earlier));
try {
chargeFare(now, "Central", 270, t.plusSeconds(40_000));
} catch (IllegalStateException ex) {
System.out.println("Fare rejected: " + ex.getMessage());
}
}
}
Stream TC-1001:
1. 08:00 CardIssued owner=Asha
2. 08:01 CardToppedUp +10.00
3. 08:15 FareCharged -2.70 at Central
4. 09:15 FareCharged -3.40 at Harbour
5. 16:20 CardBlocked reported lost
Current state: balance=3.90, blocked=true, version=5
State after event 4: balance=3.90, blocked=false, version=4
Fare rejected: card is blocked
Notice that the balance is never stored anywhere. Replaying only the first four events shows the card as it was just before it was blocked, which is very useful when you investigate a complaint. The rejected fare produced no event at all: commands can fail, events cannot.
4.2 Example 2: Two Gates, One Card, and Optimistic Concurrency
Two gates read the same card at the same moment. The card holds €3.00 and each gate wants to charge €2.70. Without protection, both decide "the balance is enough" and both append.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class OptimisticConcurrencyDemo {
sealed interface Event permits ToppedUp, Charged {}
record ToppedUp(int cents) implements Event {}
record Charged(String gate, int cents) implements Event {}
static class ConcurrencyException extends RuntimeException {
ConcurrencyException(String message) { super(message); }
}
static class EventStore {
private final Map<String, List<Event>> streams = new HashMap<>();
private final boolean checkVersion;
EventStore(boolean checkVersion) { this.checkVersion = checkVersion; }
List<Event> load(String streamId) {
return List.copyOf(streams.getOrDefault(streamId, List.of()));
}
// expectedVersion = how many events the writer saw when it made its decision
void append(String streamId, int expectedVersion, Event event) {
List<Event> stream = streams.computeIfAbsent(streamId, k -> new ArrayList<>());
if (checkVersion && stream.size() != expectedVersion) {
throw new ConcurrencyException("expected version " + expectedVersion + ", stream is at " + stream.size());
}
stream.add(event);
}
}
static int balance(List<Event> events) {
int cents = 0;
for (Event e : events) {
cents += switch (e) {
case ToppedUp t -> t.cents();
case Charged c -> -c.cents();
};
}
return cents;
}
static String money(int cents) {
return (cents < 0 ? "-" : "") + String.format("%d.%02d", Math.abs(cents) / 100, Math.abs(cents) % 100);
}
// One gate decides using the events it loaded, then tries to append
static void charge(EventStore store, String gate, List<Event> seen, int fare) {
if (balance(seen) < fare) {
System.out.println(" " + gate + ": rejected, balance is " + money(balance(seen)));
return;
}
store.append("TC-2002", seen.size(), new Charged(gate, fare));
System.out.println(" " + gate + ": charged " + money(fare));
}
static void run(String title, boolean checkVersion) {
System.out.println(title);
EventStore store = new EventStore(checkVersion);
store.append("TC-2002", 0, new ToppedUp(300));
List<Event> seenByA = store.load("TC-2002"); // both gates read the card at version 1
List<Event> seenByB = store.load("TC-2002");
charge(store, "Gate A", seenByA, 270);
try {
charge(store, "Gate B", seenByB, 270);
} catch (ConcurrencyException ex) {
System.out.println(" Gate B: conflict (" + ex.getMessage() + "), reloading");
charge(store, "Gate B", store.load("TC-2002"), 270);
}
System.out.println(" final balance: " + money(balance(store.load("TC-2002"))));
}
public static void main(String[] args) {
run("Without a version check:", false);
run("With optimistic concurrency:", true);
}
}
Without a version check:
Gate A: charged 2.70
Gate B: charged 2.70
final balance: -2.40
With optimistic concurrency:
Gate A: charged 2.70
Gate B: conflict (expected version 1, stream is at 2), reloading
Gate B: rejected, balance is 0.30
final balance: 0.30
Without the check, the card ends at −2.40, a classic lost update. With optimistic concurrency, each append says "I made my decision at version 1". The store refuses the second append because the stream has already moved on, and Gate B reloads, sees €0.30 and rejects the fare. Real event stores do exactly this check, using a unique key on (stream_id, version) or an "expected version" parameter on append.
4.3 Example 3: A Projection, a Checkpoint and an Old Event Version
Read models are built from the log in global order. This one also has to read an event saved in an older format.
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class ProjectionDemo {
// How an event sits in the store: global position, stream, type, schema version and payload
record StoredEvent(long position, String streamId, String type, int schemaVersion, Map<String, String> data) {}
// The only shape the rest of the code needs to understand
record FareCharged(String cardId, String station, int cents) {}
// Upcaster: turns old schema versions into the current one while reading; stored rows never change
static FareCharged read(StoredEvent e) {
return switch (e.schemaVersion()) {
case 1 -> new FareCharged(e.streamId(), e.data().get("stop"),
new BigDecimal(e.data().get("euros")).movePointRight(2).intValueExact());
case 2 -> new FareCharged(e.streamId(), e.data().get("station"),
Integer.parseInt(e.data().get("cents")));
default -> throw new IllegalArgumentException("unknown schema version " + e.schemaVersion());
};
}
// A read model built only from events; it remembers how far it has read
static class RevenueByStation {
final Map<String, Integer> centsByStation = new TreeMap<>();
long checkpoint = 0;
int catchUp(List<StoredEvent> log) {
int processed = 0;
for (StoredEvent e : log) {
if (e.position() <= checkpoint) continue; // already handled earlier
if (e.type().equals("FareCharged")) {
FareCharged fare = read(e);
centsByStation.merge(fare.station(), fare.cents(), Integer::sum);
}
checkpoint = e.position();
processed++;
}
return processed;
}
}
public static void main(String[] args) {
List<StoredEvent> log = new ArrayList<>(List.of(
new StoredEvent(1, "TC-1001", "CardToppedUp", 1, Map.of("cents", "1000")),
new StoredEvent(2, "TC-1001", "FareCharged", 1, Map.of("stop", "Central", "euros", "2.7")),
new StoredEvent(3, "TC-3003", "CardToppedUp", 1, Map.of("cents", "500")),
new StoredEvent(4, "TC-3003", "FareCharged", 2, Map.of("station", "Harbour", "cents", "340")),
new StoredEvent(5, "TC-1001", "FareCharged", 2, Map.of("station", "Central", "cents", "270"))));
RevenueByStation view = new RevenueByStation();
System.out.println("First run: read " + view.catchUp(log) + " events, checkpoint " + view.checkpoint
+ ", cents " + view.centsByStation);
log.add(new StoredEvent(6, "TC-3003", "FareCharged", 2, Map.of("station", "Airport", "cents", "450")));
System.out.println("Catch-up: read " + view.catchUp(log) + " event, checkpoint " + view.checkpoint
+ ", cents " + view.centsByStation);
RevenueByStation rebuilt = new RevenueByStation(); // a brand-new read model, filled from event 1
rebuilt.catchUp(log);
System.out.println("Rebuilt: same result = " + rebuilt.centsByStation.equals(view.centsByStation));
}
}
First run: read 5 events, checkpoint 5, cents {Central=540, Harbour=340}
Catch-up: read 1 event, checkpoint 6, cents {Airport=450, Central=540, Harbour=340}
Rebuilt: same result = true
Three things happen here. The projection stores a checkpoint, so when event 6 arrives it reads only that event, not the whole log. Event 2 was written by an older version of the service, with stop and euros instead of station and cents; the upcaster converts it while reading, and the stored row is never touched. Finally, a brand-new read model built from event 1 gives the same result, which is how you fix a buggy projection: delete it and replay.
5. Event Sourcing vs Traditional CRUD
| Traditional CRUD | Event sourcing | |
|---|---|---|
| What is stored | The latest state, overwritten on change | Every change as an immutable event |
| History and audit | Only if you add it, and it can drift | Built in and always correct |
| State at a past date | Usually impossible | Replay up to that point |
| Reading data | Query the table directly | Query projections built from events |
| Concurrent writes | Row locks or version columns | Expected version on append |
| Changing the data model | Migrate the table | Events stay; add upcasters and new projections |
| Learning curve | Low | High |
6. Common Mistakes and Pitfalls
1. Querying the event store directly. "Find all cards with a balance below €1" means replaying every stream. Build a projection for each question the business asks.
2. Making decisions in apply. If apply checks the current fare price or calls another service, replaying old events gives different answers over time. Put the decided values, like the charged amount, in the event itself.
3. Using event sourcing everywhere. A list of countries or a user's display settings don't need a history. Use it for parts of the system where history, audit or concurrent changes really matter, like payments, orders and inventory.
4. Forgetting that events are forever. You can't rename a field in a million stored events. Plan for versioning from day one: version every event type and write upcasters.
5. Personal data in immutable events. GDPR gives people the right to have their data erased, but events can't be edited. A common approach is crypto-shredding: encrypt personal fields with a per-person key and delete the key when the person asks to be forgotten.
6. Ignoring eventual consistency. Projections are updated after the write, often a few milliseconds later. A user who tops up and immediately opens the app may still see the old balance. Design the UI for this, or return the new state from the command itself.
7. Best Practices
- Name events after business facts (
FareCharged,CardBlocked), not technical changes likeCardUpdated. - Keep streams short. One stream per card, order or account, not one per customer for their whole life. Add snapshots only when replay is measurably slow, often after hundreds of events.
- Always append with an expected version to prevent lost updates.
- Make projectors idempotent and store their checkpoint in the same transaction as the read model, so a restart doesn't count an event twice.
- Separate internal events from public ones. Publish a stable integration event to other services instead of exposing every internal event.
- Combine with CQRS for reads, and with sagas for workflows that span several services.
8. Summary
- The event sourcing pattern stores every change as an immutable event and rebuilds state by replaying them.
- Commands can be rejected; events are facts and are never changed or deleted.
- Replay is a left fold, so
applymust not contain decisions. - An expected version on append prevents two writers from overwriting each other's decisions.
- Projections give fast queries and can be rebuilt at any time from the log.
- Version events from the start, and plan for personal data and eventual consistency.
Event sourcing adds real complexity, so pick one part of your system where history matters most and try it there. Used in the right place, the event sourcing pattern gives you an audit trail, time travel and a ready-made event feed for free.
Event Sourcing Pattern: Interview Questions
Common interview questions on Event Sourcing Pattern, from basic to scenario-based, with short answers.
Basic questions
Q. What is the difference between an event and a command?
Answer: A command is a request, like ChargeFare, and it can be rejected if the rules don't allow it. An event, like FareCharged, records something that has already happened, so it can't be rejected or changed. Commands are named in the imperative and events in the past tense.
Q. Is event sourcing the same as event-driven architecture?
Answer: No. Event-driven architecture is about services communicating through events. Event sourcing is about how one service stores its own state. A service can publish events without being event-sourced, and an event-sourced service can keep all its events private.
Q. What is a snapshot, and when do you need one?
Answer: A snapshot saves an aggregate's state at a given version, so loading starts from the snapshot and replays only the newer events. You need one only when streams become long enough that replay is slow, typically hundreds or thousands of events. Snapshots are a cache: you can delete them and rebuild from the events at any time.
Intermediate questions
Q. How do you choose a good event store for a Java service?
Answer: EventStoreDB and Axon Server are built for this and offer streams, subscriptions and expected-version checks. Many teams use PostgreSQL with an events table and a unique constraint on (stream_id, version). Kafka is a poor choice as the primary store, because it can't efficiently load one entity's events or check an expected version per entity.
Q. How do you fix a bug in a projection?
Answer: Fix the projector code, then delete the read model and its checkpoint, and replay all events from the start into a new read model. While it rebuilds, you can keep serving the old one and switch over when the new one has caught up. This is one of the biggest practical advantages of event sourcing.
Q. How can you undo an event that was recorded by mistake?
Answer: You can't delete or edit it. Instead, append a compensating event, such as FareRefunded after a wrong FareCharged. The history then shows both the mistake and the correction, which is exactly what auditors want.
Q. How does an event-sourced service reliably publish its events to other services?
Answer: Because the events are already stored, a relay can read new events from the store and publish them to a broker such as Kafka, tracking its position like a projection. This avoids a dual write, where saving the data and publishing a message are two separate steps that can fail independently. The same idea is behind the transactional outbox pattern.
Q. How do you write a unit test for an event-sourced aggregate?
Answer: Use the given–when–then style: given a list of past events, when a command is handled, then expect certain new events or an error. No database is needed.
Card card = replay(List.of(new CardIssued("TC-9", "Ravi", t), new CardToppedUp(300, t)));
assertThrows(IllegalStateException.class,
() -> chargeFare(card, "Central", 500, t));
Q. What is the difference between an upcaster and a migration?
Answer: A migration rewrites stored data into a new format. An upcaster leaves stored events untouched and converts old versions into the current shape when they are read. Upcasters keep the log immutable, and the trade-off is that the code must keep handling old versions.
Tricky and scenario questions
Q. Scenario: A stream has 50,000 events and loading the aggregate takes two seconds. What do you do?
Answer: First ask whether the stream boundary is right: an account that lives for years may be better split into streams per month or per statement period. If the boundary is right, add snapshots every few hundred events. Also check that the store reads events by stream id with an index, not by scanning.
Q. Scenario: A projector crashes after updating the read model but before saving its checkpoint. What happens on restart?
Answer: It reads the same event again and applies it twice, so a revenue total would be counted twice. Prevent this by saving the read-model change and the checkpoint in one database transaction, or by making the update idempotent, for example by storing each event's id and skipping ones already seen.
Q. Scenario: The business wants to know how many passengers topped up twice in one day last year, a question no one planned for. Can you answer it?
Answer: Yes, if the service is event-sourced. Write a new projection that counts top-ups per card per day and replay the historical events into it. With CRUD, that history would have been overwritten and the question couldn't be answered.
Q. Can events in different streams be ordered, and does it matter?
Answer: Within one stream, events have a strict order given by the version. Across streams, the store may provide a global position, but you shouldn't build business rules that depend on the exact order of two different aggregates' events. If two things must change together consistently, they probably belong in the same aggregate, or need a saga.