Dynamic Consistency Boundaries (DCB)
Experimental
DCB is a young specification and Nagare.Dcb tracks it closely. The API — attribute names, DecisionModel surface, condition shape — may evolve as the spec matures. Pin your version and read the changelog before upgrading.
Dynamic Consistency Boundaries replace the aggregate's stream-per-instance boundary with a single global, tag-indexed event log. Instead of loading "all events for aggregate X", a command declares which facts it depends on as a query over event types and tags, folds those events into a decision, and appends new events conditioned on "nothing matching my query happened since I looked". The invariant boundary is drawn per-command, not per-entity — that's what makes hard constraints spanning multiple entities enforceable by the database instead of by convention. See the DCB specification for the model Nagare implements.
Choosing your approach
Aggregates, DCB, and process managers answer three different consistency questions. Pick by where the invariant lives.
| Pick | When |
|---|---|
| Aggregate — stream-per-instance optimistic concurrency | The invariant fits inside one entity's stream: "a borrowed book can't be borrowed again." The 90% case. Single-stream reads, per-stream versioning, projections and read models as usual. |
| DCB — tag-query + conditional append on one global log | The invariant is a hard cross-entity rule the database must enforce: "a student takes at most 5 courses", "a course never exceeds capacity", "no double-booking this room". Correctness, not coordination. |
| Process manager — journaled coordinator between aggregates | The cross-entity concern is choreography, not an invariant: multi-step workflows, compensation, timeouts, waiting on external input. It reacts and dispatches; it doesn't enforce. |
The dividing line: if a concurrent pair of requests could both pass a stale check and you must prevent the second commit, that's DCB. If the second commit is fine as long as someone reacts to it, that's a process manager. If there is no second stream involved, that's an aggregate.
The two layers
Nagare.Dcb ships in two layers, mirroring the rest of the framework:
- The store layer —
IDcbEventStoreover a relational journal. Onenagare_dcb_eventstable with a global sequence position, onenagare_dcb_tagstable indexing every tag.ReadAsync(DcbQuery)streams matching events;AppendAsync(events, DcbAppendCondition)commits a batch only when no event matching the condition's query exists after the condition's watermark. This is the whole concurrency mechanism. - The decision-model layer —
DecisionModel<TCommand, TEvent, TState>, the DCB analogue ofAggregate. You declare the boundary as a function of the command, fold matching events into state, decide, and the runtime builds the append condition for you.[DcbTag]derives tags from the event payload; the stored type name defaults to the CLR type name, with[DcbEventType]as the rename-proof override.
Reach for the store layer directly only for tooling and migrations. Application code should live on the decision model.
Worked example — course subscriptions
The spec's canonical domain: students subscribe to courses. Three hard invariants, none of which fits in one aggregate stream:
- A student subscribes to at most 5 courses.
- A course has a fixed capacity.
- Re-subscribing is idempotent — asking twice must not persist twice.
Rule 3 could be an aggregate (a Subscription stream per student-course pair). Rules 1 and 2 can't — the count lives across other subscriptions' streams. This is where the aggregate model quietly gives up and DCB starts.
The example below is the behavioral fixture from the test suite (tests/Nagare.Dcb.Tests/CourseSubscriptions.cs), so what you read here is what the build runs.
Events and tags
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CourseCreated), "course-created")]
[JsonDerivedType(typeof(StudentRegistered), "student-registered")]
[JsonDerivedType(typeof(StudentSubscribedToCourse), "student-subscribed-to-course")]
public abstract record CourseEvent : IJsonable;
public record CourseCreated([property: DcbTag("course")] string CourseId, int Capacity) : CourseEvent;
public record StudentRegistered([property: DcbTag("student")] string StudentId) : CourseEvent;
public record StudentSubscribedToCourse(
[property: DcbTag("student")] string StudentId,
[property: DcbTag("course")] string CourseId) : CourseEvent;The event union is one polymorphic base record. The [JsonPolymorphic]/[JsonDerivedType] attributes aren't optional decoration: they drive the stored round-trip, and AddDcbDecisionModel uses them to exhaustiveness-check your event fold at registration — a union member without a fold fails at startup, not on first replay. The stored type name defaults to the CLR type name (CourseCreated); put [DcbEventType("...")] on a member when you want a persisted name that survives a class rename.
[DcbTag("course")] turns the property value into the tag course:{value} — a CourseId of "math-101" indexes the event under course:math-101. One event, two tags; the query side can now find "everything about this student" or "everything about this course" without a stream per either.
public abstract record CourseCommand;
public record CreateCourse(string CourseId, int Capacity) : CourseCommand;
public record RegisterStudent(string StudentId) : CourseCommand;
public record SubscribeStudentToCourse(string StudentId, string CourseId) : CourseCommand;The decision model
public record CourseState(
IReadOnlyDictionary<string, int> CourseCapacities,
IReadOnlyDictionary<string, int> SubscribersPerCourse,
IReadOnlyDictionary<string, int> CoursesPerStudent,
IReadOnlySet<string> Subscriptions) : IAggregateState<CourseState>
{
public static CourseState Default => new(
new Dictionary<string, int>(),
new Dictionary<string, int>(),
new Dictionary<string, int>(),
new HashSet<string>());
public bool CourseExists(string courseId) => CourseCapacities.ContainsKey(courseId);
public bool IsSubscribed(string studentId, string courseId) =>
Subscriptions.Contains(SubscriptionKey(studentId, courseId));
public int SubscriberCount(string courseId) =>
SubscribersPerCourse.TryGetValue(courseId, out var count) ? count : 0;
public int CourseCount(string studentId) =>
CoursesPerStudent.TryGetValue(studentId, out var count) ? count : 0;
public int CapacityOf(string courseId) => CourseCapacities[courseId];
public static string SubscriptionKey(string studentId, string courseId) => $"{studentId}@{courseId}";
}
public class CourseSubscriptions : DecisionModel<CourseCommand, CourseEvent, CourseState>
{
public const int MaxCoursesPerStudent = 5;
// The boundary is everything tagged with the command's own student/course ids: the
// fold sees exactly the subscriptions (and the course) the decision rules inspect,
// and the append condition fails on any concurrent event in that same scope.
protected override DcbQuery Boundary(CourseCommand command) => command switch
{
CreateCourse c => DcbQuery.Of(DcbQueryItem.OfTags($"course:{c.CourseId}")),
RegisterStudent s => DcbQuery.Of(DcbQueryItem.OfTags($"student:{s.StudentId}")),
SubscribeStudentToCourse s => DcbQuery.Of(
DcbQueryItem.OfTags($"course:{s.CourseId}"),
DcbQueryItem.OfTags($"student:{s.StudentId}")),
_ => throw new InvalidOperationException($"No boundary defined for {command.GetType().Name}."),
};
protected override DcbEventHandlers<CourseEvent, CourseState> RegisterEventHandlers() =>
Events
.On<CourseCreated>((state, evt) => state with
{
CourseCapacities = new Dictionary<string, int>(state.CourseCapacities)
{
[evt.CourseId] = evt.Capacity,
},
})
.On<StudentRegistered>((state, _) => state)
.On<StudentSubscribedToCourse>((state, evt) => state with
{
SubscribersPerCourse = Bump(state.SubscribersPerCourse, evt.CourseId),
CoursesPerStudent = Bump(state.CoursesPerStudent, evt.StudentId),
Subscriptions = new HashSet<string>(state.Subscriptions)
{
CourseState.SubscriptionKey(evt.StudentId, evt.CourseId),
},
})
.Build();
protected override DcbCommandHandlers<CourseCommand, CourseEvent, CourseState> RegisterCommandHandlers() =>
Commands
.On<CreateCourse>((state, cmd) =>
state.CourseExists(cmd.CourseId)
? Then.Reject("Course already exists")
: Then.Persist(new CourseCreated(cmd.CourseId, cmd.Capacity)))
.On<RegisterStudent>((_, cmd) => Then.Persist(new StudentRegistered(cmd.StudentId)))
.On<SubscribeStudentToCourse>((state, cmd) =>
{
if (!state.CourseExists(cmd.CourseId))
return Then.Reject("Course does not exist");
// Idempotent re-subscription: accept without persisting a duplicate.
if (state.IsSubscribed(cmd.StudentId, cmd.CourseId))
return Then.Accept();
if (state.SubscriberCount(cmd.CourseId) >= state.CapacityOf(cmd.CourseId))
return Then.Reject("Course is full");
if (state.CourseCount(cmd.StudentId) >= MaxCoursesPerStudent)
return Then.Reject($"Student is already subscribed to {MaxCoursesPerStudent} courses");
return Then.Persist(new StudentSubscribedToCourse(cmd.StudentId, cmd.CourseId));
})
.Build();
private static IReadOnlyDictionary<string, int> Bump(IReadOnlyDictionary<string, int> counts, string key) =>
new Dictionary<string, int>(counts)
{
[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1,
};
}Boundary(command) is the DCB-specific piece an aggregate doesn't have. For SubscribeStudentToCourse the answer is everything tagged with this student (the 5-course limit, the duplicate check) OR-combined with everything tagged with this course (existence and capacity). Each DcbQueryItem in a DcbQuery ORs; multiple tags inside one DcbQueryItem.OfTags(...) AND. The runtime reads exactly those events, folds them into state starting from CourseState.Default, runs your handler, and appends the resulting events with a condition built from the same query plus the read watermark: fail if anything matching this boundary landed since I read. When a boundary query constrains by event type, use the model's EventName<T>() helper instead of a string literal — it stays in sync with renames and [DcbEventType] overrides. Folding and deciding use the same builder idiom as aggregates: pure functions, no I/O, Then.Persist / Then.Reject / Then.Accept as the only ways out.
The three invariants are now checked against one consistent read and guarded by one conditional append. Two concurrent subscriptions to the last seat in a course: both read count = capacity − 1, both decide yes, the first append commits, the second's condition matches the first's event and fails with DcbAppendConditionFailedException. The database enforced the invariant, not a lock you remembered to take.
Registration and use
builder.Services.AddNagareSqliteStorage(databaseName: "courses");
builder.Services.AddSqliteDcbEventStore(); // IDcbEventStore — singleton, tables created lazily
builder.Services.AddDcbDecisionModel<
CourseSubscriptions, CourseCommand, CourseEvent, CourseState>();The store must be registered before the model: AddDcbDecisionModel builds and exhaustiveness-checks the model definition eagerly and throws if no IDcbEventStore is registered yet. The definition is a singleton; the repository is transient — the same shape as AddAggregate. The other engines ship the same one-liner: AddPostgresDcbEventStore, AddMySqlDcbEventStore, AddSqlServerDcbEventStore.
Sending a command:
var reply = await dcbRepository.Ask(
new SubscribeStudentToCourse("student-42", "math-101"));
if (reply.IsRejected)
return Results.Conflict();IDcbRepository<TCommand>.Ask returns Task<IReply>: it resolves the model, reads the boundary, folds, decides, and appends conditionally — the same shape as the aggregate repository, minus the stream id (the boundary is the identity). An optional IEventMetadata overload stamps metadata onto every persisted event. DcbAppendConditionFailedException propagates out of Ask — see The error contract.
Testing
InMemoryDcbEventStore backs DecisionModelTestHarness (both in Nagare.Testing), so decision-model tests need no database — same Given-When-Then shape as the aggregate harness. When runs the command through a real DcbRepository; the result asserts on the reply, the newly persisted events, and their stored form:
var result = await DecisionModelTestHarness<CourseSubscriptions, CourseCommand, CourseEvent, CourseState>
.For()
.Given(
new CourseCreated("c1", 2),
new StudentRegistered("s1"))
.When(new SubscribeStudentToCourse("s1", "c1"));
result
.ThenAccepted()
.ThenExpectSequence<StudentSubscribedToCourse>()
.ThenStoredTags(0, "student:s1", "course:c1");Rejections assert on the reason and the absence of new events:
var result = await DecisionModelTestHarness<CourseSubscriptions, CourseCommand, CourseEvent, CourseState>
.For()
.Given(
new CourseCreated("c1", 1),
new StudentRegistered("s1"),
new StudentRegistered("s2"),
new StudentSubscribedToCourse("s2", "c1"))
.When(new SubscribeStudentToCourse("s1", "c1"));
result
.ThenRejected("full")
.ThenExpectCount(0);Given serializes events exactly like the repository does — tags included — and folds through the same boundary query semantics the real store runs, so a tag you forgot to declare shows up as a failing test, not a production race. ThenStoredTags asserts the stored tag set directly — the one assertion with no aggregate-side twin.
Tagging discipline
Tags are the only index the store has. Everything a later consistency check needs must be a tag, and everything else must not be — every tag costs a row and widens potential condition conflicts.
- Format is
prefix:value.student:s1,course:c1. The prefix namespaces the tag so a bare id from one entity type can never collide with another's. - Derive tags from the payload.
[DcbTag]reads the property value; the tag is a fact about the event, not metadata attached later. If the tag can't be computed from the payload, it's probably not a stable identity. - No PII in tags. Tags are an index: they end up in query logs, condition-error messages, and any future tooling that dumps the tag table. Tag by id, never by email or name.
- Multi-tag events are the exception. Most events carry one tag. Two (like
StudentSubscribedToCourse) means the event genuinely belongs to two entities' histories. Three or more is a smell — you're widening every boundary that touches the event and paying for it in false-positive condition conflicts.
The error contract
DcbAppendConditionFailedException is a business outcome, not a transient fault. It means: the world changed between your read and your append, so your decision was made on stale facts. It carries the matching Query and the After watermark it was checked against.
- Never retry it inside a command handler. The decision is stale; re-running the same handler against the same folded state produces the same wrong answer. The store itself never retries it either — only genuine serialization failures get retried internally.
- The only correct reaction is to retry the whole command: re-read the boundary, re-fold, re-decide, re-append. Often the second read surfaces a domain-meaningful rejection ("course is full") that you return to the caller instead of an event.
- At the HTTP boundary, map it to 409 Conflict — or run the retry loop there if the command is cheap and the contention expected.
Append serialization per engine
The conditional append is a check-then-write; making it race-free is engine-specific. The relational base class serializes appends through an in-process semaphore (register the store as a singleton — AddSqliteDcbEventStore does this for you) and runs the condition check and batch insert in one transaction:
| Engine | Strategy |
|---|---|
| PostgreSQL | SERIALIZABLE transaction; serialization failures are retried (bounded attempts) since they're transient, unlike condition failures |
| MySQL | Named advisory lock via GET_LOCK around the append — InnoDB's SERIALIZABLE semantics make the transaction-level route deadlock-prone |
| SQL Server | SERIALIZABLE transaction |
| SQLite | Single-writer by construction; the in-process semaphore plus the file lock is sufficient |
Reads are always keyset-paged on the global sequence position and need no isolation gymnastics — they never feed an append directly (the watermark does).
The global-sequence caveat
The spec's FAQ is blunt about the trade-off, and so are we: a DCB journal is one global sequence on one writer. Sequence positions are unique and monotonically increasing (gaps allowed — never assume seq + 1 exists), which means appends serialize at the store. You cannot shard the journal by tenant or entity to scale writes, because a sharded sequence can't give you a global watermark to build conditions on. DCB's consistency guarantee is bought with append throughput. If your load profile is thousands of appends per second, keep the hot paths on aggregates and reserve DCB for the few genuinely cross-entity invariants.
Non-goals for this phase
Deliberately not built yet — don't design around them:
- No projections or subscriptions over the DCB journal.
ReadAsyncexists for decision-making, not for feeding read models. - No dashboard integration. The DCB journal doesn't appear in the operational UI.
- No archiving, no upcasting. The DCB event log is append-and-read only.
- Read models stay on the aggregate side. If a DCB decision model needs to project state for queries, that's a later phase; for now, read what you need through the boundary.
See also
- DCB specification — the model, the query/condition semantics, and the FAQ on the global sequence.
- Aggregates — the default write-side boundary.
- Process Managers — cross-entity choreography, as opposed to cross-entity invariants.
- Concurrency — the single-stream optimistic-concurrency story DCB generalizes.