REFERENCE · Event sourcing · Blackjack Ensemble course

Reference card — print me

PlayerAccountRepository.find()

How the repository rebuilds a PlayerAccount aggregate by replaying its stored events — the read side of event sourcing. Pairs with the Repo Map's “two ways state is held”.

The one idea The balance is never stored. An account's state is derived every time by replaying its events in order. find() reads the event rows, turns them back into domain events, and folds them into a fresh account.

1 · The pipeline at a glance

find(playerId)
     │
     ▼
eventDtosByPlayer.get(playerId)        # Map<PlayerId, List<EventDto>>
     │
     ├─ null ───────────────▶ Optional.empty()      # unknown player → no account
     │
     └─ List<EventDto>
             │
             ▼
       mapPlayer(playerId, events)
             │   each EventDto.toDomain()    # JSON ─▶ PlayerAccountEvent record
             ▼
       PlayerAccount.reconstitute(playerId, events)
             │   new PlayerAccount(...): apply(event) for each, in order
             ▼
       Optional<PlayerAccount>             # balance & name folded up from events

The real method is tiny — the elegance is that it delegates:

public Optional<PlayerAccount> find(PlayerId playerId) {
    return Optional.ofNullable(eventDtosByPlayer.get(playerId))
                   .map(events -> mapPlayer(playerId, events));
}

private PlayerAccount mapPlayer(PlayerId playerId, List<EventDto> eventDtos) {
    List<PlayerAccountEvent> events = eventDtos.stream()
                                              .map(EventDto::toDomain)   // row → event
                                              .toList();
    return PlayerAccount.reconstitute(playerId, events);                 // events → account
}

2 · What each step does

#StepDetail
1Look up the event streamThe repo is an in-memory event store: a Map<PlayerId, List<EventDto>> named eventDtosByPlayer. find() asks the map for this player's list of events.
2No events → no accountOptional.ofNullable(...) means an unknown player yields Optional.empty(). Nothing is invented — no events, no account.
3Deserialize each rowEach EventDto is one stored “row” (playerId, eventId, eventType, json). toDomain() uses the eventType string to pick the record class from a converter map, and Jackson turns the JSON back into a domain event like PlayerRegistered("Judy").
4Reconstitute the aggregatePlayerAccount.reconstitute(playerId, events) is the @Reconstitute factory. It requires a non-null playerId, then calls the private constructor.
5Replay to fold up stateThe constructor starts blank (balance = -1, name "DEFAULT NAME") and loops apply(event) over every event in order. Each event nudges state. A left-fold: state = replay of all past events.
6Return the live objectThe result is the same PlayerAccount you'd have if you'd run those commands live. No new events are enqueued during replay — apply() only changes fields, whereas live commands (deposit(), bet()) call enqueue().

3 · The fold — state grows one event at a time

Replaying Judy's stream, apply() folds each event into the running state:

Event replayedRule in apply()Account state after
— (start)constructor defaultsname="DEFAULT NAME", balance=−1
PlayerRegistered("Judy")set name, balance = 0name="Judy", balance=0
MoneyDeposited(10)balance += 10name="Judy", balance=10
MoneyBet(4)balance −= 4name="Judy", balance=6
PlayerWonGame(8, …)balance += 8name="Judy", balance=14
Read it back Balance 14 is nowhere on disk — it's the sum of a registration, a deposit, a bet, and a win, recomputed on every find(). Change or replay the events and the balance follows for free. That is event sourcing.

4 · Where find() sits — the save/read mirror

save() writes events out as rows; find() is its mirror image, reading rows back into events:

save()   event ─▶ EventDto.from(id, eventId, event) ─▶ JSON row ─▶ append to map
                  (event.getClass().getSimpleName() = eventType)

find()   JSON row ─▶ EventDto.toDomain() ─▶ event ─▶ apply()-fold ─▶ PlayerAccount
                  (converters.get(eventType) picks the record class)
save() — writing new eventsfind() — reading them back
Commands (register/deposit/bet) enqueue() events; save() serialises each fresh event to an EventDto row and appends it.Loads the rows, toDomain() each back to an event, then reconstitute() folds them into a rebuilt account.
event → EventDto.from() → JSONJSON → EventDto.toDomain() → event

Note the type round-trip: save stores eventType = event.getClass().getSimpleName() (e.g. "MoneyDeposited"); find uses that same string to look the class back up in EventDto's converters map before Jackson deserialises.

Primary source

The classes themselves — read them together, they're short: application/port/PlayerAccountRepository.java (the find/mapPlayer/save trio), application/port/EventDto.java (from/toDomain + the table-schema comment), and domain/PlayerAccount.java (reconstitute + apply). For the pattern behind it, Martin Fowler, Event Sourcing. Background on why we seed test state through the aggregate is in the 17 July learning record.

Want to trace a real save→find round-trip in the debugger, or see how Lesson 0004's payout writes a PlayerWonGame event that this fold later replays? Ask me.