Table of Contents

Class DecisionModelExtensions

Namespace
Opossum.DecisionModel
Assembly
Opossum.dll

Extension methods on IEventStore that implement the DCB read → decide → append pattern via Decision Model projections.

public static class DecisionModelExtensions
Inheritance
DecisionModelExtensions
Inherited Members

Methods

BuildDecisionModelAsync<TState>(IEventStore, IDecisionProjection<TState>, CancellationToken)

Builds a Decision Model by reading all events relevant to the given projection from the event store, folding them into state, and returning both the state and the AppendCondition that guards the decision against concurrent writes.

public static Task<DecisionModel<TState>> BuildDecisionModelAsync<TState>(this IEventStore eventStore, IDecisionProjection<TState> projection, CancellationToken cancellationToken = default)

Parameters

eventStore IEventStore

The event store to read from.

projection IDecisionProjection<TState>

The projection that defines the query, initial state, and fold function.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DecisionModel<TState>>

A DecisionModel<TState> containing the folded state and the append condition to enforce the Dynamic Consistency Boundary.

Type Parameters

TState

The decision model state type.

Remarks

This implements the DCB write-side pattern in a single call:

var model = await eventStore.BuildDecisionModelAsync(
    CourseProjections.Capacity(command.CourseId));

if (model.State.IsFull) return CommandResult.Fail("Course is at capacity.");

await eventStore.AppendAsync(newEvent, model.AppendCondition); // Throws AppendConditionFailedException if a concurrent write invalidated the model.

The AppendCondition is constructed automatically:

  • FailIfEventsMatch is set to Query — the same query used to read, ensuring only semantically relevant writes can invalidate the decision.
  • AfterSequencePosition is set to the maximum position of all loaded events, or null when the store contained no matching events.

Exceptions

ArgumentNullException

Thrown when eventStore or projection is null.

BuildDecisionModelAsync<TState>(IEventStore, IReadOnlyList<IDecisionProjection<TState>>, CancellationToken)

Builds a Decision Model from a runtime-variable list of homogeneous projections using a single event-store read. Returns one state per projection in the same order as the input list, plus the AppendCondition spanning all queries.

public static Task<(IReadOnlyList<TState> States, AppendCondition Condition)> BuildDecisionModelAsync<TState>(this IEventStore eventStore, IReadOnlyList<IDecisionProjection<TState>> projections, CancellationToken cancellationToken = default)

Parameters

eventStore IEventStore

The event store to read from.

projections IReadOnlyList<IDecisionProjection<TState>>

The list of projections to fold. Must not be empty.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<(IReadOnlyList<TState> States, AppendCondition Condition)>

A value tuple of (States, Condition) where States[i] corresponds to projections[i] and Condition spans all projection queries.

Type Parameters

TState

The shared state type for all projections.

Remarks

Enables the DCB runtime-variable-N pattern — useful when the number of projections is only known at command handling time (e.g. items in a shopping cart):

var projections = command.Items
    .Select(item => CourseBookProjections.PriceWithGracePeriod(item.BookId))
    .ToList();

var (states, condition) = await eventStore.BuildDecisionModelAsync(projections);

for (var i = 0; i < projections.Count; i++) { if (!states[i].IsValidPrice(command.Items[i].DisplayedPrice)) return CommandResult.Fail($"Price mismatch for book {command.Items[i].BookId}."); }

await eventStore.AppendAsync(newEvent, condition);

One ReadAsync(Query, ReadOption[]?, long?, int?) call is made with the union of all queries. Each projection then folds only the subset of events that match its own query. The single AppendCondition returned spans all projection queries — a concurrent write matching any query will invalidate the decision.

Exceptions

ArgumentNullException

Thrown when eventStore or projections is null.

ArgumentException

Thrown when projections is empty.

BuildDecisionModelAsync<T1, T2>(IEventStore, IDecisionProjection<T1>, IDecisionProjection<T2>, CancellationToken)

Builds a Decision Model from two independent projections using a single read against the event store. The combined query is the union of both sub-queries; each projection folds only the events that match its own query.

public static Task<(T1 First, T2 Second, AppendCondition Condition)> BuildDecisionModelAsync<T1, T2>(this IEventStore eventStore, IDecisionProjection<T1> first, IDecisionProjection<T2> second, CancellationToken cancellationToken = default)

Parameters

eventStore IEventStore
first IDecisionProjection<T1>
second IDecisionProjection<T2>
cancellationToken CancellationToken

Returns

