00:00

Event Sourcing Pattern in Microservices: Store What Happened, Not Just the Result

Last updated:

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

How the event sourcing pattern worksWrite side: 1, a client or ticket gate sends a command to the command handler. 2, the handler loads the card's stream from the event store and replays it. 3, if the rules pass, it appends new events to the event store. Read side: 4, new events are delivered to projectors. 5, projectors update the read models. 6, a dashboard or app queries the read models. WRITE SIDE Client / ticket gate sends ChargeFare Command handler replay → check rules → decide Event store append-only, one stream per card 1. command 2. load 3. append READ SIDE 4. new events (asynchronous) Projectors follow the log, keep a checkpoint Read models balance view, revenue per station Dashboard / app reads only read models 5. update 6. query events are the source of truth; read models can be deleted and rebuilt by replaying them
The write side decides using events replayed from the store and only ever appends; the read side turns the same events into views that are cheap to query.
  1. A command arrives, for example "charge €2.70 at Central station to card TC-1001".
  2. The handler loads the card's stream and replays it to rebuild the current state.
  3. 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.
  4. The new events are delivered to projectors, usually asynchronously.
  5. Projectors update read models: a balance table for the mobile app, revenue figures for finance.
  6. Queries read the read models, never the raw event stream.
What happens internally: rebuilding state is a left fold. You start with an empty state and apply each event in order: 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.

TransitCardDemo.java
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());
        }
    }
}
Output
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.

OptimisticConcurrencyDemo.java
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);
    }
}
Output
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.

ProjectionDemo.java
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));
    }
}
Output
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 CRUDEvent sourcing
What is storedThe latest state, overwritten on changeEvery change as an immutable event
History and auditOnly if you add it, and it can driftBuilt in and always correct
State at a past dateUsually impossibleReplay up to that point
Reading dataQuery the table directlyQuery projections built from events
Concurrent writesRow locks or version columnsExpected version on append
Changing the data modelMigrate the tableEvents stay; add upcasters and new projections
Learning curveLowHigh

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 like CardUpdated.
  • 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 apply must 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.