Dynamic Consistency Boundary in Marten, Part 1: The aggregate trap
Series: Dynamic Consistency Boundary in Marten
- 1 Dynamic Consistency Boundary in Marten, Part 1: The aggregate trap
- 2 Dynamic Consistency Boundary in Marten, Part 2: Implementation with plain Marten
- 3 Dynamic Consistency Boundary in Marten, Part 3: Less ceremony with Wolverine
- 4 Dynamic Consistency Boundary in Marten, Part 4: Production considerations
Marten supports the Dynamic Consistency Boundary (DCB) pattern for event-sourced systems. This is the first of a four-part series walking through what DCB is, how to use it with plain Marten, how Wolverine simplifies the handler code, and what to watch for when this goes to production.
This part covers the problem. The next three cover the solution.
The problem with one example
Consider a coupon code redemption with two rules:
- Coupon
SUMMER25can be redeemed at most 1000 times in total. - A single customer can redeem
SUMMER25at most twice.
We model the system using event sourcing and pick Customer as the aggregate. Each customer has their own event stream. We enforce the per-customer cap by reading the customer’s stream, counting prior CouponRedeemed events, and rejecting if the count is at the cap. Optimistic concurrency on the customer’s stream version prevents two concurrent writes for the same customer from both succeeding.
This works for the per-customer rule. It does nothing for the total cap. The total cap is a property of events spread across thousands of customer streams, and there is no single stream version to defend it with.
If we switch and pick Coupon as the aggregate, we get the opposite problem. The total cap becomes easy because every redemption lands in one stream. The per-customer cap becomes the racy one, because concurrent redemptions from different customers each see the same view of redemptions and decide independently.
One of the rules spans both entities, and a single event stream can only protect one of them.
A brief vocabulary refresher
If you have not worked with event sourcing in a while, here are the terms used through the series:
- Event sourcing: store the history of things that happened (
deposited $30,deposited $20) rather than the current state (balance is $50). State is computed by replaying events. - Event stream: an ordered, append-only list of events that belong together. Usually one stream per entity.
- Aggregate: a cluster of data treated as one unit for changes. In event sourcing, one aggregate is typically backed by one stream.
- Invariant: a rule that must always be true.
- Optimistic concurrency: a pattern for handling concurrent updates without locking. The writer reads with a version number, makes a decision, and only commits if the version is still what it read. Most event stores enforce this per stream.
The usual workarounds and what they cost
The traditional approaches to a rule like the total cap each have problems:
- Pick one aggregate and enforce the other rule with a saga. A saga is a background process that watches events and compensates if a rule is breached. This means a customer can briefly redeem past the cap, get a confirmation email, and then receive a “we are reversing your redemption” email a few seconds later. Eventually consistent in the bad sense.
- Roll both concepts into one aggregate. Build a
Promotionaggregate that holds the redemption count for every customer. Both invariants are now defended by one stream version. The price is contention because every redemption for the coupon now serializes on the same stream. - Wrap two streams in a database transaction. This gives up the per-stream isolation that event sourcing was supposed to provide and reintroduces row-level locking through the back door.
- Take a distributed lock around the command. Pull in Redis, take a lock keyed by coupon code, do the work, release the lock. New failure modes around lock expiry and clock skew, and now there is another system to babysit.
Each works to some extent. None feel right because none are right. The mismatch is that the consistency boundary belongs to the decision, not to the entity. With aggregate-based event sourcing the boundary is fixed at design time by which stream you write to. With DCB the boundary is declared per command by the events you query.
Dynamic Consistency Boundary
Sara Pellegrini’s “Killing the Aggregate” talk introduced the pattern. The idea in one sentence:
When appending new events, the writer specifies a query of events to defend, and the store atomically rejects the append if any new event matching that query has arrived since the writer read.
The mechanics:
- The command declares a query, typically by tag, describing the events relevant to its decision.
- The store reads matching events, projects them into a decision model, and records the current global sequence number as the read point.
- The command makes its decision in memory using the decision model.
- When the command commits new events, the store checks (in the same database transaction as the append) whether any new event matching the query has arrived since the read point. If yes, the commit fails. If no, it succeeds.
For our coupon, the redemption command declares a query for “events tagged with this coupon OR this customer.” It builds a CouponRedemptionGuard from those events. When it commits a CouponRedeemed event, the store guarantees no concurrent redemption for the same coupon or the same customer has slipped in. Both invariants are defended by a single atomic write. No saga, no mega-aggregate, no distributed lock.
The boundary is no longer baked into the stream layout you chose up front. It is declared per command, in code, and enforced by the store at commit time.
When DCB fits
DCB is the right tool when a business rule depends on events that belong to more than one entity and an eventually consistent enforcement would be a bug. Some examples:
- Coupon limits across customers and the system as a whole, as above.
- Hotel room booking against guest reservation caps.
- Seat allocation in a venue where the same seat cannot go to two different bookings.
- Course enrolment with cross-side capacity caps. This is the canonical example in the DCB academic writing.
- Username uniqueness, though for that case a Postgres unique index is usually simpler.
If the answer to “which aggregate owns this rule?” is “both, sort of”, DCB is likely the right answer.
What this series covers
This series uses a coupon redemption sample to walk through DCB end-to-end:
- Part 2 builds the redemption with plain Marten, writing each step of the consistency cycle by hand so the mechanics are visible.
- Part 3 rebuilds the same handler using Wolverine, which collapses most of the boilerplate.
- Part 4 covers production concerns: tag storage modes, query design, tag governance, when DCB is the wrong tool.
The companion sample code lives in the dcb-coupon-sample repo. Each post points at the relevant files and tests.
Further reading
- Sara Pellegrini, “A name for an idea: Dynamic Consistency Boundary”
- Marten DCB documentation
- dcb.events, a community-maintained reference site with implementations across several languages
Series: Dynamic Consistency Boundary in Marten
- 1 Dynamic Consistency Boundary in Marten, Part 1: The aggregate trap
- 2 Dynamic Consistency Boundary in Marten, Part 2: Implementation with plain Marten
- 3 Dynamic Consistency Boundary in Marten, Part 3: Less ceremony with Wolverine
- 4 Dynamic Consistency Boundary in Marten, Part 4: Production considerations