Task<(T1 First, T2 Second, AppendCondition Condition)>

A value tuple of (First, Second, Condition) where Condition is the AppendCondition spanning both projection queries.

Type Parameters

T1
T2

Remarks

One ReadAsync(Query, ReadOption[]?, long?, int?) call is made with the union of both queries. Each projection then folds only the subset of events it cares about:

var (courseCapacity, studentLimit, condition) = await eventStore.BuildDecisionModelAsync(
    CourseProjections.Capacity(command.CourseId),
    StudentProjections.EnrollmentLimit(command.StudentId));

if (courseCapacity.IsFull || studentLimit.IsAtMax) return CommandResult.Fail("...");

await eventStore.AppendAsync(newEvent, condition);

The single AppendCondition returned spans both projection queries — a concurrent write matching either query will invalidate the decision.

Exceptions

ArgumentNullException

Thrown when any parameter is null.

BuildDecisionModelAsync<T1, T2, T3>(IEventStore, IDecisionProjection<T1>, IDecisionProjection<T2>, IDecisionProjection<T3>, CancellationToken)

Builds a Decision Model from three independent projections using a single read against the event store. The combined query is the union of all three sub-queries.

public static Task<(T1 First, T2 Second, T3 Third, AppendCondition Condition)> BuildDecisionModelAsync<T1, T2, T3>(this IEventStore eventStore, IDecisionProjection<T1> first, IDecisionProjection<T2> second, IDecisionProjection<T3> third, CancellationToken cancellationToken = default)

Parameters

eventStore IEventStore
first IDecisionProjection<T1>
second IDecisionProjection<T2>
third IDecisionProjection<T3>
cancellationToken CancellationToken

Returns

Task<(T1 First, T2 Second, T3 Third, AppendCondition Condition)>

A value tuple of (First, Second, Condition) where Condition is the AppendCondition spanning both projection queries.

Type Parameters

T1
T2
T3

Remarks

One ReadAsync(Query, ReadOption[]?, long?, int?) call is made with the union of both queries. Each projection then folds only the subset of events it cares about:

var (courseCapacity, studentLimit, condition) = await eventStore.BuildDecisionModelAsync(
    CourseProjections.Capacity(command.CourseId),
    StudentProjections.EnrollmentLimit(command.StudentId));

if (courseCapacity.IsFull || studentLimit.IsAtMax) return CommandResult.Fail("...");

await eventStore.AppendAsync(newEvent, condition);

The single AppendCondition returned spans both projection queries — a concurrent write matching either query will invalidate the decision.

Exceptions

ArgumentNullException

Thrown when any parameter is null.

ExecuteDecisionAsync<TResult>(IEventStore, Func<IEventStore, CancellationToken, Task<TResult>>, int, int, CancellationToken)

Executes the complete DCB read → decide → append cycle with automatic retry on optimistic concurrency failures.

public static Task<TResult> ExecuteDecisionAsync<TResult>(this IEventStore eventStore, Func<IEventStore, CancellationToken, Task<TResult>> operation, int maxRetries = 3, int initialDelayMs = 50, CancellationToken cancellationToken = default)

Parameters

eventStore IEventStore

The event store to pass into the operation.

operation Func<IEventStore, CancellationToken, Task<TResult>>

The delegate that performs the read → decide → append cycle. Receives the event store and a CancellationToken.

maxRetries int

Total number of attempts. Defaults to 3.

initialDelayMs int

Initial delay in milliseconds for the exponential back-off. Defaults to 50. Delay after attempt n is initialDelayMs × 2^n.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<TResult>

The result produced by operation.

Type Parameters

TResult

The return type of the operation.

Remarks

The operation delegate is retried whenever an AppendConditionFailedException is thrown (which includes ConcurrencyException, its subclass) — both indicate that another writer modified the relevant event stream between the read and the append. Retries use exponential back-off.

return await eventStore.ExecuteDecisionAsync(async (store, ct) =>
{
    var (capacity, condition) = await store.BuildDecisionModelAsync(
        CourseProjections.Capacity(command.CourseId), ct);
if (capacity.IsFull)
    return CommandResult.Fail("Course is full.");

await store.AppendAsync(enrollmentEvent, condition);
return CommandResult.Ok();

});

If all maxRetries attempts fail, the last exception is re-thrown so the caller can decide how to handle an exhausted retry budget.

Exceptions

AppendConditionFailedException

Re-thrown when max retries are exhausted due to append-condition failures (including ConcurrencyException subclass instances).