Dynamic Consistency Boundaries
Dynamic Consistency Boundaries (DCB) is an Event Sourcing technique for protecting exactly the facts used by a business decision. The boundary is selected for each command instead of being permanently tied to one entity or event stream.
Factos is built around this technique. This page starts from the problem DCB solves, then maps the idea to Factos types and backend behavior.
Start with the invariant
An invariant is a business rule that must remain true when a change is
accepted. Consider subscribing student s1 to course c1:
c1must exist;c1must have a free seat;s1must not already be subscribed toc1;s1must be subscribed to fewer than five courses.
The decision depends on course facts and student facts at the same time. The consistency boundary is therefore not “the course” or “the student.” It is the set of facts that can change the answer to this command.
Why a fixed stream can be awkward
A common Event Sourcing design gives each aggregate instance its own stream:
course-c1 stream student-s1 stream
---------------- -----------------
CourseDefined StudentRegistered
CapacityChanged StudentSubscribed...
StudentSubscribed...
An expected stream revision can prevent two writers from concurrently changing one stream. It cannot, by itself, make a decision based on both streams atomic. The design must choose another coordination mechanism: move the rule into a larger aggregate, reserve capacity, run a process manager, or accept temporary inconsistency and compensate later.
Those are valid designs when the business process needs them. They are overhead when the only requirement is “accept this one fact if these selected facts have not changed.”
DCB decouples the consistency boundary from the stream boundary.
One log, selectable facts
In DCB, events belong to an ordered log and expose store-visible event types and tags. One accepted event can carry every tag needed to find it later:
StudentSubscribedToCourse(s1, c1)
type: StudentSubscribedToCourse
tags: student:s1, course:c1
The event is not duplicated into a student copy and a course copy. A
course-oriented decision can select it with course:c1; a student-oriented
decision can select the same fact with student:s1.
Tags are an index over domain facts, not a second source of truth. They must be written consistently with the payload and kept stable enough for old history to remain selectable.
The boundary is a query
For the subscription command, Factos represents the relevant facts as a
DecisionContext:
factos.Matching(items: [
factos.Item(
types: [
factos.EventType("CourseDefined"),
factos.EventType("CourseCapacityChanged"),
factos.EventType("StudentSubscribedToCourse"),
],
tags: [factos.Tag("course:" <> course_id)],
),
factos.Item(
types: [factos.EventType("StudentSubscribedToCourse")],
tags: [factos.Tag("student:" <> student_id)],
),
])
Matching rules are precise:
- items are OR-combined;
- event types within one item are OR-combined;
- tags within one item are AND-combined;
- an empty type list means any type;
- an empty tag list adds no tag constraint.
The example asks for course-related facts or the student’s subscription facts. The model folds only those selected events into the temporary state needed for this command.
The read and write use the same boundary
Selecting the right history is only half of DCB. The same predicate must protect the append.
Suppose the backend reads matching events through global position 40:
Client A reads the context at position 40: one seat remains
Client B reads the context at position 40: one seat remains
Client A appends a matching subscription at position 41
Client B tries to append using its observation from position 40
Client B’s decision is now stale. Factos represents the required check as:
factos.FailIfEventsMatch(
decision_context: context,
after: factos.SequencePosition(40),
)
Because Client A’s new event matches the course context after position 40, Client B’s append cannot be accepted from the stale decision. Depending on the backend and configuration, the dispatch is retried from a fresh context or returns an append/store error. A retry sees the full course and returns the domain error instead of oversubscribing it.
This is optimistic concurrency over a predicate rather than over one fixed stream revision.
How Factos executes a command
A backend dispatch follows the same logical sequence:
- read events matching the supplied
DecisionContext; - decode and fold those events from the model’s
initialstate withevolve; - call the model’s
tryfunction with the folded state and command; - encode the proposed events;
- append them only while the observed context remains valid;
- run supported transactional subscriptions;
- commit and return globally ordered
Recordedevents.
The core package defines the types and pure operations. The storage backend is
responsible for implementing the context read and append guarantee correctly.
For example, factos_pog performs dispatch in a PostgreSQL SERIALIZABLE
transaction and retries serialization and deadlock conflicts up to its
configured limit.
Choosing a context
NoContext
Use NoContext only when no earlier event can change the answer. It reads no
history and represents an unconditional append.
Good example: recording an independently acceptable observation with an event id chosen outside the retrying decision.
It is not appropriate for uniqueness, capacity, “only once,” or existence checks.
Matching(items:)
Use Matching for the normal DCB case. Include every event type and tag
combination that can change the command’s answer, and no unrelated history.
A context that omits a relevant event can admit an invalid decision. A context that includes unrelated events remains correct but creates needless conflicts, reads, and retries.
AllEvents
Use AllEvents for a genuinely global invariant. It protects the complete log,
so any concurrent event can invalidate the decision. This is safe but usually
the least concurrent choice.
Do not use it merely to avoid designing stable types and tags.
DCB changes how aggregates are used
DDD aggregates are modelling boundaries for maintaining invariants. They remain useful language and design tools. DCB does not abolish entities, value objects, or bounded contexts.
What DCB removes is the requirement that every consistency boundary be one permanent aggregate stream. A command may select:
- facts about one entity;
- facts shared by several entities;
- facts about a value that is not an entity, such as a username;
- different subsets for different commands involving the same entity.
The boundary follows the decision. It can still coincide with a traditional aggregate when that is the natural shape of the rule.
Design rules that matter
Name business facts, not database mutations
StudentSubscribedToCourse explains what the business accepted.
SubscriptionRowInserted exposes a storage implementation and is less useful
for future decisions.
Treat event descriptors as a persisted contract
Event type names and versions drive decoding. Tags drive context selection. Changing them without a migration or compatibility plan can make old facts invisible or undecodable.
Keep decisions pure
A backend may retry a dispatch. evolve, try, encoding, and any retrying
transaction callback must be deterministic and free of external IO.
Generate unstable values outside the retrying decision or represent the need for
work as a durable event/subscription write, then perform external IO after
commit.
Separate decisions from views
A decision context should contain the smallest authoritative history needed to enforce an invariant. A user interface usually needs a different shape. Build projections for queries instead of widening command contexts into general read models.
Test the rule and the storage guarantee
Use factos/simulate to prove folding, decisions, errors, and codecs. Simulation
does not create database races. Use dispatch integration tests for the selected
backend to prove transaction isolation and concurrent behavior.
Costs and tradeoffs
DCB avoids some cross-stream coordination, but it is not free:
- the event store needs efficient type-and-tag indexes;
- every invariant depends on a complete, correct context predicate;
- hot predicates can still cause contention;
- global ordering and growing history require operational planning;
- event and tag evolution need long-term compatibility discipline;
- projections, checkpoints, and recovery remain application concerns.
DCB is most valuable when fixed write boundaries fight the actual business rules. It is not a reason to turn a straightforward CRUD system into an event log.
Continue learning
- Read the official DCB introduction and specification.
- Explore Factos’s runnable DCB examples: course subscriptions, unique usernames, invoice numbers, opt-in tokens, dynamic product prices, and record deduplication.
- See The Factos core model for the exact API vocabulary and Event Sourcing and command dispatch for the surrounding architecture.