Event-Sourced Ledger
Build an account ledger service in Scala where balances are never stored directly but derived from an append-only log of transaction events (deposits, withdrawals, transfers and fees). Commands are validated against the account's current state (you can't withdraw more than you have), events are appended with an expected version so concurrent commands never corrupt an account, and periodic snapshots keep loading fast. An http4s API exposes commands, balances, statements and balance at any past time. It's built with Cats Effect, with events stored in PostgreSQL through Doobie. You'll learn event sourcing in a functional style, optimistic concurrency, snapshots, pure domain logic with Either, and testing with property-based tests.
- Estimate
- ~14.5h
- Steps
- 7
- Completed by
- 0
- Proposed by
- codeseed.app
sbt · Cats Effect · http4s
Project roadmap
- 01
Design the ledger
~2hWrite DESIGN.md: the commands (open account, deposit, withdraw, transfer and charge fee) and the events they produce, the rules (no negative balance beyond an overdraft limit, amounts positive, and accounts must be open), how a transfer affects two accounts (events on both, written in one transaction), the events table (account, version, event type, data as JSON, time, and a unique index on account plus version), snapshots, and the API. Done when: DESIGN.md covers commands, events, rules and storage.
- 02
Write the pure domain
~2.5hModel events as a sealed trait (or enum), the account state as a case class, a pure apply(state, event) function, and a pure decide(state, command) returning Either[DomainError, List[Event]]. Money is a Long in cents. Done when: unit tests show valid commands produce the right events and invalid ones return the right errors.
- 03
Store events with Doobie
~2.5hCreate the tables with a migration, and write an EventStore with Doobie: load an account's events after a version, and append events with the expected version in a transaction, turning the unique index violation into a concurrency conflict. Encode events as JSON with Circe. A transfer appends to both accounts in one transaction (locking them in a fixed order). Done when: appending with a stale version fails with a conflict, and transfers are all-or-nothing.
- 04
Handle commands with retries
~1.5hWrite a command handler in Cats Effect: load the state (latest snapshot plus later events), decide, append with the expected version, and on a conflict reload and retry a few times. Accept an idempotency key per command, stored with its result, so a repeated command returns the same result. Done when: many concurrent deposits and withdrawals on one account give a correct final balance.
- 05
Add snapshots
~1.5hSave a snapshot of an account's state every N events, and load from the latest snapshot when handling commands. Add a check that rebuilding an account from all its events gives the same state as snapshot plus later events. Done when: loading long-lived accounts uses snapshots, and the check always passes.
- 06
Build the http4s API
~2hExpose POST /accounts, POST /accounts/{id}/deposits, /withdrawals and /transfers (with an Idempotency-Key header), GET /accounts/{id} (current balance and version), GET /accounts/{id}/statement (events with running balance, paginated) and GET /accounts/{id}/balance?at=TIME. Return 409 for conflicts after retries and 422 for rule violations. Done when: every endpoint works with curl.
- 07
Test it
~2.5hWrite munit-cats-effect tests: the domain rules, property-based tests with ScalaCheck (applying any sequence of valid commands never gives a negative balance beyond the limit, and the total money across accounts only changes through deposits, withdrawals and fees), concurrency conflicts, idempotency, snapshots, and the API, using PostgreSQL in Testcontainers. Done when: sbt test passes.
Resources
Ready to build this?
Get a GitHub repo and start building. Your AI reviewer checks each step as you go.
Tech stack