What if we could get rid of the aggregate in event sourcing? The aggregate gives strong consistency, and what happens the day a business rule has to reach across two of them. This post is about that, and about a pattern I’ve come to rely on called Dynamic Consistency Boundaries (DCB). The short version: instead of carving consistency into fixed structures at analysis time, you express it as a per-command predicate over your event log.
I’ll keep this at the level I’d want if I were picking this up myself: no safety net around event sourcing and aggregates, straight into the concurrency model, the storage side, and the trade-offs. There’s Go, because that’s what the store is written in. If you’d rather have the pitch first, this overview post about DCB (Dutch) walks the same idea without the code.
The aggregate as a static consistency boundary
In classic event sourcing you pick a bounded fragment of your domain (one customer, one order, one product) and give it its own event stream. The rule is simple:
The aggregate is the unit of strong consistency.
Enforcing it is straightforward. Every aggregate stream carries a monotonically increasing version. The command handler reads the stream up to version N, makes its decision against that snapshot, and appends new events only if the stream is still at version N. Plain optimistic concurrency:
append(stream, expectedVersion = N, events)
succeeds only if head(stream).version == N
otherwise Conflict, then reload and retry
That’s a compare-and-swap on a single stream head. Cheap, embarrassingly parallel (streams are independent), and it guarantees serializability of everything that touches one stream. The catch: two things that must always change together must live in the same aggregate. There’s no way around it.
The problem is that this boundary gets locked in during analysis, before any code runs. Whether a rule can be enforced atomically is decided by how you carved up your aggregates, not by the shape of the rule itself.
When the rule crosses the boundary
Take an order placement that has to check two things on its own:
- this product still has stock (the
product:123aggregate) - this customer is still within their credit limit (the
customer:456aggregate)
Those live in separate aggregates. There’s no single stream head to compare-and-swap against, so you can’t express “append order events iff both of these still hold” in one atomic write. A decision that’s conceptually one transition has been split into two.
The usual workarounds are all compromises:
The God aggregate. Fold everything into one giant stream. Now the rule is checkable, but you’ve serialized the whole domain on one lock. Two unrelated customers now contend for the same stream head, parallelism evaporates, and the aggregate turns into an object nobody wants to maintain.
Sagas / two-phase commit. Coordinate the two boundaries with a saga. You add coordinating events, a temporary inconsistent intermediate state that other readers can observe, and a compensation path for when the second leg fails. It works, but the machinery is real: an invariant that’s naturally atomic has become a distributed transaction by construction.
Re-carving the boundaries. If you guessed your granularity wrong, fixing it means re-streaming your event history into new streams. That’s a migration you don’t want to run against a live system.
DCB: consistency as a predicate, not a partition
DCB keeps the optimistic-append mechanism from classic event sourcing but changes what you compare-and-swap against. The unit of concurrency stops being “a stream” and becomes a query over event tags.
Concretely:
- Every event is tagged when it’s written. A
UserSignedUpevent carries a tag likeusername:alice. Tags are the raw material of your boundaries. - A consistency boundary is a predicate over tags (and event types). For a uniqueness rule, the predicate is “events tagged
username:alice.” - A command is a read-then-conditional-append. You read the events in scope up to position
P, make your decision, and append your new events with the condition “fail unless nothing matching my predicate has been appended since positionP.”
That last sentence is the whole trick, which is why the interface matters more than the plumbing.
The interface
Here’s the store interface the idea needs. It’s smaller than you’d expect, because the hard part lives entirely in AppendCondition:
package eventstore
import (
"context"
"errors"
"iter"
)
// AppendCondition enforces optimistic concurrency: the append fails with
// ErrAppendConditionViolated if the store contains any event matching Query with
// a sequence number strictly greater than After, i.e. someone changed relevant
// state since you last read it. After 0 checks against all events.
//
// Query uses the usual semantics, so a nil/empty Query matches EVERY event: such
// a condition fails if any event at all was appended after After (a global
// "nothing changed since" guard, rarely what you want for a scoped boundary).
type AppendCondition struct {
Query Query
After uint64
}
// ErrAppendConditionViolated is returned by Append when the AppendCondition is
// violated, meaning relevant state changed since the caller last read.
var ErrAppendConditionViolated = errors.New("append condition violated")
// Store reads and appends events.
type Store interface {
// Read returns an iterator over events matching the query with a sequence
// number strictly greater than after. Pass 0 to read from the beginning.
Read(ctx context.Context, query Query, after uint64) iter.Seq2[SequencedEvent, error]
// Append atomically persists events. If condition is non-nil, the append
// fails with ErrAppendConditionViolated when the store contains any event
// matching condition.Query with a sequence number greater than condition.After.
Append(ctx context.Context, events []Event, condition *AppendCondition) error
}
Three decisions are doing the work here, and they’re worth calling out:
1. The boundary is a Query, not a stream name. In classic event sourcing this type would be a stream ID plus an expected version; the boundary is baked into the data model. Here the boundary is an argument the caller constructs. The store has no notion of a “stream” at all, only tags and sequence numbers. That’s the whole point: the store can’t lock you into a fixed granularity because it never knew one.
2. After is a monotonic sequence, not a version. A per-stream version only tells you about that stream. A sequence number lets one condition cover events across many tags: you read “product:123 OR customer:456” and record a single high-water mark After. Classic optimistic concurrency would force you to check two version numbers separately, which is two races to worry about. Here the store compares After against any matching event; that’s the “did anything I depend on move?” check you actually want.
3. Query needs a scoped boundary. This isn’t hygiene, it’s a hard storage constraint. A nil or type-only query matches everything, which means there’s no boundary to lock on. Our store refuses those conditions, because enforcing them would serialize the whole type. Every condition has to carry at least one event-type-plus-tag combination you can lock on. I’ll come back to why in the storage section.
Making decisions with projections
The practical bit is that building a condition is usually driven by a projection, not by fiddling with raw events. We read the facts we need into a projection state, and BuildDecisionModel both fills that state and records the After position for us. The decision is then a plain check against the projection, and the append is guarded by the same boundary we read from.
Here’s user sign-up, where the invariant is “the username is unique”:
// signUp registers a new user, failing if the username is already taken.
func signUp(ctx context.Context, store eventstore.Store, username string) error {
exists := usernameExistsProjection(username)
condition, err := eventstore.BuildDecisionModel(ctx, store, &exists)
if err != nil {
return fmt.Errorf("could not build decision model: %w", err)
}
if exists.State {
return fmt.Errorf("username %q is already taken", username)
}
event, err := eventstore.NewEvent("UserSignedUp", username, eventstore.NewTags("username", username))
if err != nil {
return fmt.Errorf("could not create event: %w", err)
}
return store.Append(ctx, []eventstore.Event{event}, condition)
}
Follow the concurrency through, because the pieces line up neatly:
usernameExistsProjection(username)declares the query, scoped to the tagusername:<this name>.BuildDecisionModelreads the matching events, folds them into the projection (exists.State), and captures theAfterposition the append will check later.- If
exists.Stateis true, someone already signed up with this name and we reject. No conditional append needed. - Otherwise we create the
UserSignedUpevent, tagged with the sameusernameit guards, and append with the condition built in step 2.
The subtle part is that the event is tagged with exactly the same tag the uniqueness boundary locks on. Two concurrent signUp("alice") calls both read “no username:alice event present,” both build a condition with the same After, and only one wins the append. The loser gets ErrAppendConditionViolated and retries against a fresher read, where exists.State is now true. So “exactly one alice” falls out of the same guard that checks stock and credit. No reservation table, no separate uniqueness index, and atomicity spans however many tags the rule needs in a single guarded append.
The intro’s order example is the same pattern. Instead of a fixed aggregate that forces stock and credit into one stream, you model two projections (stock for product:123, outstanding credit for customer:456), build a decision model whose boundary spans both tags, decide against both states, and append the order event tagged with both product:123 and customer:456. Two invariants over two independent things, atomically enforced in one conditional append.
The storage contract
Everything above leans on a single guarantee, and the rest is implementation detail:
Atomic read-and-conditional-append. A command reads the events it needs, notes the position
After, and appends its new events such that the guard “no event matchingcondition.Queryhas a sequence greater thancondition.After” is evaluated atomically at the moment of write, with the event durably stored before the call returns. If relevant state changed since your read,AppendreturnsErrAppendConditionViolatedand you retry.
If that guard is ever evaluated against something other than the point of write, DCB silently turns into an expensive per-event race. Keep that one sentence in mind and every storage design becomes an answer to “how do I make this atomic under concurrency without going slow?”
We implement it on Postgres. The wrinkle there is sequence-number assignment: give each boundary its own lock and positions commit out of order, which breaks a plain monotonic cursor. My Postgres benchmark post goes through the locking strategies (global, per-boundary, hybrid, and the staging + flusher design we use) and shows that spread, human-paced workloads get single-digit-thousands of appends per second on managed Postgres. If you want the “how,” that’s the place to read it. Here it’s enough to know the guarantee everything depends on.
One consequence of that guarantee does shape your code, so I’ll pull it into this article: an append condition must be scoped to a real boundary. It needs at least one event-type-plus-tag combination (like UserCreated tagged user:123) to serve as the lock key. A bare type-level condition such as “no UserCreated exists anywhere” has no boundary to lock on, so we disallow it; it would force a global lock over the whole type and kill the per-boundary concurrency every fast strategy relies on. When you build decision models, scope them to real tags.
What becomes trivial
Once uniqueness is just “append failed because something touched my tag,” a few things stop being problems:
- Uniqueness constraints (at most one reservation for slot T, a username that can’t be registered twice) stop being separate reservation tables or read-model gymnastics. They’re a predicate over a tag (
slot:T,username:alice) that the append already checks. ThesignUpexample above is the whole pattern. - Exactly-once / double-submit. Tag with the idempotency key; a replayed submit is just a conflicting append.
- New invariants without re-streaming. A new rule over existing tags is a new query, not a new structure. Nothing to migrate. You only re-stream if you discover you failed to tag something you later need, which is why tag selection is design, not an afterthought. We measured this and it held up; see the DCB benchmark (Dutch).
The trade-offs
DCB isn’t free, and if you reach for it you should be able to say why it beats a saga for your particular invariant:
- Read amplification and hot tags. The guard is a query over your tags. A
customer:456that participates in half your writes becomes a contention hot spot, exactly like a single stream would. The skill is tagging just precisely enough: the boundary should be the minimal set of tags the rule truly needs. Tag too broadly (“this busy customer’s everything must always move together”) and writers pile onto the same tag and retry in lockstep, and your parallel throughput collapses to aggregate-class serialization. - Boundary-scoped conditions are mandatory. An empty or type-only query has no lock key, so it’s disallowed. That’s a real constraint on how you write decision models, not a style preference, and it follows directly from the storage contract above.
- The read and the append must share the atomicity boundary. If the position captured by
BuildDecisionModeland the guard insideAppendaren’t evaluated against the same moment of write, DCB silently degenerates into an expensive per-event race. Most of the painful production stories I’ve seen come from splitting this, not from the concept. - Retry is part of the contract. Because the guard can fail under real concurrency, handlers have to be written as read, decide, append, and on conflict re-read with idempotent decision logic. That’s a discipline, not a bug.
- It doesn’t fix distributed systems. DCB gives you atomicity across tagged scopes on one log. It doesn’t hand you multi-node linearizable transactions for free; if your boundaries span physical partitions you’ve shipped a distributed transaction and all its problems, whatever you call the boundary. Within a single node, where most event-sourced systems actually live, this is a non-issue.
The log already is the tagged, append-only substrate a predicate needs, and the read model is just another projection (Dutch) over it. You don’t add a consistency engine to an event-sourced system; you add tags at write time and a scoped query plus an After at command time.
Wrapping up
Event sourcing gives you a memory that grows with your domain. DCB makes the rules spanning several parts of that memory reliably enforceable, with an interface small enough to fit on a slide: Read(query, after) and Append(events, &AppendCondition{Query, After}). The boundary is no longer a structure you committed to during analysis; it’s a predicate you choose per command, built from a projection and guarded by a scoped condition. For a business rule that’s genuinely dynamic, that’s where the decision belongs.
I keep coming back to a simple test for a pattern: does it remove machinery? If you find yourself building a saga to stitch together two aggregates that a single invariant actually binds, it’s worth asking whether the boundary should have been a scoped query all along. The spec has the full details if you want them.