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
eventStoreIEventStoreThe event store to read from.
projectionIDecisionProjection<TState>The projection that defines the query, initial state, and fold function.
cancellationTokenCancellationTokenCancellation token.
Returns
- Task<DecisionModel<TState>>
A DecisionModel<TState> containing the folded state and the append condition to enforce the Dynamic Consistency Boundary.
Type Parameters
TStateThe 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
eventStoreorprojectionis 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
eventStoreIEventStoreThe event store to read from.
projectionsIReadOnlyList<IDecisionProjection<TState>>The list of projections to fold. Must not be empty.
cancellationTokenCancellationTokenCancellation token.
Returns
- Task<(IReadOnlyList<TState> States, AppendCondition Condition)>
A value tuple of
(States, Condition)whereStates[i]corresponds toprojections[i]andConditionspans all projection queries.
Type Parameters
TStateThe 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
eventStoreorprojectionsis null.- ArgumentException
Thrown when
projectionsis 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
eventStoreIEventStorefirstIDecisionProjection<T1>secondIDecisionProjection<T2>cancellationTokenCancellationToken
Returns
- Task<(T1 First, T2 Second, AppendCondition Condition)>
A value tuple of
(First, Second, Condition)whereConditionis the AppendCondition spanning both projection queries.
Type Parameters
T1T2
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
eventStoreIEventStorefirstIDecisionProjection<T1>secondIDecisionProjection<T2>thirdIDecisionProjection<T3>cancellationTokenCancellationToken
Returns
- Task<(T1 First, T2 Second, T3 Third, AppendCondition Condition)>
A value tuple of
(First, Second, Condition)whereConditionis the AppendCondition spanning both projection queries.
Type Parameters
T1T2T3
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
eventStoreIEventStoreThe event store to pass into the operation.
operationFunc<IEventStore, CancellationToken, Task<TResult>>The delegate that performs the read → decide → append cycle. Receives the event store and a CancellationToken.
maxRetriesintTotal number of attempts. Defaults to
3.initialDelayMsintInitial delay in milliseconds for the exponential back-off. Defaults to
50. Delay after attemptnisinitialDelayMs × 2^n.cancellationTokenCancellationTokenCancellation token.
Returns
- Task<TResult>
The result produced by
operation.
Type Parameters
TResultThe 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).