Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Event Sourcing Deep Dive
Event sourcing stores state as a sequence of events, providing a complete audit trail and enabling powerful temporal queries. The analogy that clicks for most people: traditional databases are like a bank balance (you only see the current number), while event sourcing is like a bank statement (you see every transaction that led to that number). This difference is profound — with event sourcing, you can answer questions like “what was this account’s balance three months ago?” or “which transactions happened between Tuesday and Thursday?” without maintaining separate audit tables. The trade-off is real complexity: event versioning, projection management, and eventual consistency are not trivial to get right. In a microservices architecture, event sourcing becomes especially powerful because events are already the backbone of inter-service communication. If theOrders service emits OrderPlaced as a Kafka event for the Inventory service to consume, why not persist that same event as the source of truth instead of deriving it from a mutated orders table? This alignment collapses two concerns (state persistence and inter-service messaging) into one: the event log. When you combine event sourcing with CQRS and a message bus, you get a system where services can be rebuilt from scratch, new projections can be added without migrations, and consumers can replay history to catch up after an outage — capabilities that are extremely difficult to retrofit into a traditional CRUD system.
- Understand when to use event sourcing
- Implement event stores and projections
- Master snapshot strategies
- Combine event sourcing with CQRS
- Handle event versioning and schema evolution
Event Sourcing Fundamentals
Before diving into code, it helps to internalize the philosophical shift event sourcing demands. In a CRUD world, the database row is the truth, and history is a derived artifact (at best, a change-log table maintained via triggers or application code). In event sourcing, the event log is the truth, and the current state is the derived artifact — a projection you compute by folding events together. This inversion is why event sourcing naturally answers temporal questions: the “past” isn’t lost information that requires special infrastructure to preserve, it’s the default representation. The business impact shows up in compliance-heavy domains. A bank that stores only current balances cannot produce a defensible answer to “what was this customer’s balance on March 15th at 3:47 PM?” without parallel audit tables that might drift out of sync with the primary data. An event-sourced bank can replay events up to that timestamp and produce a bit-for-bit exact reconstruction. Regulators love this. Auditors love this. Incident responders love this — “what was the state of the system when this bug triggered?” becomes a tractable question instead of an archaeological dig through log files.When to Use Event Sourcing
The decision to adopt event sourcing should be driven by concrete requirements, not architectural fashion. If your domain has genuine temporal queries, legal audit requirements, or complex workflows with many state transitions, the up-front complexity pays for itself many times over. If your domain is essentially “create user, update profile, delete account,” event sourcing will feel like you’re wearing a spacesuit to take out the trash — technically impressive, practically ridiculous.✅ Good Use Cases
- Financial systems (audit required)
- Order management (complex workflows)
- User activity tracking
- Collaborative editing
- Undo/redo functionality
- Temporal queries (“balance last month?”)
❌ Poor Use Cases
- Simple CRUD applications
- High-volume metrics/logs
- Frequently updated data
- No audit requirements
- Small teams (complexity overhead)
Event Store Implementation
An event store is fundamentally simpler than a relational database: it’s an append-only log keyed by stream identifier, where each entry has a monotonically increasing version number. The reason we build this on PostgreSQL (instead of a dedicated product like EventStoreDB) is pragmatic — most teams already operate PostgreSQL, its transactional guarantees are battle-tested, and JSONB gives us schema flexibility for event payloads. The moment you outgrow PostgreSQL’s throughput, you migrate the abstraction (not the semantics) to a specialized store. Designing your repository layer around the event store interface, rather than direct SQL, makes this migration feasible years later. The non-negotiable property an event store must provide is optimistic concurrency control via the(stream_id, version) uniqueness constraint. Without it, two concurrent requests loading the same aggregate, each adding an event, could both successfully append at version N+1 — one would silently overwrite or corrupt the stream. If you did this naively (say, with a plain append and no version check), you’d get lost updates under any real load, and the bugs would be nearly impossible to reproduce. With optimistic concurrency, one of the two writes fails with a ConcurrencyError, and the caller retries from a fresh load — which is exactly the semantics you want.
A second implementation detail worth dwelling on: the event store emits events after commit so that projections and downstream consumers can react. You might be tempted to emit before commit for “lower latency,” but that’s a correctness trap — if the transaction rolls back, subscribers will have acted on events that never happened. Always emit post-commit, and if your subscribers live in another process, use a transactional outbox pattern (covered in Chapter 17) instead of in-process event emitters.
- Node.js
- Python
Scenario: Your event store table has 2 billion events and rebuilding a projection takes 6 hours during a full deploy. How do you reduce the rebuild time without dropping data?
Scenario: Your event store table has 2 billion events and rebuilding a projection takes 6 hours during a full deploy. How do you reduce the rebuild time without dropping data?
- Measure first. Profile one rebuild to find the actual bottleneck: is it event read throughput (IO-bound), projection logic CPU (compute-bound), or write amplification on the projection side (index contention)? Guessing wastes weeks.
- Parallelize by stream partition, not by row. Events in one stream must apply in order; events across streams can process concurrently. Shard the rebuild by
hash(stream_id) % N_workersso each worker owns a disjoint set of streams and order within a stream is preserved. - Add a checkpointed resume. Long rebuilds that fail at hour 5 and restart from zero are the worst failure mode. Persist the last processed
global_positionper partition every few thousand events; on restart, resume from there. - Pre-aggregate with snapshots. If the projection depends mostly on recent state (current balance) rather than full history (every transaction), build a periodic snapshot projection that other projections can start from. This is a “derived event stream” — snapshot events every million raw events.
- Use bulk inserts and disable indexes during replay. Drop non-primary indexes on the projection table before rebuild, bulk-copy events in batches of 10k-50k, then recreate indexes at the end. This alone often takes a rebuild from 6 hours to 40 minutes on PostgreSQL.
- Split hot from cold. If the projection only needs recent data (last 90 days), introduce a “cold” archive of older events and a warm partition the rebuild actually scans. Store older events compressed in object storage with a pointer kept in the primary table.
- Blue-green the projection. Build the new projection into a new table while the old one serves traffic, then atomically swap names. This means you never stop serving reads during rebuild.
- “Just buy a bigger database.” Scaling up hides the problem for 6 months until you hit the next wall. The issue is architectural (no partitioning, no snapshots, indexed writes during bulk load), not hardware.
- “Stop the world and rebuild overnight.” A 6-hour maintenance window is unacceptable for anything customer-facing, and the rebuild will eventually grow beyond the window. Blue-green swap solves this without downtime.
- Greg Young, “Building an Event Storage” — the original rationale for stream-partitioned event stores.
- Martin Kleppmann, Designing Data-Intensive Applications, Chapter 11 (Stream Processing) — covers projection rebuild strategies generically.
- Apache Kafka documentation on “log compaction” — the same idea applied to message logs.
Scenario: A user reports their account balance is wrong by $47.23. You have 8 years of events for this account. Walk through how you'd debug it in an event-sourced system.
Scenario: A user reports their account balance is wrong by $47.23. You have 8 years of events for this account. Walk through how you'd debug it in an event-sourced system.
- Start with the projection, not the events. Query the balance projection for this account to see what the system currently shows. Compare with what the user says it should be. Confirm the delta is $47.23 and note whether it’s positive (system says too much) or negative (system says too little).
- Sum the raw events as a sanity check. Run
SELECT SUM(CASE WHEN type='MoneyDeposited' THEN amount ELSE -amount END) FROM events WHERE stream_id = 'BankAccount-X'. If this matches the projection, the bug is in how events were recorded (the write path). If it differs from the projection, the bug is in the projection logic or a missed event. - Scan for suspicious events. Look for events with unusually large amounts, duplicate timestamps, or events just before/after a known system incident. Temporal correlation is often the smoking gun — “this balance went wrong at 02:14 AM on March 3rd, what was happening then?”
- Replay the aggregate to the suspected bad point. Load events in order up to just before the suspected event and inspect the computed state. Then apply one more event at a time. This is where event sourcing shines: you can reconstruct state at any point in history without log grepping.
- Check for compensating events that never fired. If there was a failed external transaction that should have been compensated (refund, reversal) but the compensating event was never emitted, the balance drifts. Common root cause when the issue involves integrations with payment processors.
- Check projection checkpoint and duplicate application. If the projection crashed mid-batch and the checkpoint save was not transactional with the row update, some events may have been applied twice. Query for telltale signs (repeated exact amounts within the same second).
- Fix with a compensating event, not a direct write. Never
UPDATE account_balances SET balance = correct_value. Append aBalanceAdjustedevent with reason and operator, rebuild the projection, verify. The audit trail stays intact.
WithdrawalRequested event for which no corresponding WithdrawalSettled or WithdrawalCancelled ever arrived due to a message bus partition during a network incident. The fix was a WithdrawalCancelled compensating event plus a monitor for unresolved pending transactions older than 24 hours. The whole investigation took under two hours; in a CRUD system, the equivalent forensic work would have required database backups and audit-log correlation across services.Senior Follow-up QuestionsGRANT INSERT, SELECT ON events with no UPDATE/DELETE for application roles) and should include a hash chain: every event’s metadata contains hash(prev_event_hash + this_event_payload). A scan that recomputes hashes and compares them to stored ones reveals tampering. Some teams go further and anchor the hash chain to an external signed timestamp service so tampering is legally detectable. For the case where tampering is discovered, you replay from a known-good backup and coordinate compensating events for any real activity that occurred after the tampering.created_at <= '2024-10-15 15:00:00' and compute the balance. That is the definitive answer, independent of whatever the projection said at the time. If the projection was wrong historically, you can now prove the user right (or wrong) from the canonical event log. In a CRUD system this question is unanswerable without a separate audit table that may itself have drifted.- “I’d check the database directly and fix the balance.” This is the CRUD mindset. In event sourcing, the database (projection) is derived — fixing it without fixing the underlying events means the projection will drift again on the next rebuild. The root cause is always in the event stream.
- “I’d add a log statement and wait for it to happen again.” In an event-sourced system, you already have the log — it’s the event store. You don’t need to reproduce the bug because you have the full history of what happened.
- Martin Fowler, “Event Sourcing” — original articulation of temporal queries as a first-class capability.
- Greg Young, “CQRS Documents” — detailed treatment of debugging via event replay.
- Adam Dymitruk, “Event Modeling” — workshops teach the exact debugging workflow above.
Scenario: Your team is about to launch an event-sourced trading system and the CTO is nervous about the event store becoming a single point of failure. How do you design for availability?
Scenario: Your team is about to launch an event-sourced trading system and the CTO is nervous about the event store becoming a single point of failure. How do you design for availability?
- Separate write availability from read availability. The event store write path must be strongly consistent (you cannot lose a trade), but read models can be stale or locally replicated. Design them differently: write side is synchronously replicated (PostgreSQL with synchronous_commit=on, or a managed multi-AZ setup). Read side runs regionally with async replication of the event stream.
- Use a proven log-structured store if scale demands it. For anything past 50k events/sec sustained, move the event log to Kafka (with
acks=allandmin.insync.replicas=2) and keep PostgreSQL only for aggregate-level streams. Kafka’s replication protocol is designed for exactly this. - Snapshot aggressively so you can survive store unavailability for reads. If every aggregate has a snapshot within the last 100 events, you can serve reads from snapshot + in-memory cache if the event store is degraded.
- Adopt a transactional outbox if you’re using PostgreSQL and need downstream propagation. Outbox + relay to Kafka gives you “write durably to Postgres, propagate reliably to anywhere.”
- Design aggregates to be idempotent on retry. Every command should include an idempotency key stored on the event. If a client retries a deposit after a network failure and the command is applied twice, the second write detects the duplicate key and returns success without double-charging.
- Regional failover is driven by the event log, not the projections. In a DR scenario, the secondary region replays the replicated event log into fresh projections. The projections in the dead region are not part of the recovery — they are regenerated.
- Write runbooks for projection corruption separately from write-side failure. The two failure modes require different responses and the team must practice both.
- “We’ll use an active-active multi-master event store.” Active-active writes to an event log destroy ordering guarantees and create merge conflicts that are unresolvable at the event level. Event stores are single-leader by design. Availability comes from fast failover, not concurrent writes.
- “We’ll cache aggregates in Redis so we never hit the event store for reads.” Cache coherence with an event-sourced aggregate is subtle. If you rehydrate from cache and miss events that landed after the cache fill, you will emit commands based on stale state and hit ConcurrencyErrors on save. Caches must be invalidated by the event stream, which is more work than it’s worth for most aggregates.
- Martin Fowler, “The LMAX Architecture” (2011) — the canonical write-up on high-availability event-sourced trading.
- Confluent, “Exactly-Once Semantics in Kafka” — details the idempotent producer pattern used in step 5 above.
- Google’s Spanner paper (2012) — for context on the cost of true global consistency when you do need it.
Aggregate Pattern
An aggregate is a cluster of domain objects treated as a single unit for data changes, with one root entity that enforces invariants. In event sourcing, the aggregate is the piece of code that decides “given these past events and this incoming command, what new events should happen?” Commands are intent (deposit $100), events are facts (MoneyDeposited $100). This distinction matters because commands can be rejected (insufficient funds, account closed) while events, once stored, are immutable truth. You cannot “undo” an event — you can only append a compensating one.
The pattern of applyChange (which both mutates state and records an uncommitted event) versus _apply (which only mutates state from historical events) is what lets the same code handle both fresh command processing and rehydration from the event store. When you load an aggregate, you construct an empty instance and fold the stored events through _apply. When you execute a command, you call a business method that validates invariants and calls applyChange to produce new events. If you separated these into totally different code paths, you’d inevitably have drift — state computed by the command path would diverge from state computed during replay, and your bugs would be of the “cannot reproduce” variety.
A common beginner mistake: putting validation inside event handlers (onMoneyWithdrawn). Don’t. Events are facts that already happened; they cannot be rejected during replay. Validation belongs in the command method (withdraw()), which decides whether to emit the event in the first place. If you add validation to the handler, replaying old events can throw exceptions mid-load, and you’ll discover this the first time business rules tighten (e.g., a new minimum balance rule applied retroactively to historical withdrawals that were legal at the time).
- Node.js
- Python
Repository Pattern
The repository is the boundary between domain code (aggregates) and infrastructure (the event store). Its job is simple but crucial: hide the fact that the aggregate is backed by an event log, not a row in a table. A domain developer writingaccount.deposit(100) and repository.save(account) should not need to think about versions, streams, snapshots, or concurrency. The repository translates the aggregate’s uncommittedEvents into a stream append, and translates a stream read back into a rehydrated aggregate.
Why bother with the abstraction? Because two years from now, when you swap PostgreSQL for EventStoreDB, or when you want to add a write-through cache for frequently-accessed aggregates, or when you realize snapshots need to live in Redis instead of the main database — those changes should be invisible to domain code. If your command handlers call eventStore.appendToStream(...) directly, you have to rewrite them all. If they call repository.save(account), you rewrite one class.
Snapshots deserve special attention here. The snapshot frequency (every 100 events in our example) is a tuning knob that trades write overhead for read performance. Too frequent: you’re writing state snapshots that nobody will ever read (for short-lived aggregates) and burning storage. Too infrequent: loading an active aggregate requires replaying thousands of events, killing response time. The sweet spot is domain-specific — for a bank account that lives 30 years with a few transactions per month, snapshot every 50 events. For a short-lived shopping cart that gets abandoned after 20 events, snapshots may not be worth it at all. Measure p99 load time and adjust.
- Node.js
- Python
Projections
Projections are where event sourcing’s read-side gets its scalability. The core idea: the event log is optimized for appends and per-stream reads (loading an aggregate), but it’s terrible for ad-hoc queries like “show me all accounts with balance > $10,000 sorted by owner name.” Rather than trying to make the event log good at everything, we derive purpose-built read models from it. Each projection is a materialized view, subscribed to the event stream, that maintains a schema optimized for specific queries. This is where CQRS (Command Query Responsibility Segregation) naturally enters the picture. Your write model (the event-sourced aggregates) and your read models (projections) evolve independently. Need a new query? Build a new projection. Don’t like how your balance report looks? Drop the projection table, reset the checkpoint, and replay events. This is vastly easier than reshaping a traditional database schema, which requires migration scripts, downtime planning, and prayer. The trade-off you’re buying into: eventual consistency. When a user deposits $100, the event is committed instantly, but theaccount_balances projection may be a few hundred milliseconds behind. For most domains this is fine — the UI can display the command’s response directly rather than re-querying. For domains where it’s not fine (strong read-your-writes requirements), you can route specific reads back to the write side via aggregate replay, or use single-writer projections with synchronous updates. Don’t make the whole system synchronous just because one query needs it; that defeats the point.
A non-obvious operational win: projections are disposable. If the balance projection has a bug, you don’t need to frantically patch production data — you deploy a fix, truncate the projection table, reset its checkpoint, and let it rebuild from the event stream. The source of truth is never damaged. I have watched teams rebuild six-month-old projection tables over a weekend to add new columns with exactly zero data loss. That’s not possible with a CRUD architecture.
Projection Implementation
Every projection needs a checkpoint — the position in the event stream up to which it has already processed. Without checkpoints, a projection restarting after a crash would either double-apply events (corrupting sums) or miss events entirely. The checkpoint table is the projection’s memory of “where was I?” The processing loop is deliberately simple: read a batch of events from the last checkpoint, apply them to the projection, save the new checkpoint, sleep briefly, repeat. Simple is good here because projections are frequently the thing that’s wrong — simple code is easy to reason about when you’re in an incident at 2am wondering why balances look off. A common mistake is making the checkpoint update and the projection update separate transactions. Don’t. If the projection updates the balance but crashes before saving the checkpoint, the next startup will re-apply the same event, doubling the balance. Wrap them in a single transaction (or make the projection operation idempotent by including the event ID in a uniqueness constraint — the “inbox pattern”).- Node.js
- Python
Scenario: A product manager deposits $500 via the UI, watches the balance stay at its old value for 2 seconds, and files a Sev-2 bug about 'the money disappeared'. How do you design around this complaint without losing the benefits of eventual consistency?
Scenario: A product manager deposits $500 via the UI, watches the balance stay at its old value for 2 seconds, and files a Sev-2 bug about 'the money disappeared'. How do you design around this complaint without losing the benefits of eventual consistency?
- Return write-side state from command responses. The deposit endpoint should return the updated balance from the aggregate’s in-memory state directly, not query the projection afterward. This makes the user-visible behavior strongly consistent for the user’s own actions.
- Distinguish “read my own writes” from “read what others have done.” The PM’s complaint is specifically about their own write. That is the easy case. Other users seeing your deposit can be eventually consistent; it’s invisible to them.
- Use versioned responses for polled updates. If the UI polls the projection afterward (say, showing a transaction list), include the aggregate version in the command response and have the UI query
WHERE version >= Xso it knows when the projection has caught up. - Add read-your-writes caching with explicit invalidation. The command response populates a short-lived per-user cache (30 seconds) with the new state. Reads check the cache first. This layer is local to the user’s session and does not require distributed consistency.
- Measure projection lag and alert on anomalies. Healthy lag is under 500ms. If the 99th-percentile lag crosses 2 seconds, you have an infrastructure problem, not an eventual-consistency problem, and you should page on it.
- Educate the product org with a 5-minute demo. Show the PM: the balance updates immediately in the UI because the command response is authoritative, the projection catches up in 200ms for others, and the audit trail is bit-exact. The answer to the Sev-2 is a UX bug (UI should refresh from command response), not an architecture change.
- “Just read from the aggregate every time.” You’ve now reinvented a CRUD system — the projection exists because querying the aggregate for read-heavy operations is expensive and doesn’t support ad-hoc queries.
- “Add a polling loop in the UI that refreshes every 500ms.” Works until you have 100k concurrent users, at which point you’ve created a self-DDoS of your projection endpoint.
- Shopify Engineering, “Building Resilient GraphQL APIs” (2021) — read-your-writes patterns used at scale.
- Pat Helland, “Data on the Outside vs Data on the Inside” — foundational paper on this exact tension.
- Martin Kleppmann, Designing Data-Intensive Applications, Chapter 5 (Replication) — covers read-your-writes and monotonic read consistency in detail.
Scenario: You want to introduce a new 'user_activity_by_region' projection that requires joining events from 4 aggregates. How do you design it to avoid coupling the projector to internal aggregate shapes?
Scenario: You want to introduce a new 'user_activity_by_region' projection that requires joining events from 4 aggregates. How do you design it to avoid coupling the projector to internal aggregate shapes?
- Subscribe to events, not aggregates. The projector registers for the specific event types it cares about (
UserLoggedIn,UserPurchased,UserReferralSubmitted,UserProfileUpdated). It never loads the aggregate; it only reacts to events. - Define a public event contract. Every event type has a stable schema, versioned and documented. The projector depends on the contract, not the aggregate’s internal representation. If the aggregate refactors its private fields, the projector is unaffected as long as the events stay compatible.
- Use correlation IDs in metadata. When the projector joins events across aggregates (this purchase is from this login session), the events share a correlation ID in metadata set by the command path. The projector correlates on metadata, not by rebuilding aggregate state.
- Keep the projection schema denormalized. Don’t store foreign keys back to aggregates. Store whatever denormalized data the queries need. If the underlying aggregate changes, the denormalized snapshot in the projection is a point-in-time record, not a live reference.
- Make the projection rebuildable. A new projection starts from global position zero and processes the entire log. This must be feasible — hence the rebuild performance work covered earlier. If the log is 2 billion events, partitioning the rebuild across workers is mandatory.
- Version the projection schema. The projection table has a
schema_versioncolumn. When the schema changes, the rebuild writes new rows with the new version and an atomic swap updates readers.
ProductCreated, PriceAdjusted, InventoryUpdated, and ReviewPosted events across four different aggregates. They consumed only the public events (documented in an internal schema registry) and populated an Elasticsearch-backed projection. Crucially, the projector did not call back to the aggregates’ services; it only ran from the event log, which meant search performance was independent of the write-side’s load.Senior Follow-up Questionscorrelation_id but arrive in the projector out of order (event B from aggregate 2 arrives before event A from aggregate 1)?Strong Answer: Projections must be tolerant of out-of-order arrival across streams — you only get ordering guarantees within a single stream. Design the projection handlers so that late-arriving events can update rows already present, or use a “waiting” table for partial correlations that complete later. For strict cross-stream ordering requirements, serialize the events you care about into a single “causal stream” via an upstream orchestrator; this is rare and expensive, so avoid it when possible.- “The projector should call the aggregates’ query APIs to enrich events.” This couples the projector to the aggregates’ runtime availability and re-introduces the N+1 problem you were trying to avoid with event sourcing.
- “Store the full aggregate snapshot in each event.” Events balloon in size, storage costs explode, and the aggregate’s internal shape becomes baked into the event — the opposite of decoupling.
- Greg Young, “CQRS and Event Sourcing” (Code on the Beach 2014) — projections as first-class citizens.
- Walmart Labs, “CQRS on a 100-Service Platform” QCon 2019 talk — real-world multi-aggregate projections.
- Confluent, “Streaming Joins with Kafka Streams” — the exact problem solved in a Kafka-native style.
Event Versioning & Schema Evolution
Schema evolution is the Achilles heel of event sourcing, and the reason many teams abandon it. The core tension: events are immutable facts, but your understanding of those facts evolves over time. You added acurrency field because the business went international. You split name into firstName and lastName because GDPR required granular data handling. Now you have 200 million events in production with the old shape, and your domain code wants to work with the new shape.
The wrong solutions: (1) migrate old events in place — this breaks immutability, destroys audit trails, and is operationally terrifying; (2) branch your domain code with if event.version === 1 ... else ... — this turns your aggregate into a museum of historical schemas and becomes unmaintainable within six months; (3) ignore the problem and let old events fail validation — congrats, you just broke rehydration for every aggregate that existed before the change.
The right solution is upcasting: store events exactly as they were written, but apply a chain of transformations when reading them so that domain code only ever sees the latest shape. The transformation chain (v1 → v2 → v3) lives in one place, is unit-testable in isolation, and can be reviewed by anyone asking “how did we evolve this event?” This preserves the sacred invariant (stored events are immutable) while still letting the domain evolve freely. The cost is a small runtime overhead per read, and the discipline of always versioning new events so the chain knows where to start.
Upcasting Implementation
- Node.js
- Python
Scenario: You need to rename `OrderPlaced.total` to `OrderPlaced.total_cents` and change its unit from dollars to cents (so $10.00 becomes 1000). 180 million old events exist. How do you ship this safely?
Scenario: You need to rename `OrderPlaced.total` to `OrderPlaced.total_cents` and change its unit from dollars to cents (so $10.00 becomes 1000). 180 million old events exist. How do you ship this safely?
- Stop and confirm this is the right design. Renaming and changing semantics in one step is asking for trouble. Two separate changes are safer: first rename the field, then later change the unit. Ship them weeks apart so any wrong assumptions surface in the first rollout rather than compounding in the second.
- Freeze the v1 schema in writing. Document that all old events are “v1: total in dollars as decimal.” This is the ground truth the upcaster will transform from.
- Introduce
OrderPlaced_v2withtotal_cents: integer. New events are written in v2. The upcaster converts v1total(dollars, decimal) to v2total_cents(integer, cents) by multiplying by 100 and rounding. Rounding choice is explicit and documented. - Stage the rollout. Deploy the upcaster to read paths first; verify rebuild of one small projection produces correct values (spot-check 100 events manually). Then deploy the write-path change so new events are v2.
- Run both in parallel for a week. Two projections — one reading v1-shaped events (legacy), one reading v2-shaped events (new). Diff their outputs on the same stream. Any discrepancy indicates an upcaster bug.
- Notify downstream consumers. If Kafka or a webhook publishes these events, consumers need to know about the schema change. Preferably dual-publish for a transition period: send both v1 and v2 on the bus, consumers migrate at their own pace, v1 is retired when usage drops to zero.
- Only after verification, flip read-side defaults. New code reads v2-shape only. The upcaster is doing its job. Old code paths that read v1 directly are found via grep and fixed.
v1 saw events in the old shape, v2 subscribers saw the new shape, and both shapes were served from the same underlying events via transformation at the edge. The transformation logic was ~150 lines, had a 98% unit test coverage, and was left in place for 3 years until the last v1 subscriber migrated.Senior Follow-up QuestionsTotalConverted {from_dollars: 10.005, to_cents: 1001, reason: "half-up"}) as an audit trail. Rounding is a business decision, not a technical one — get finance sign-off before shipping.- “We’ll update all the old events in place with an UPDATE statement.” This destroys the immutability guarantee. Every subscriber that checked checksums, every hash chain, every offline backup now disagrees with production.
- “We’ll write a new service that owns the new schema and deprecate the old one.” Creates two sources of truth, doubles operational cost, and migration becomes a multi-year project. Upcasting in-place is the pattern for a reason.
- Stripe Engineering, “API Upgrades” (2017) — the exact pattern applied to webhook schemas.
- Greg Young, “Versioning in an Event Sourced System” (2017 PDF book) — book-length treatment of this problem.
- Martin Fowler, “Event Interception” — taxonomy of where to place transformations in the event pipeline.
Scenario: You inherited an event-sourced system that has 9 event types, each with between 2 and 5 schema versions, and no documentation. The upcaster is a 400-line function. How do you get it under control?
Scenario: You inherited an event-sourced system that has 9 event types, each with between 2 and 5 schema versions, and no documentation. The upcaster is a 400-line function. How do you get it under control?
- Reverse-engineer the schemas from production samples. For each event type, pull 1000 events spanning the lifetime of the system. Extract the distinct JSON schemas (via tools like
quicktypeor a simple field-presence analyzer). You now have a map of what “v1” actually looks like in production versus what the code assumes. - Document the upcaster chain. For each upcaster step, write a paragraph: what changed, why, when. If the reason is “the product team renamed the field,” note the PR that did it. If the reason is lost, mark it “unknown origin — do not remove without investigation.” This is 80% of the value, generated in a few afternoons.
- Add contract tests. For each event type at each version, add a test that produces a canonical sample event, runs it through the upcaster, and asserts the output matches the current domain type. Break any upcaster that fails — you now have a regression safety net.
- Identify “dead” upcasters. An upcaster handling v1 → v2 of an event type that hasn’t been produced since 2019 — check whether any v1 events still exist in the store. If not, the upcaster is dead code and can be removed (with a note in the commit that says so).
- Plan a compaction window. Schedule a migration to rewrite ancient events in their v-latest shape so upcasters for the oldest versions can retire. Do this only after contract tests are in place and only during low-traffic windows.
- Institutionalize the discipline. Every new event version gets its own migration function with a clear name (
money_deposited_v2_to_v3_add_currency), a documented reason, and a test. The 400-line function becomes a directory of small, testable functions.
SELECT COUNT(*) FROM events WHERE event_type = 'OrderPlaced' AND metadata->>'schema_version' = '1'. If the count is zero, the upcaster is dead. If it’s nonzero, the upcaster is live and removal would break reads of those events. For borderline cases (100 events still v1), you either migrate those 100 events to v-latest explicitly or keep the upcaster. Don’t ever delete an upcaster without that query.- “Rewrite the whole upcaster from scratch.” Dangerous without contract tests; you will lose subtle business logic encoded in the current one.
- “Leave it alone, it works.” Technical debt compounds. The next schema change will be twice as hard.
- Vaughn Vernon, Implementing Domain-Driven Design, Chapter 8 (Domain Events) — event evolution principles.
- Klarna Engineering Blog, “Taming the Event Store” (2022) — the real inherited-codebase war story.
- Eric Evans, “Domain-Driven Design Reference” — vocabulary for discussing event schemas with business stakeholders.
CQRS with Event Sourcing
CQRS stands for Command Query Responsibility Segregation — a pattern that says “the model you use to change state should be different from the model you use to read state.” Event sourcing and CQRS are often discussed together because they reinforce each other: event sourcing gives you a natural write model (aggregates emitting events) and a natural way to build read models (projections subscribing to events). You can do CQRS without event sourcing (split writes and reads in a traditional relational DB), and you can do event sourcing without CQRS (queries that load and replay aggregates). But together they’re a coherent architectural style. The “why” to internalize: in a traditional CRUD service, your ORM entity is simultaneously the write model (the thing the business logic mutates) and the read model (the thing you return from GET endpoints). This tension leads to ugly compromises — you add a field for reads that has no business meaning, you denormalize for query performance at the cost of write complexity, you add joins that slow mutations. CQRS says: stop contorting one model. Use aggregates with rich domain logic for writes, and flat, query-optimized tables for reads, connected asynchronously via events. For microservices specifically, CQRS gets more interesting because each side can scale independently. The write side (where business invariants must be enforced) might be a small, carefully-guarded service with strong consistency. The read side (where projections serve dashboards, search, and reports) can be sharded, replicated, or spun up in multiple regions for latency, without affecting the write-side correctness. And since read models are disposable, you can add regional read replicas without migrating any production data — just replay events into new projections.Complete CQRS Implementation
A critical design choice here: commands return “acknowledgement that the command succeeded” and not the resulting read-side state. If yourdeposit endpoint tries to return the updated balance by reading from the projection, you’ve introduced a race condition — the projection may not have processed the event yet. Either return the write-side balance (which is the aggregate’s in-memory state, guaranteed current) or return just the acknowledgement and let the client refresh. Mixing the two creates subtle bugs that appear only under load.
- Node.js
- Python
Interview Questions
Q1: What is Event Sourcing and when should you use it?
Q1: What is Event Sourcing and when should you use it?
- Need complete audit trail (finance, healthcare)
- Complex business workflows
- Need temporal queries (“balance 3 months ago?”)
- Debugging requires understanding “how we got here”
- Undo/redo functionality needed
- Simple CRUD apps
- High-volume updates (too many events)
- No audit requirements
- Team lacks experience (steep learning curve)
Q2: How do you handle schema evolution in events?
Q2: How do you handle schema evolution in events?
- Events are immutable (never change stored events)
- Transform events when reading
- Chain transformations: v1 → v2 → v3
- Add version metadata to events
- Make new fields optional with defaults
- Test upcasters with old event data
Q3: What are projections and why do you need them?
Q3: What are projections and why do you need them?
- Replaying events for every query is slow
- Different queries need different data shapes
- Can use different databases per projection
- Checkpoint: Last processed event position
- Rebuild: Delete projection, replay all events
- Eventually consistent: Slight delay from write
- Account balances (fast balance lookup)
- Transaction history (ordered list)
- Daily summaries (aggregated stats)
Q4: Explain snapshots in Event Sourcing
Q4: Explain snapshots in Event Sourcing
- Save snapshot every N events (e.g., 100)
- On load: Get snapshot + events since snapshot
- Reduces events to replay
- After N events (e.g., 100)
- On aggregate save (if changed significantly)
- Background process during low load
Chapter Summary
- Event sourcing stores state as immutable event sequence
- Use aggregates to encapsulate business logic and emit events
- Projections build read-optimized views from events
- Snapshots improve loading performance for long-lived aggregates
- Upcasting handles event schema evolution
- CQRS separates read and write models for scalability
Interview Deep-Dive
'Your event store has 500 million events for an order aggregate. Loading the current state requires replaying all events, which takes 30 seconds. How do you fix this?'
'Your event store has 500 million events for an order aggregate. Loading the current state requires replaying all events, which takes 30 seconds. How do you fix this?'
SELECT * FROM snapshots WHERE aggregate_id = X ORDER BY sequence_number DESC LIMIT 1, then SELECT * FROM events WHERE aggregate_id = X AND sequence_number > snapshot_sequence.For the 500 million events case, most of those events are probably across many order aggregates, not a single one. But even if a single order has 10,000 events (very active order with many status changes, partial refunds, modifications), snapshots every 100 events mean loading requires 1 snapshot read plus replaying at most 100 events. That takes milliseconds, not 30 seconds.The trade-off: snapshots add complexity. You need to handle snapshot versioning (what happens when the aggregate’s state structure changes?), snapshot storage (how long to keep old snapshots?), and snapshot creation timing (synchronous during event append, or asynchronous in a background process?). I recommend asynchronous snapshots via a background process to avoid adding latency to the write path.Follow-up: “What happens when you change the aggregate’s state structure? Old snapshots have the old structure and cannot be deserialized.”Snapshot migration. I version the snapshot format (snapshot_version field) and maintain deserializers for the current version and the previous version. When loading, the deserializer checks the version and applies transformations if needed. Alternatively, I invalidate old snapshots by deleting them when the state structure changes, forcing the system to rebuild snapshots from events. This is safe because events are immutable and the event replay logic always produces the current state structure. The cost is a temporary performance degradation while snapshots are rebuilt.'A developer on your team wants to delete an event from the event store because it contains a bug (incorrect price). Why is this dangerous, and what is the correct approach?'
'A developer on your team wants to delete an event from the event store because it contains a bug (incorrect price). Why is this dangerous, and what is the correct approach?'
'When would you choose event sourcing over a traditional CRUD approach, and what is the most common reason teams abandon event sourcing after adopting it?'
'When would you choose event sourcing over a traditional CRUD approach, and what is the most common reason teams abandon event sourcing after adopting it?'