The idea underneath
A boundary per decision, not per entity.
A Dynamic Consistency Boundary derives the set of events a decision must be consistent with from a query, at the moment of the decision, rather than fixing it in advance by drawing a box around an entity. It is not a hekla idea: it comes from Sara Pellegrini's work, and hekla is one way of writing it down.
Rules do not respect your boxes.
Take the canonical example. A student subscribes to a course, and five things have to be true at the instant the subscription is recorded.
Where the aggregate breaks.
Two of those rules belong to the course and two to the student. One belongs to neither: it is about the pair, and it exists in the log rather than in either box. No single aggregate owns all five. Whichever entity you make the aggregate, the rest of the decision is outside its transaction, so it is either read stale or coordinated afterwards by a saga that compensates when it turns out to be wrong.
The usual escape is to grow the aggregate until it covers the rule. That works until the next rule spans a different pair, and it ends with one aggregate the whole application serialises on.
What the saga costs.
A compensating transaction is a second decision, written to undo a first one that should not have been made. It is more code, it is the code most likely to be wrong, and while it is in flight the system is in a state your domain does not have a name for.
None of that machinery is about the business. It exists because the boundary was drawn before the decision was known.
Derive the boundary from the read.
Every event carries a type and a set of tags. A query selects exactly the events a decision depends on, whichever entity they belong to, and that same query guards the append that records the decision. There is no aggregate to grow, because there is no aggregate.
// Every fact this application records. An event is written whole and never changes, and
// each field is a tag a command can fold on.
event @course.defined { course_id: Uuid, capacity: Int, title: CourseTitle }
event @student.registered { student_id: Uuid }
event @student.subscribed { course_id: Uuid, student_id: Uuid }
event @student.unsubscribed { course_id: Uuid, student_id: Uuid }const MAX_COURSES: Int = 10
command Subscribe(course_id: Uuid, student_id: Uuid) {
// Five folds, one pass over the log. Together they are this command's consistency
// boundary, and the append is conditioned on exactly them.
fold capacity: Int = 0
on @course.defined(course_id) { capacity } => capacity
fold taken: Int = 0
on @student.subscribed(course_id) => taken + 1
on @student.unsubscribed(course_id) => taken - 1
fold registered: Bool = false
on @student.registered(student_id) => true
fold subscribed: Bool = false
on @student.subscribed(course_id, student_id) => true
on @student.unsubscribed(course_id, student_id) => false
fold held: Int = 0
on @student.subscribed(student_id) => held + 1
on @student.unsubscribed(student_id) => held - 1
if capacity == 0 {
return reject NoSuchCourse
}
if !registered {
return reject NoSuchStudent
}
if subscribed {
return reject AlreadySubscribed
}
if taken >= capacity {
return reject CourseIsFull
}
if held >= MAX_COURSES {
return reject TooManyCourses
}
emit @student.subscribed { course_id, student_id }
}The five folds are one pass over the log. Together they are the consistency boundary: narrow slices spanning two entities and the pair they form, none of which is an aggregate.
The append carries those same slices as its condition. If any writer lands in one of them after the read, the append is rejected, the command re-folds against the new log and decides again.
Two students subscribing to different courses touch disjoint slices and never conflict. Two students racing for the last seat in the same course touch the same slice, and exactly one wins.
Nothing above configures a boundary. The command reads what it needs and the boundary is what it read, which is why the keyword is fold and not let. A declared boundary that can drift from the actual reads is a class of bug that does not exist here, because they are the same object.
Three things you stop writing.
The value is mostly negative space: work that a boundary drawn per decision simply does not create.
The saga
A rule that spans two entities is one command, so there is no second decision to compensate the first, and no in-between state the domain has no name for.
The aggregate argument
Nobody has to decide how big the box is, because there is no box. A new rule that spans a different pair of entities is a new fold, not a redesign.
The concurrency config
There is no version column to pick and no lock to scope. The condition is derived from the reads, so it is exactly as wide as the decision and no wider.
The cost, stated plainly
A boundary that spans entities is a boundary you cannot shard. Partitioning the log by tag would break exactly the cross-entity conditions the model exists to check, so a bounded context is one logical writer and you scale by running more contexts rather than by splitting one. A wide slice also costs what a wide slice costs: an allocation cap keyed on a shop means every order in that shop conflicts with every other, and the retry loop is what absorbs it. That is the price of enforcing a hard cap at append time instead of racing a read model.
The store has to agree.
A conditional append is only useful if the store can check the condition atomically against the whole log. tephra is built for it: every event carries a type and a set of tags, a single writer assigns each one a dense monotonic position so the log is one global order, and an append condition is checked against that order before the batch commits.
hekla embeds it as a library rather than running it as a server, so the check happens in-process with no network hop and no serialisation on the write path.