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:

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:

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:

  1. read events matching the supplied DecisionContext;
  2. decode and fold those events from the model’s initial state with evolve;
  3. call the model’s try function with the folded state and command;
  4. encode the proposed events;
  5. append them only while the observed context remains valid;
  6. run supported transactional subscriptions;
  7. commit and return globally ordered Recorded events.

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:

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:

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

Search Document