We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Concepts
Documentation
Dynamic Consistency Boundary (DCB) & CCC
DCB is ReckonDB’s primitive for atomic cross-stream conditional writes. It solves the question every event sourcing team eventually hits:
“I need to enforce an invariant that spans multiple aggregates, how do I do that without a saga, a dual-write, or a serialised bottleneck?”
This guide explains what DCB is, why the existing alternatives all have a cost, and how ReckonDB implements it.
The Problem
In a standard event-sourced system, optimistic concurrency is per-stream:
Stream "seat-B7" at version 3
→ seat_available_v1 (ver 0)
→ seat_held_v1 (ver 1)
→ seat_released_v1 (ver 2)
→ seat_reserved_v1 (ver 3) ← "append if version == 3"
This works perfectly for invariants within a single aggregate. The problem appears the moment an invariant spans streams.
A concrete scenario: seat reservation
A theatre has 800 seats. Each seat is its own aggregate stream. A buyer selects seat B7 and clicks “Book.” The system must guarantee that seat B7 is only booked once, even if two buyers click at exactly the same moment.
With per-stream optimistic concurrency:
Buyer A reads seat-B7 → available (ver 2)
Buyer B reads seat-B7 → available (ver 2)
Buyer A appends seat_reserved_v1 to seat-B7 if version == 2 ✅ succeeds, ver → 3
Buyer B appends seat_reserved_v1 to seat-B7 if version == 2 ❌ conflict (ver is now 3)
This works for a single seat. But the theatre also sells “row B, any seat” tickets, a buyer wants any available seat in row B. Now the invariant is:
“Reserve a seat in row B, but only if this buyer hasn’t already reserved another seat in row B on the same show.”
The per-stream check on seat-B7 doesn’t encode this. To check it, you need to
query across the entire show:42, row:B event space, potentially 40 different
stream IDs, and then write your reservation atomically against the result of
that query.
This is the cross-aggregate consistency problem. Every event sourcing team encounters it eventually.
The Existing Solutions (and Their Costs)
Saga / Process Manager
A saga reacts to the first seat_held_v1 event, checks inventory, and emits
seat_reserved_v1 or seat_rejected_v1. The consistency window is the time
between the hold event and the saga decision event.
T=0: Buyer A emits seat_held_v1 for row-B
T=0: Buyer B emits seat_held_v1 for row-B ← both succeed
T=1: Saga A processes Buyer A's hold, sees 1 seat left, emits seat_reserved_v1
T=1: Saga B processes Buyer B's hold, sees 1 seat left, emits seat_reserved_v1
T=2: Compensation: one of the two reservations is rolled back
The saga eventually corrects the state, but there is a window where both buyers think they have the seat. Compensation requires more events, more code, more tests. The system is never “wrong” in a data sense, but it IS wrong in a user-experience sense until the compensation fires.
Cost: Complexity. Every cross-aggregate invariant needs its own saga with its own compensation path. Sagas are the right tool for long-running process coordination; they are a blunt instrument for synchronous consistency enforcement.
Dedicated “Set” Stream
Model the cross-cutting constraint as its own stream:
Stream "show:42-row-B-reservations"
→ B1_reserved_v1 (ver 0)
→ B4_reserved_v1 (ver 1)
→ ...
Append to this stream with an expected version, and append to the seat stream in a separate step.
Cost: Two separate appends, not atomic. If the process crashes between them, you have a “set” event without a “seat” event (or vice versa). You need a cleanup saga to find and repair these half-states. You’ve also modeled your domain around a technical constraint rather than business reality.
Dual-Write with Database Constraint
Keep a seat_reservations table in a SQL database. Use a UNIQUE constraint to
enforce uniqueness. Write to the table AND the event store.
Cost: Two systems that can diverge. The UNIQUE constraint prevents the duplicate in SQL, but if the event store write fails after the SQL commit, you have a constraint with no event. Distributed transactions (2PC) are expensive and fragile. This is the dual-write problem: with two systems, you cannot atomically succeed or fail in both.
Serialised Command Handler
Run a single process that handles all reservations for a given show. It holds the full “row B seat availability” state in memory, serialises all writes, and appends atomically (nothing else can write while it decides).
Cost: Throughput bottleneck. A single process for all reservations for a popular show is a global lock. Under load this is a queue backed up to the ticketing front-end.
What DCB Is
DCB (Dynamic Consistency Boundary) is a store primitive: a conditional append that makes the consistency check and the write a single atomic operation.
The primitive has three parts:
1. The Filter
A tag filter is a logical expression over an event’s tag set and event type. It defines what events are “in scope” for the invariant check.
{any_of, ["show:42", "row:B"]} % any event tagged with show:42 OR row:B
{all_of, ["show:42", "row:B"]} % tagged with BOTH
{event_type, "seat_reserved_v1"} % event type == seat_reserved_v1
{and_, [Filter1, Filter2]} % both filters must match
{or_, [Filter1, Filter2]} % either filter must match
This algebra composes to arbitrary depth. The filters are evaluated using secondary indexes in ReckonDB, the store does not scan all events.
2. The Context Read
Before writing, you read the current state under the filter:
ReadDcbContext(store_id, filter, batch_size)
→ {events: [...], max_seq: N}
events are all events matching the filter in the DCB pseudo-stream, ordered by
their global sequence number. max_seq is the highest sequence number seen, this
becomes your cutoff for the write.
3. The Conditional Append
You write your events with the filter and the cutoff:
AppendIfNoTagMatches(store_id, filter, seq_cutoff, events)
→ Committed{last_seq} OR Conflict{max_seq}
The server checks: “has any event matching this filter been written since
seq_cutoff?” If no, the write succeeds atomically. If yes, the write is
rejected with a Conflict carrying the new max_seq.
This check and write happen inside a single Raft log entry. No other write can interleave between the check and the append. The invariant is enforced exactly once.
The Decision Loop
The canonical usage pattern is a retry loop:
decide(StoreId, Filter, DecideFun, Events) ->
decide(StoreId, Filter, DecideFun, Events, _Retries = 0).
decide(_StoreId, _Filter, _Decide, _Events, 10) ->
{error, too_many_retries};
decide(StoreId, Filter, DecideFun, Events, Retries) ->
{ok, #{events := Ctx, max_seq := MaxSeq}} =
evoq_decision:read_context(StoreId, Filter),
case DecideFun(Ctx) of
{ok, EventsToAppend} ->
case evoq_decision:append(StoreId, Filter, MaxSeq, EventsToAppend) of
{ok, _} = Committed ->
Committed;
{conflict, NewMaxSeq} ->
%% Re-read with updated cutoff and retry
decide_with_cutoff(StoreId, Filter, DecideFun, NewMaxSeq, Retries + 1)
end;
{error, _} = DomainRejection ->
DomainRejection
end.
In Go (using reckon-go):
for retries := 0; retries < budget; retries++ {
ctx, err := d.Read(rootCtx, dcb.MatchAny("show:42", "row:B"), 200)
if err != nil { return err }
// Domain logic: can we reserve a seat for this buyer?
events, ok := reservationDecision(ctx.Events, buyerID, showID)
if !ok { return ErrNoSeatsAvailable }
committed, conflict, err := d.Append(
rootCtx,
dcb.MatchAny("show:42", "row:B"),
ctx.MaxSeq,
events,
)
switch {
case err != nil:
return err // transport / backend failure
case conflict != nil:
continue // stale context, re-read and retry
default:
return committed // reserved
}
}
return ErrTooManyRetries
Conflict is not an error. It is a structured response meaning “the world changed
since you last looked, re-read and decide again.” Retries are expected and cheap.
The Filter Algebra
Leaf filters
{any_of, [Tag1, Tag2, ...]}
Matches an event whose tag set contains any of the listed tags.
{all_of, [Tag1, Tag2, ...]}
Matches an event whose tag set contains all of the listed tags.
{event_type, "type_name_v1"}
Matches an event whose event_type field equals the given string exactly.
Requires the event_type secondary index (ReckonDB 5.2.0+).
Payload leaves (CCC)
{payload_match, "field", "value"}
Matches an event whose payload field field equals value. Requires a declared
{payload, "field"} index.
{payload_hash_match, ["f1", "f2"], ["v1", "v2"]}
Matches an event whose payload fields f1, f2 equal v1, v2. Requires a declared
{payload_hash, ["f1", "f2"]} composite index.
These payload leaves are Command Context Consistency (CCC), covered in the next
section. They compose inside {and_} / {or_} exactly like the tag and event_type
leaves (ReckonDB 5.3.0+).
Compound filters
{and_, [Filter1, Filter2, ...]}
An event must satisfy all sub-filters.
{or_, [Filter1, Filter2, ...]}
An event must satisfy any sub-filter.
Examples
%% All seat reservations for show 42
{any_of, [<<"show:42">>]}
%% All reservations for show 42, row B specifically
{all_of, [<<"show:42">>, <<"row:B">>]}
%% Events of a specific type for show 42
{and_, [{event_type, <<"seat_reserved_v1">>}, {any_of, [<<"show:42">>]}]}
%% Registrations for a specific email (tag-based uniqueness)
{and_, [{event_type, <<"user_registered_v1">>}, {any_of, [<<"email:alice@example.com">>]}]}
%% Orders above a credit limit for a customer
{and_, [{event_type, <<"order_placed_v1">>}, {any_of, [<<"customer:99">>]}]}
Known v1 limitation
{or_, [BranchA, BranchB]} works correctly when both branches use tags, or when
the filter is used as a top-level {event_type, T} term. However, when one branch
is {event_type, T} and another is a tag-based filter inside an or_, the context
read may miss events that only match the event_type branch but carry none of the
tags from the other branch. Use {and_} to combine eventtype with tag filters,
or keep `{or}` branches homogeneous.
Payload-Conditioned Boundaries (CCC)
Tags and event_type are labels the producer attaches at write time. But sometimes
the value an invariant depends on already lives inside the event payload, and
tagging it just to make it filterable is ceremony you would rather skip.
Command Context Consistency (CCC) lets a DCB boundary query opaque payload fields directly. You declare which payload fields are indexed at store-creation time:
{payload, "license_key"} % single-field index
{payload_hash, ["realm", "email"]} % composite index over an ordered field set
and then a DCB filter can condition on them:
%% No two activations may share a license key, regardless of how they were tagged
Filter = {and_, [
{event_type, <<"license_activated_v1">>},
{payload_match, <<"license_key">>, <<"LK-7C4B9">>}
]}.
The check runs inside the same Raft log entry as the conditional append, so it carries the identical atomicity guarantee as a tag-based boundary. CCC simply widens what the boundary can read: from producer-applied labels to the event’s own data.
CCC fails loud. If a filter references a payload field with no declared index, the store rejects the operation rather than silently scanning. Declare the index, or the boundary will not run. The declared indexes are introspectable, so tooling (and the reckon-gateway admin UI) can show exactly which payload fields a store can condition on.
Reach for CCC over tags when the constraining value is intrinsic to the event (a license
key, an external account id, an idempotency key) rather than a domain label you control
at write time. Requires ReckonDB 5.3.0+ for the indexes and 5.4.0+ for the full
ccc_filter evaluation.
Worked Examples
1. Unique username registration
Invariant: No two users may register with the same username.
Tags on user_registered_v1 events: ["username:alice"]
Filter = {and_, [
{event_type, <<"user_registered_v1">>},
{any_of, [<<"username:alice">>]}
]},
DecideFun = fun
([]) -> {ok, [user_registered_event("alice", ...)]};
([_ | _]) -> {error, username_taken}
end,
decide(StoreId, Filter, DecideFun, []).
If alice is already taken, the context read returns the existing
user_registered_v1 event and the decision function rejects immediately, no
write, no conflict retry needed.
If alice is available, the write succeeds. If two registrations race, exactly one
will succeed atomically. The other will retry, find the context now non-empty, and
be rejected by DecideFun without a second store write.
2. Seat reservation (the full scenario)
Invariant: At most one confirmed reservation per seat per show. A buyer selecting “any available seat in row B” must get a seat atomically.
Tags on DCB events: ["show:42", "seat:B7"], ["show:42", "seat:B8"], etc.
reserve_any_seat_in_row(StoreId, ShowId, Row, BuyerId) ->
ShowTag = iolist_to_binary(["show:", ShowId]),
RowTag = iolist_to_binary(["row:", Row]),
Filter = {all_of, [ShowTag, RowTag]},
DecideFun = fun(Ctx) ->
ReservedSeats = [tag_value(<<"seat">>, Tags) || #{tags := Tags} <- Ctx],
AvailableSeats = all_seats_in_row(ShowId, Row) -- ReservedSeats,
case AvailableSeats of
[] -> {error, no_seats_available};
[Seat | _] ->
SeatTag = iolist_to_binary(["seat:", Seat]),
Event = #{
event_type => <<"seat_reserved_v1">>,
tags => [ShowTag, RowTag, SeatTag, <<"buyer:", BuyerId>>],
data => #{show => ShowId, seat => Seat, buyer => BuyerId}
},
{ok, [Event]}
end
end,
decide(StoreId, Filter, DecideFun, []).
Under contention, two buyers racing for the last seat in row B will both read
“one seat available,” both compute the same seat as their choice, and both attempt
to append. One will commit; the other will get Conflict, re-read a context now
showing the seat as taken, and get {error, no_seats_available} from DecideFun
without another write.
3. Credit limit enforcement
Invariant: Total placed orders for customer 99 must not exceed €5,000.
Tags on order_placed_v1 events: ["customer:99"]
Filter = {all_of, [
{event_type, <<"order_placed_v1">>},
{any_of, [<<"customer:99">>]}
]},
DecideFun = fun(Ctx) ->
CurrentTotal = lists:sum([maps:get(amount, E) || E <- Ctx]),
case CurrentTotal + NewOrderAmount =< 5000 of
true -> {ok, [order_placed_event(CustomerId, NewOrderAmount, ...)]};
false -> {error, credit_limit_exceeded}
end
end,
decide(StoreId, Filter, DecideFun, []).
Note that DecideFun computes the total from the context events. This is
domain logic on the context, not a database aggregation. The context events
are the ordered history of everything matching the filter.
Interaction with Per-Stream Optimistic Concurrency
DCB and per-stream optimistic concurrency are complementary, not alternatives.
Use per-stream OCC for:
-
Aggregate version checks (
if version == N, append) - “No concurrent modification of this aggregate” semantics
Use DCB for:
- Cross-stream invariants (“no event matching this filter since seq N”)
- Uniqueness constraints across aggregates
- Allocation / reservation across a set of resources
You can use both on the same write: a write to seat-B7 with expected_version=3
AND a DCB append that checks show-level seat availability. If either check fails,
the write is rejected.
Performance
DCB conditional appends are evaluated inside a single Ra/Raft log entry on the leader node. The cost is:
- Index lookup: O(matches), the tag and event-type indexes turn the filter into a list of matching sequence numbers without a full scan.
- Raft commit: the same latency as any other append (~5–25ms LAN, depending on cluster size and network).
- Retry cost: conflicts are rare when seq_cutoff is fresh. Under contention (many writers racing for the same filter), expect 1–3 retries on average.
DCB is not suitable for extremely high-frequency updates to the same invariant scope (e.g., a global counter incremented 10,000 times/second). It is designed for the rate at which domain invariants naturally change, hundreds to low-thousands of conflicting writes per second per filter scope.
Tagging Strategy
Tags are the primitive that DCB operates on. Your tagging design determines what invariants you can express.
Guidelines:
-
Tags should be domain concepts, not technical IDs:
"show:42"not"stream:show-uuid-abc123" - Tags should be stable: they are indexed at write time; changing a tagging scheme requires re-emitting events.
-
Use the narrowest possible filter in DCB to minimise context size and contention.
{all_of, ["show:42", "seat:B7"]}is more efficient than{any_of, ["show:42"]}when you only care about one seat. -
event_typefilters are the right tool when the invariant is about “what happened” rather than “to which resource”: uniqueness constraints, rate limits, registration deduplication.
Connection to the Research
DCB is based on work by Sara Pellegrini and Milan Savić, first presented at DDD Europe. Their key insight: the “optimistic locking” model can be generalised from a stream-version integer to an arbitrary query, any write that depends on reading some subset of the event log can be made atomic by attaching the query as a precondition to the write.
ReckonDB’s implementation takes their concept to production: the filter algebra is evaluated using secondary indexes (no full scans), the consistency check runs inside a Raft log entry (no gap between check and write), and the client-side Decision Loop (read → decide → append → retry on conflict) gives a clean programming model that composes with ordinary domain logic.
What DCB Is Not
DCB is not a general transaction mechanism. It cannot atomically write to two different stores, or to a database and an event store. It operates on a single ReckonDB store’s DCB pseudo-stream.
DCB does not replace sagas for long-running processes. If your invariant spans days or requires compensation across external systems, a saga is still the right tool. DCB handles synchronous consistency within a single write operation.
DCB does not serialize all writes. Only writes with overlapping filters contend with each other. A seat reservation for show:42 does not contend with a seat reservation for show:99. DCB scales horizontally with the number of distinct filter scopes.