The event sourcing decision record: why the event store selection you made determines your projection replay latency surface and your event schema evolution failure mode
The event store selection, the projection consistency contract, and the event schema evolution protocol are architectural decisions that are almost never made explicitly — they emerge from a Kafka deployment treated as both a transport and a durable store without specifying the retention policy that determines replay durability, projections built without a maximum acceptable lag specification, and JSON event schemas evolved by adding fields without a registry or an upcasting strategy. Three failure patterns: the fintech that used Kafka as its event store and discovered after 15 months and 400 million events that the 7-day retention window had discarded the event history the team assumed was permanent, forcing an 11-hour projection rebuild from an S3 archive; the inventory SaaS whose projections had no lag specification and produced a false P1 incident when a customer support representative read 4-second-stale data immediately after an adjustment; and the enterprise SaaS that added a field to a JSON event schema without a registry, broke three production consumers, and left 18 months of historical events permanently missing the field for analytics backfill.
A 38-person fintech company built a payment ledger and reconciliation platform for e-commerce merchants handling multi-currency payouts across nine countries. The engineering team of seventeen used event sourcing for the payment ledger aggregate — every debit, credit, adjustment, and fee applied to a merchant's ledger was recorded as an immutable event, and the current ledger balance was derived by projecting the event stream. The decision to use event sourcing was made in an architecture review in the platform's first year: the regulatory requirement for a complete, auditable, and unalterable record of every ledger movement made event sourcing the obvious fit, and the team had experience with CQRS from a prior project.
The team chose Kafka as the event streaming platform. Kafka was already in use for inter-service messaging, and extending it to serve as the payment event stream was presented as a single-infrastructure decision that avoided adding a dedicated event store. The Kafka topic for payment events was configured with a seven-day retention window — the default for the organization's Kafka clusters, applied because no one changed it. The team's mental model was that Kafka retained events permanently; the retention policy was not read or discussed in the architecture review. The topic's compaction setting was not enabled, which meant events were subject to time-based deletion rather than key-based compaction. A secondary S3 export was configured to archive processed events to object storage, but the S3 archive was treated as a backup for disaster recovery, not as a primary data source for operational replay.
Fifteen months after the platform launched, a bug was identified in the foreign currency conversion projection — the read model that projected each merchant's current balance denominated in their payout currency. The bug had been present since launch: when a merchant's payout currency differed from the transaction currency, a rounding error in the conversion rate application accumulated across high-volume transaction histories. For merchants with low payout volumes the error was below the display precision threshold; for three large merchants processing over 50,000 transactions per month, the accumulated rounding error had produced ledger projections that differed from the correct value by between $0.83 and $4.17 over fifteen months. The values were small but the compliance implication was not: auditable accuracy to the cent was a contractual commitment to all merchants.
The fix required rebuilding the foreign currency conversion projection from scratch — replaying every payment event from the beginning to recompute the correct balances. The team's engineer assigned to the rebuild opened the Kafka consumer configuration to start a replay from offset zero on the payment events topic. The consumer reported that the earliest available offset was from eight days prior. Fifteen months of payment events — approximately 400 million events — had been deleted by the seven-day retention window. The team had assumed Kafka was the system of record for the payment event history. It was not. The system of record was the S3 archive, which contained the same events in JSON format compressed into hourly partition files.
The projection rebuild from the S3 archive required writing a custom replay pipeline: read hourly S3 partition files in chronological order, deserialize each event, apply the projection logic, and write the corrected balance to the projection store. The pipeline processed approximately 600,000 events per minute against an S3 source with 127 ms average read latency per partition file — the replay took eleven hours and twenty minutes to process the full fifteen-month event history. During the replay the corrected projection was built in parallel, isolated from production traffic. When the replay completed and the corrected balances were verified against an independent ledger reconciliation, the production read path was switched to the new projection. The three affected merchants received notifications with corrected balance history.
The team's retrospective identified the root decision: Kafka had been selected as the event store without reading the retention configuration, which meant the decision had been made implicitly against a system with a seven-day durability horizon rather than a permanent one. The S3 archive had been positioned as a backup rather than as the primary event journal — its throughput characteristics had never been evaluated as a replay source. After the incident, the team migrated the payment event stream to an append-only Postgres events table with no retention limit, with Kafka retained as a transport layer for fanout to downstream consumers but explicitly removed from the position of primary event store.
A 42-person SaaS company built an inventory management platform for mid-market retailers managing physical warehouse locations, with a real-time dashboard showing available stock counts, reservation holds, and in-transit quantities for each SKU across each location. The engineering team of twenty-one used event sourcing for the inventory aggregate — every stock adjustment, reservation creation, reservation release, inbound shipment receipt, and damage write-off was recorded as an event, and the available quantity for each SKU at each location was derived by projecting the event stream through a set of read models.
The team had built twenty-three distinct read-model projections from the inventory event stream over two years of feature development — each new dashboard feature, each new reporting view, and each new integration with external warehouse management systems had added a projection. The projections were built as Kafka consumer groups reading from the inventory events topic. No maximum acceptable lag specification had been written for any projection. In development and staging environments, where event volumes were low and the Kafka consumer groups had no rebalancing overhead, projection lag was consistently under 100 milliseconds. The team's understanding of the system's eventual consistency characteristics was shaped entirely by the development environment behavior.
In month twenty-six, the platform was processing approximately 380,000 inventory events per hour during peak warehouse activity windows — a volume that had grown from under 50,000 per hour a year earlier as the largest customers had expanded their warehouse footprint. Under this load, the Kafka consumer groups for the twenty-three projections began to experience rebalancing lag — when any consumer group member restarted, rebalanced, or was throttled by the broker, lag accumulated. The available stock projection — the most frequently read projection, showing current available quantities for all SKUs — reached a sustained lag of 2,400 milliseconds during peak hours. The team had no monitoring alert for projection lag and was not aware of the lag accumulation until the first customer escalation.
The escalation came from a customer support representative at the platform's second-largest customer account. The representative was assisting a warehouse operator who had adjusted a SKU's available count downward after a physical audit revealed a discrepancy — a 47-unit reduction on a high-velocity SKU. The adjustment was submitted through the warehouse operator's terminal, which wrote a stock adjustment event and displayed a confirmation message. The CS representative, on a support call with the warehouse operator, refreshed the inventory dashboard on a separate browser tab to verify the adjustment had been applied. The dashboard showed the pre-adjustment quantity. The representative checked again after thirty seconds. The dashboard still showed the pre-adjustment quantity. The representative opened an internal incident ticket: "Inventory dashboard showing wrong count after confirmed adjustment — possible data loss event." The incident ticket reached the on-call engineer who escalated it to P1, initiating a two-hour incident response that included a database inspection, a Kafka consumer group status check, and a message to the engineering lead — before the on-call engineer identified that the available stock projection's consumer group was running at 2,400 milliseconds lag and that the adjustment event had simply not yet been processed. The adjustment was visible in the projection 2.8 seconds after the incident was opened.
The two-hour P1 incident response was triggered not by a system failure but by a CS representative and an on-call engineer encountering eventual consistency behavior that no documentation in the system had prepared them to expect. The available stock dashboard had no indication that the displayed quantity was as of N seconds ago. The warehouse operator terminal confirmation message did not say "the adjustment has been recorded and will appear in the dashboard within a few seconds." The CS team had received no training on eventual consistency as a property of the inventory system. The on-call runbook did not include "projection lag exceeding N milliseconds" as a known condition. The decision to use event sourcing with eventually consistent projections had been made in an architecture review, documented in a design document that the platform's operational and customer support teams had not read. The eventual consistency contract existed in the engineering team's mental model and nowhere else.
A 51-person enterprise SaaS company built a workflow automation engine for compliance-driven business processes — employee onboarding, contract approval chains, procurement authorizations, and policy acknowledgment workflows for companies in financial services and insurance. The platform processed approximately 800,000 workflow events per day, with an event-sourced workflow execution aggregate that recorded every step transition, approval decision, delegation, escalation, and cancellation as an immutable event.
The event schema was defined in JSON with no schema registry. In the platform's first two years, the team had added four new event types to support new workflow features without encountering any backward compatibility problems — new event types were simply ignored by consumers that did not understand them, and the pattern had created a false impression that schema evolution was safe by default. In year three, an engineering manager proposed adding a performance analytics feature: a dashboard showing median and P95 step completion times across workflow types, to help compliance teams identify bottlenecks in their approval processes. The feature required knowing how long each workflow step had taken to complete. The relevant event was WorkflowStepCompleted, which recorded the step ID, the completing user, the decision outcome, and the workflow instance ID — but not the duration of the step. Duration had not been a tracked property when the event type was defined.
The engineer assigned to the analytics feature added a duration_ms field to the WorkflowStepCompleted event schema and updated the workflow execution service to populate the field. The change was reviewed and merged. There was no schema registry — the JSON structure was defined in a shared TypeScript types package versioned alongside the workflow execution service. Three downstream consumers read WorkflowStepCompleted events: the audit trail service, the notification service, and the compliance reporting service. All three consumed events through a Kafka topic, deserializing the JSON payload using the shared TypeScript types package. The TypeScript package was shared via a private npm registry with no enforced versioning — consuming services pinned to a major version, not a specific release.
The workflow execution service was deployed in the morning. By afternoon, two of the three consumers had automatically updated to the new package version through their nightly dependency update automation — a scheduled CI job that updated minor and patch versions without manual approval. The compliance reporting service had not updated. By the following morning, the compliance reporting service's consumer was failing on every WorkflowStepCompleted event: its deserialization logic treated any unexpected field as an error, a defensive coding pattern adopted after an earlier incident where a malformed event had caused silent data corruption. The service's Kafka consumer group stopped committing offsets. Topic lag accumulated. By the time the on-call engineer noticed the alert — consumer group lag exceeding 10,000 events — the compliance reporting projection for three enterprise customers was between 4 and 11 hours behind. The fix was a one-line change to ignore unknown fields during deserialization, but the consumer group had to replay from its last committed offset — a 4-to-11-hour event backlog per affected partition — before projections were current again.
The second problem emerged a week later, when the analytics feature was presented for review. The analytics dashboard showed step completion durations for workflow events in the prior week — since the duration_ms field had been added — but showed no data for the preceding 27 months. The historical WorkflowStepCompleted events in the event store had no duration_ms field. There was no upcasting strategy — no function that could transform a historical event missing duration_ms into an event with an estimated value. The analytics feature had been specced against the assumption that it would have access to the duration of every step completion in the event history. The event history did not contain that information and could not be reconstructed: the workflow execution service did not retain step start timestamps in any queryable form outside the event log itself. Reconstructing 27 months of step durations required correlating across two event types — WorkflowStepStarted and WorkflowStepCompleted — for 800,000 events per day, a query the analytics infrastructure could not perform at that volume with acceptable latency. The analytics feature launched with a note: "Duration data available from [date]. Historical data unavailable."
Structural properties set by the event sourcing decision
Three structural properties are determined when a team decides — or fails to explicitly decide — how to implement event sourcing: what the event store selection determines about projection replay durability as the event history grows beyond what a messaging system's retention window retains, what the projection design determines about the eventual consistency exposure gap as consumers built in low-volume environments encounter production-load lag and the system's consistency contract is communicated only through engineering documentation that operational teams have not read, and what the event schema evolution protocol determines about backward compatibility as producers add fields without a registry and consumers discover the change in production. None of these properties are labeled as decisions in the conversations that produce them. The event store selection emerges from a "we already have Kafka" infrastructure observation without reading the retention configuration. The projection consistency contract emerges from an architecture design document that does not travel to the teams that operate and support the product. The schema evolution protocol emerges from a shared types package with no enforced compatibility rules and no registration of consumers before a schema change is deployed.
Property 1: The event store selection and the projection replay durability contract. The event store is the system of record for the aggregate's history — not the projection store, not a derived state database, and not a backup archive. The system of record is the system read when a projection must be rebuilt from scratch: after a bug fix in the projection logic, after a new read model is added that requires the full event history, or after a projection is corrupted and must be restored. The durability properties of the system of record determine whether this rebuild is possible and how long it takes. Messaging systems (Kafka, NATS JetStream, AWS Kinesis, Google Pub/Sub) are optimized for throughput and delivery with configurable retention windows — they are not optimized for permanent, indexed, ordered event storage. Using a messaging system as the primary event store without a permanent storage backend means the event history is available only within the retention window, and the replay is constrained by sequential offset consumption rather than by a dedicated event store's indexed position seek. Dedicated event stores (EventStoreDB, a Postgres append-only events table, an S3-backed immutable journal with a Glue catalog) are optimized for ordered, indexed, and permanently persistent event streams; they trade throughput for durability and seek performance. The event store selection decision should specify: which system is the authoritative event store (the system read during projection rebuilds), the maximum event volume expected over the system's operational lifetime, the estimated projection rebuild time at that volume, and the acceptable rebuild time given the business's tolerance for projection staleness during a rebuild. Connect this property to the build artifact management decision record: the event store's snapshot archives, the schema registry state, and the projection store backups are artifacts that require the same versioning, retention, and integrity verification guarantees as compiled build artifacts; the artifact management decision specifies the storage backend, the retention policy, and the integrity verification model that ensures a projection rebuild from the event store produces a deterministic result across multiple replay runs.
Property 2: The projection design and the eventual consistency exposure gap. A projection is eventually consistent with the aggregate state by design — there is always a lag between when an event is written and when the projection reflects that event. The eventual consistency exposure gap is the set of product surfaces, operational procedures, and business processes that were built without awareness of this lag and will produce false incident escalations, incorrect decisions, or compliance failures when they encounter production-level projection lag. The gap is not an implementation bug — it is a communication failure: the eventual consistency contract was specified in the architecture design document and communicated to the engineering team, but it did not travel to the teams that operate the system, support customers, or build internal tooling on top of the projections. The projection design decision should specify three things beyond the projection's query interface: the maximum acceptable lag for each projection (a number in milliseconds, not "low" or "near real-time"), the monitoring alert threshold that fires when lag exceeds the acceptable bound, and the strong-consistency read path for business processes that cannot accept any lag — either a synchronous aggregate load from the event store or a read-your-writes guarantee implemented via a write-through cache keyed on the writing session. The eventual consistency contract should be expressed in the product layer, not just in the architecture layer: the UI should display the projection's staleness when lag exceeds a visible threshold, the API response should include a data_as_of timestamp for projection-backed responses, and the operational runbook should classify lag exceedance as a known condition distinct from data loss. Connect this property to the observability sampling decision record: projection lag is a derived metric — the difference between the latest event position in the event store and the projection consumer's current read position — that requires sampling the consumer group offset and the topic high watermark at the same instant; the sampling rate and the alerting threshold for lag exceedance are configuration decisions that belong in the observability sampling specification alongside tracing sample rates and metric cardinality limits; a projection lag alert that fires at 10,000 events behind is not useful if the consumer processes 50,000 events per second — the lag threshold should be expressed in seconds, not in event count.
Property 3: The event schema evolution and the backward compatibility failure mode. An event schema is a contract between the producer that writes events and the consumers that read them. Schema evolution — adding fields, removing fields, renaming fields, changing field types — is unavoidable as the product adds features, the domain model is refined, and analytics requirements surface new data needs. The backward compatibility failure mode occurs when a schema change is deployed without verifying that all existing consumers can parse the new schema. In a system without a schema registry, the verification is manual and dependent on the engineer making the change knowing which consumers exist and how they parse the event. In a system with a schema registry and enforced compatibility rules, the producer cannot register a schema with a breaking change until all registered consumers confirm compatibility. The event schema evolution protocol specifies four things: the schema registry (the system that tracks event schema versions and registered consumers), the compatibility rules (backward compatibility means new schema can read events written with old schema; forward compatibility means old schema can read events written with new schema; full compatibility means both), the upcasting strategy for historical events (the function that transforms events in schema version N to N+1 during replay, with explicit handling for fields absent in historical events), and the deprecation timeline (the window between announcing a breaking schema change and deploying it, allowing consumers to adapt). Without an upcasting strategy, adding a new field to an event type produces an event history split at the deployment date — events before the deployment are missing the field, events after have it, and analytics requiring the field for the full history cannot be backfilled. Connect this property to the database schema migration decision record: the projection store's database schema evolves in parallel with the event schema — when a new field is added to an event type, the projection builder must write the new field to the projection store, which may require a schema migration on the projection database; the coordination between the event schema version, the projection builder version, and the projection store schema version is a three-way migration that requires the same ordered deployment discipline as a zero-downtime database schema migration — the projection store schema must be backward compatible with the previous projection builder version before the new projection builder is deployed. The WhyChose extractor finds the event sourcing decisions buried in your AI chat history — the architecture review where the team debated Kafka as the event store and the question about retention configuration was not asked, the sprint planning where the analytics feature was scoped without asking whether duration_ms existed in the event history, and the on-call postmortem where the eventual consistency contract was identified as documentation that had not reached the CS team.
The event sourcing ADR: five sections
Section 1: Event store selection and durability specification. Specify the event store — the system of record for the aggregate's event history — with a written rationale that distinguishes the event store from the event transport. If a messaging system (Kafka, NATS JetStream, Kinesis) is used as part of the event infrastructure, specify whether it is the event store (authoritative, permanent, the source replayed during projection rebuilds) or the event transport (delivery mechanism for downstream consumers, with the event store held elsewhere). If the messaging system is the transport, specify the event store: the Postgres append-only events table schema and its index configuration, the EventStoreDB stream partitioning and retention policy, or the S3-backed immutable journal with its partition layout, object naming convention, and catalog configuration. For each candidate event store, document the replay throughput at the aggregate's expected ten-year event volume, the indexed seek performance for replaying from a specific event position (required for snapshot-based rebuilds), and the operational overhead of the storage backend. The retention policy for the messaging transport should be specified explicitly — not as the organizational default, but as a deliberate decision: if the retention window is seven days, document that projection rebuilds requiring events older than seven days must read from the event store, not from the transport, and verify that the pipeline for reading the event store is tested and its throughput is measured. Connect to the data residency decision record: the event store holds every event in the aggregate's history, which for a payment ledger or a workflow execution engine includes PII, financial data, and compliance artifacts; the data residency specification must cover the event store's storage location, replication configuration, and retention duration; event history that must be erasable under GDPR right-to-erasure requirements requires an encryption-at-rest model where the encryption key can be deleted to effectively erase the events (crypto-shredding), which must be specified in the event store selection before the store is provisioned.
Section 2: Projection design and consistency contract specification. For each projection built from the event stream, specify: the maximum acceptable lag in milliseconds at the expected production event rate, the monitoring alert threshold and the escalation path when lag exceeds the acceptable bound, and whether the projection has a strong-consistency read path for consumers that cannot accept any lag. The consistency contract for each projection should be expressed in three forms: the technical specification (maximum lag in milliseconds, alert threshold), the product specification (what the UI shows when lag exceeds the visible threshold — a timestamp showing data freshness, a banner, or a loading state), and the operational specification (what the on-call runbook says about lag exceedance — is it a known condition with a defined triage procedure, or is it indistinguishable from a data loss event?). The strong-consistency read path for writes that require read-your-writes guarantees should be specified as part of the projection design, not added after the first incident: a write-through cache keyed on the writing session ID and populated by the command handler before the command returns is the simplest implementation; a synchronous aggregate load from the event store on reads that require strong consistency is the alternative for aggregates with short event histories. Document the consumer groups reading the event stream and their rebalancing behavior at production event rates — consumer group rebalancing produces lag spikes bounded by the time to redistribute partitions across the group; at the expected production event rate, specify whether rebalancing-induced lag exceeds the acceptable bound and whether the consumer group configuration requires tuning (partition count, consumer count, max.poll.interval.ms) to stay within the bound. Connect to the API versioning decision record: projection-backed API responses are eventually consistent; the API versioning specification should include the consistency model for each response — whether the response is backed by a projection (eventually consistent, data_as_of header recommended), a synchronous aggregate load (strongly consistent), or a write-through cache (read-your-writes consistent for the writing session); clients that require strong consistency should use the strongly consistent endpoints, and the API documentation should specify the consistency model for each endpoint rather than treating all endpoints as equivalent.
Section 3: Event schema versioning and evolution protocol. Specify the schema registry, the compatibility rules, and the upcasting strategy before the first schema change is required, not after the first breaking change is deployed. The schema registry tracks: every event type name, every schema version for each event type, the compatibility rule for each event type (backward, forward, full, or none), and the registered consumers for each event type with their confirmed compatible schema version. The compatibility rule for event types written to a permanent event store and replayed during projection rebuilds should be backward compatible at minimum — new schemas must be able to read events written with old schemas — because historical events cannot be rewritten. The upcasting strategy specifies the transformation function for each schema version transition: when a projection builder reads a historical event in version N during a replay, the upcaster transforms it to version N+1 before the projection logic processes it; the upcaster specifies the default value for any field present in version N+1 but absent in version N, and the rationale for that default (null for unknown, a reconstructed approximation, or a sentinel value that downstream logic handles explicitly). The schema evolution process for adding a new field: (1) register the new schema version in the schema registry as backward compatible; (2) write the upcaster for the version N to N+1 transition, with an explicit default for the new field in historical events; (3) notify registered consumers of the schema change with a migration deadline; (4) deploy the new schema to the producer; (5) confirm that all registered consumers have confirmed compatibility; (6) if a future version will make the field required, schedule the breaking change for after all consumers confirm compatibility. Connect to the build artifact management decision record: the schema registry state — the current schema versions, the compatibility rules, and the registered consumers — is an artifact that should be versioned and backed up alongside the event store; a schema registry that is lost requires reconstructing the schema history from stored events; the artifact management specification should include the schema registry backup frequency, retention period, and restore procedure.
Section 4: Snapshot strategy and replay performance bound. Specify the snapshot strategy for each event-sourced aggregate based on two measured performance bounds: the maximum acceptable aggregate load time for command handling (the P99 time to replay the event stream and rebuild the in-memory aggregate state before applying a command) and the maximum acceptable projection rebuild time for the full event history. Measure both bounds at the expected production event volume before specifying the snapshot interval — the measurement will either confirm that snapshotting is not required at the current volume or will reveal the event count at which the load time exceeds the bound. The snapshot store specification covers: the storage backend (typically the same database as the projection store, or the event store if it supports snapshots natively), the serialization format for the aggregate state (which must be versioned alongside the event schema — a serialized snapshot in version N of the aggregate model cannot be loaded by a command handler running version N+1 without a migration), and the snapshot version tracking. The snapshot invalidation procedure specifies what happens when the aggregate model changes: if the new model is backward compatible with the previous model's serialized snapshots, existing snapshots can be loaded and the new model applied; if the new model is not backward compatible, existing snapshots are invalid and the aggregate load procedure must fall back to a full replay from event zero until the first valid snapshot is written by a command handler running the new model. Document the snapshot interval explicitly — not as "every N commands" by default, but as the interval derived from the performance bound: if the aggregate receives 1,000 events per day and the acceptable aggregate load time at the maximum replay volume is 500 milliseconds, the snapshot interval is determined by the event processing rate — at 10,000 events per second processing rate, a 500 ms bound allows replaying 5,000 events after the snapshot, so the snapshot interval is 5,000 events. Connect to the observability sampling decision record: aggregate load time under snapshotting and under full replay are distinct metrics that should be tracked separately — the snapshot hit rate (the proportion of aggregate loads that find a valid snapshot), the average events replayed per load after a snapshot, and the P99 load time with and without a snapshot hit provide the data needed to evaluate whether the snapshot interval is calibrated correctly for the current production event rate.
Section 5: Event sourcing scope boundary and aggregate selection. Specify which aggregates in the system will use event sourcing and which will use a conventional CRUD model, with a written rationale for each classification. Event sourcing is not appropriate for all aggregates — applying it universally adds event store operational overhead, projection management complexity, and schema evolution governance to every domain entity regardless of whether the entity has a business requirement for a complete, auditable event history. The classification criteria are: does the business require a complete, auditable, and unalterable record of every state change (payment ledgers, compliance workflows, medical record updates — yes; user preferences, session state, UI configuration — no), and does the entity have multiple distinct read models with different projections of the same state (a workflow execution aggregate projected as a current-status dashboard, a performance analytics dataset, and a compliance audit trail — yes; a user profile entity with one read model — no). For aggregates classified as event-sourced, specify the aggregate boundary — the set of domain entities and behaviors that are part of the aggregate — and the command handler's consistency guarantee: the aggregate boundary determines the transaction boundary for command handling (commands are atomic within the aggregate, not across aggregates), and the consistency guarantee specifies whether the command handler validates against the projection (eventual consistency risk) or against the aggregate's own event stream loaded from the event store (strong consistency, at the cost of an event store read per command). For aggregates classified as CRUD, specify the change log strategy if audit requirements exist: a change log table appended by database triggers or application-level hooks provides an auditable history without the event store operational overhead; the change log is not a first-class event stream and does not support projection rebuilds, but it satisfies audit requirements for entities where full event sourcing overhead is not justified. Connect to the database schema migration decision record: CRUD aggregates with change log tables and event-sourced aggregates with projection stores both require database schema migrations as the domain model evolves; the zero-downtime migration discipline — expand/contract, backward-compatible schema changes deployed before the application change — applies to both; the event sourcing scope boundary decision determines which part of the database schema is the authoritative state store (the CRUD table for CRUD aggregates, the events table for event-sourced aggregates) and which part is derived state (the change log for CRUD aggregates, the projection store for event-sourced aggregates).
FAQ
When is event sourcing the right choice over CRUD for a new service?
Event sourcing is the right choice for a service when two conditions hold simultaneously: the history of state changes is a first-class product requirement (not an audit afterthought), and the service has multiple distinct read models with different projections of the same underlying aggregate state. The history requirement means the business needs to answer questions about what happened, in what order, and under what conditions — not just what the current state is. Financial ledgers, compliance audit trails, inventory adjustment logs, and workflow execution histories are examples where the event stream is the primary artifact and current state is derived. If the history requirement can be satisfied by a change log or an audit table alongside a CRUD model, event sourcing adds architectural complexity without proportional benefit. The multiple read model requirement means the same aggregate state needs to be projected differently for different consumers — an order aggregate projected as a fulfillment queue view, a revenue dashboard view, and a customer history view. If there is only one read model or if all consumers read the same projection, the CQRS split that event sourcing enables does not reduce complexity. Against these conditions, the cost of event sourcing includes: the event store selection and operational overhead, the projection rebuild process and its latency when projections are added or corrected, the event schema evolution protocol and its enforcement across producers and consumers, and the eventual consistency exposure that requires all consumers to be designed with lag awareness. Teams that adopt event sourcing for a service that does not meet both conditions find that the operational overhead exceeds the development velocity cost of a CRUD model with a change log.
How do you rebuild a projection after a bug fix without extended downtime?
Projection rebuild after a bug fix follows a blue-green pattern: build the corrected projection in parallel, run it behind the existing projection until it catches up to the current event position, then swap consumers to the new projection. The procedure has five steps: (1) Provision a new projection store with the same schema as the existing projection — a new database table or a new index, isolated from the production read path. (2) Start the corrected projection builder reading from the event store from position zero, writing into the new projection store. The existing projection continues serving traffic throughout the rebuild. (3) Monitor the new projection builder's lag — the difference between the latest event position in the event store and the builder's current read position. Rebuild time is determined by the event volume and the builder's throughput; for event histories over 50 million events, rebuild time is measured in hours to days. (4) When the new projection catches up to within the acceptable lag threshold, pause writes to the old projection store, run the new builder one final pass, and swap the read path to the new projection store. (5) Keep the old projection store provisioned for the rollback window — typically 24 to 72 hours — in case the new projection has a different bug. The two prerequisites that determine whether this procedure is fast are the event store's replay throughput (a dedicated event store with indexed position seeks is significantly faster than an S3 archive requiring sequential file reads) and whether the projection builder can be parallelized across aggregate partitions. For aggregates with very large event histories, snapshot strategies reduce the rebuild time in proportion to the snapshot interval: if the event store contains snapshots every 5,000 events and the aggregate has 2 million events, the rebuild replays from the most recent snapshot rather than from event zero, reducing the replay volume by up to 99.75%.
How do you add a new field to an event schema without breaking existing consumers?
Adding a new field without breaking existing consumers requires three decisions made before the field is added: compatibility direction, default value for historical events, and consumer notification timeline. Compatibility direction: new fields should be optional with a default value, never required, because historical events in the store do not contain the field and any consumer that treats missing fields as errors will fail on replay. Forward-compatible schema changes add optional fields and specify defaults; backward-compatible changes ensure the new schema version can parse events written with the old version. Default value specification: when historical events are replayed, the new field is absent in events written before the schema change; the upcaster must specify what value to assign for the absent field; the correct default is domain-specific and must be decided before the schema change is deployed, not discovered by projection logic at runtime. Consumer notification timeline: consumers reading the event must be notified of the new field before any future version makes the field required; a schema registry that tracks registered consumers and their confirmed-compatible schema version provides the mechanism — the producer cannot register a breaking change until all registered consumers confirm compatibility. The historical data problem is separate from the backward compatibility problem: historical events can be read correctly using the upcaster, but they do not contain the data the new field was intended to capture; if analytics requires the field for the full history, this must be evaluated against whether the data can be reconstructed from other event fields before the schema change is committed.
What is the right snapshot strategy for event-sourced aggregates with long histories?
The snapshot strategy should be driven by two measured performance bounds. The first bound is the maximum acceptable aggregate load time for command handling — the P99 latency to replay the event stream and rebuild the in-memory aggregate state before applying a command. The second bound is the maximum acceptable projection rebuild time for the full event history. Measure both bounds at the current production event volume before adding snapshotting, and measure them again at the projected volume two years from now. If both bounds are met without snapshotting, snapshotting adds operational overhead for no performance benefit. If either bound is exceeded, the snapshot interval is derived from the bound: divide the acceptable number of events to replay after a snapshot by the daily event volume to get the maximum snapshot age in days. Snapshot version tracking is mandatory — a snapshot written by version N of the aggregate model cannot be loaded by version N+1 if the model changed in a backward-incompatible way; the snapshot load procedure must check the snapshot version against the current model version and fall back to a full replay from event zero if incompatible. This fallback is infrequent but must be tested regularly — an aggregate load procedure that has never been exercised in full-replay mode may have a bug that only surfaces when the snapshot is invalid.
Further reading
- Database schema migration decision record — the migration execution model, the zero-downtime constraint, and the schema drift detection gap that apply to both the event store's events table schema and the projection store's read model schema; when a new field is added to an event type, the projection store database schema must be migrated in a coordinated, backward-compatible sequence alongside the event schema and the projection builder version.
- API versioning decision record — the versioning scheme, deprecation window, and version sunset policy that apply to projection-backed API responses; the consistency model for projection-backed endpoints (eventually consistent, data_as_of header) versus aggregate-load-backed endpoints (strongly consistent) should be documented in the API versioning specification alongside the schema version compatibility rules.
- Data residency decision record — the data jurisdiction and right-to-erasure requirements that determine whether the event store requires crypto-shredding for GDPR compliance; an event store that retains PII in immutable event payloads must implement encryption-at-rest with per-subject key rotation so that deleting the encryption key effectively erases the subject's data without mutating the event log.
- Observability sampling decision record — the projection lag metric, its sampling rate, and the alert threshold that distinguish acceptable eventual consistency from a consumer group failure; projection lag expressed in seconds rather than event count requires sampling both the consumer group offset and the topic high watermark at the same instant, coordinated with the observability sampling configuration.
- Build artifact management decision record — the versioning, retention, and integrity verification model for the schema registry state, event store snapshots, and projection store backups; a schema registry that is lost requires reconstructing schema history from stored events, and a snapshot store that is lost requires a full projection rebuild from event zero; both are artifacts with the same reproducibility requirements as compiled service binaries.
- Open-source extractor — find the event sourcing decisions buried in your AI chat history: the architecture review where the team debated Kafka as the event store and the retention configuration was not discussed, the sprint planning where the analytics feature was scoped without asking whether the required field existed in the historical event log, and the postmortem where the eventual consistency contract was identified as documentation that had not reached the customer support team.