Class AppendCondition
Specifies an optimistic concurrency guard evaluated atomically during AppendAsync(NewEvent[], AppendCondition?, CancellationToken).
public class AppendCondition
- Inheritance
-
AppendCondition
- Inherited Members
Remarks
Purpose — preventing the lost-update problem
In an event-sourced system multiple writers can read the same events, build independent
decision models and then try to append new events. Without a guard, the second writer
silently overwrites a state that was already changed by the first writer (a classic
Time-Of-Check / Time-Of-Use race). AppendCondition closes this gap by letting the
writer declare which events would have invalidated its decision; the event store
enforces the check atomically and throws ConcurrencyException when
a conflict is detected.
The DCB read → decide → append pattern
Dynamic Consistency Boundaries (DCB) replace the traditional single-aggregate lock with a
query-scoped lock that spans exactly the events relevant to one business decision —
nothing more, nothing less. The pattern has three steps:
- Read – query the event store for all events relevant to the decision (e.g. all events for a specific course and student). Record the highest Position seen; that becomes AfterSequencePosition.
- Decide – fold the events into a decision model, validate business invariants (capacity, duplicate enrollments, etc.) and produce the events to append.
-
Append – call AppendAsync(NewEvent[], AppendCondition?, CancellationToken) with an
AppendConditionwhose FailIfEventsMatch is the same query used in step 1. The event store atomically re-runs the query restricted to positions > AfterSequencePosition and aborts with ConcurrencyException if any such events exist, because the decision model would have been different had those events been visible.
Example — enrol a student in a course
// Step 1 – Read
var query = Query.FromItems(
new QueryItem
{
EventTypes = [nameof(CourseCreatedEvent), nameof(StudentEnrolledToCourseEvent)],
Tags = [new Tag("courseId", courseId.ToString())]
},
new QueryItem
{
EventTypes = [nameof(StudentRegisteredEvent)],
Tags = [new Tag("studentId", studentId.ToString())]
});
var events = await eventStore.ReadAsync(query, ReadOption.None);
var lastKnownPosition = events.Length > 0 ? events.Max(e => e.Position) : (long?)null;
// Step 2 – Decide (apply business rules, build aggregate, etc.)
// Step 3 – Append
var condition = new AppendCondition
{
FailIfEventsMatch = query, // same query as step 1
AfterSequencePosition = lastKnownPosition // watermark from step 1
};
await eventStore.AppendAsync([enrollmentEvent], condition);
// Throws ConcurrencyException if any matching event appeared after lastKnownPosition.
Properties
AfterSequencePosition
The highest sequence position the client was aware of when building the decision model,
or null if no events were observed.
public long? AfterSequencePosition { get; set; }
Property Value
- long?
Remarks
The event store uses this value as an exclusive lower bound: events at positions less than or equal to this number are invisible to the conflict check. Only events stored after this position are tested against FailIfEventsMatch.
Important: this value is the watermark over all events returned by the read query, not just the last event of a specific type. It may therefore be higher than the position of the most recent event that directly affected the decision.
Two distinct behaviours:
- Populated — the append is rejected only when a new conflicting event appeared since the read. Concurrent writes that do not touch the same query domain are allowed through, keeping the consistency boundary as narrow as possible. This guarantee only holds when FailIfEventsMatch carries a scoped query — see the warning below.
-
null— no events have ever been seen for this query; the append is rejected if any matching event exists anywhere in the store. Use this when appending the very first event for a new entity to guarantee uniqueness.
Warning — do not combine with Query.All()
When FailIfEventsMatch has no query items (i.e. Query.All()),
the query itself is bypassed entirely. The check degenerates to a raw ledger-position
comparison: the append fails if any event at all was written by anyone since
the watermark, regardless of type or tags. This creates an implicit global write lock
and defeats the entire purpose of DCB. There is no valid domain use case for this
combination; if you need global write serialization, use an infrastructure-level
primitive (a SemaphoreSlim, a file lock, etc.) instead.
FailIfEventsMatch
The query that identifies events which would invalidate the current decision model.
public required Query FailIfEventsMatch { get; set; }
Property Value
Remarks
This should be the same Query that was used to read events when building the decision model. By reusing it, you guarantee that the decision model is still valid at the moment of the append: if any event matching this query appeared since you last read, your decision could be wrong and the append is aborted.
The conflict check is the conjunction of both properties
A conflict is only raised when an event satisfies both conditions at once:
- its position is > AfterSequencePosition, and
- it matches this query.
This is the key difference between DCB and naive position-based optimistic concurrency
control. A CourseRenamedEvent or a StudentAddressChangedEvent that was
appended after AfterSequencePosition will not cause a conflict,
because it does not match the enrollment query. The consistency boundary is defined
entirely by what this query selects — only events that are semantically relevant
to the decision can invalidate it. Unrelated writes are allowed through, keeping the
boundary as narrow as the business decision requires.
Scope of the check depends on AfterSequencePosition:
- When AfterSequencePosition is set, only events stored after that position are checked. This is the standard DCB pattern.
-
When AfterSequencePosition is
null, all events in the store are checked. This is useful for bootstrapping uniqueness invariants, e.g. "thisStudentRegisteredevent must be the very first event for this student id — reject if any already exists".