Skip to content

Scenarios

Use this page when you know the shape of your rule and want to see it as code. Each scenario names the rule, shows the shape that holds, and says what happens when two writers race. To pick a mechanism first, read Choosing how a rule holds. The examples use the .NET client.

RuleMechanismScenario
A username must be uniqueConstraintUnique username
An account can be opened onceConstraintCreate once
A member may have three loansDecision readA limit on one key
A course has seats, a student has a course limitTwo decision reads, one AppendManyA rule across two keys
The same rule in an ASP.NET Core endpointDecision reads in a unit of workIn an ASP.NET Core endpoint
Two people edit the same projectExplicit concurrency scopeAn editing conflict
Overbooking is rare and fixableRepair with a reactorA rare violation you can repair

A username must be unique. Declare it on the event and append as usual.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
using Cratis.Chronicle.EventSequences;
[EventType]
public record UserRegistered([property: Unique] string Username, string Email);
public static class UserRegistration
{
public static async Task<string?> Register(IEventLog eventLog, EventSourceId userId, string username, string email)
{
var result = await eventLog.Append(userId, new UserRegistered(username, email));
if (result.IsSuccess)
{
return null;
}
// The kernel rejected the append, so this holds for every writer, not only this one.
return result.HasConstraintViolations
? result.ConstraintViolations.First().Message.Value
: "The user could not be registered.";
}
}

View C# snippet source on GitHub

The kernel checks the constraint inside the append, before the event is written. Two requests that register the same username at the same time cannot both succeed: one append is rejected and returns a result with a constraint violation. Because the kernel enforces it, the rule also holds for a writer that is not this application.

Read the result. An append that fails a constraint does not throw. If you ignore the result, the registration is lost without an error.

Uniqueness is checked within one event sequence in one namespace. Two tenants in different namespaces can hold the same username. To let a user change their username and release the old one, or to release it when the user is removed, see model-bound constraints and declarative constraints. The reasoning is in Understanding constraints.

An account can be opened only once. Put [Unique] on the event type.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
[Unique]
public record AccountOpened(string Owner);

View C# snippet source on GitHub

The kernel accepts one AccountOpened per event source. A second one for the same event source is rejected with a constraint violation, even when two requests race. Do not write “does the account exist?” against a read model and then append: two requests can both see that it does not.

A member may have at most three active loans. The rule is a count over one key’s history, so use a decision read.

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
[EventType]
public record MemberRegistered(string Name);
[EventType]
public record LoanTaken(string Isbn);
[EventType]
public record LoanReturned(string Isbn);
[FromEvent<MemberRegistered>]
public record MemberLoans(
Guid Id,
[Increment<LoanTaken>]
[Decrement<LoanReturned>]
int Active);
public enum TakeLoanOutcome
{
Taken,
UnknownMember,
LimitReached,
ChangedConcurrently,
Failed
}
public static class TakeLoan
{
const int MaxActiveLoans = 3;
public static async Task<TakeLoanOutcome> Handle(IEventStore eventStore, Guid memberId, string isbn)
{
var member = await eventStore.GetDecisionReads().GetDetached<MemberLoans>(memberId);
if (member.Instance is not { } loans)
{
return TakeLoanOutcome.UnknownMember;
}
if (loans.Active >= MaxActiveLoans)
{
return TakeLoanOutcome.LimitReached;
}
var result = await eventStore.EventLog.AppendMany(
[new EventForEventSourceId(memberId, new LoanTaken(isbn))],
guardedBy: [member]);
if (result.GetDecisionConflicts([member]).Any())
{
// Another loan or return was appended for this member after the read. Nothing was appended.
return TakeLoanOutcome.ChangedConcurrently;
}
return result.IsSuccess ? TakeLoanOutcome.Taken : TakeLoanOutcome.Failed;
}
}

View C# snippet source on GitHub

What makes this work:

  • The read model is a flat projection keyed by the event source. MemberLoans has an Id property, which is the key. A decision read refuses a model without one. [Increment] and [Decrement] keep the count.
  • Every fact the rule needs is on the member’s event source. LoanTaken and LoanReturned are appended with the member’s ID as the event source ID. If you appended them on a loan’s own event source, the member’s model would not see them.
  • GetDetached captures the log’s tail before it folds, then returns the state and a guard.
  • guardedBy: [member] attaches the guard. If an event of a type the model uses (MemberRegistered, LoanTaken, LoanReturned) was appended for this member after the read, the append is rejected and nothing is written.
  • An event for another member does not conflict, and neither does an unrelated event type for the same member.

When the guard rejects the append, read again and decide again. Chronicle does not retry for you: the new state can change the answer.

Two cautions:

  • A GUID key must be in lowercase canonical form (D) in every writer, including writers that are not .NET.
  • If loans can start on a source nobody reads, a new key is in nobody’s guard. Keep the rule on the one key that carries the facts.

A course has a capacity, and a student may take at most five courses. Neither number lives in one place, so you need two reads and two events.

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
[EventType]
public record CourseDefined(string Name, int Capacity);
[EventType]
public record CourseCapacityChanged(int Capacity);
// Events of the course's own story.
[EventType]
public record CourseSeatTaken(Guid StudentId);
[EventType]
public record CourseSeatReleased(Guid StudentId);
// Events of the student's own story.
[EventType]
public record StudentJoinedCourse(Guid CourseId);
[EventType]
public record StudentLeftCourse(Guid CourseId);
[FromEvent<CourseDefined>]
public record CourseSeats(
Guid Id,
[SetFrom<CourseDefined>(nameof(CourseDefined.Capacity))]
[SetFrom<CourseCapacityChanged>(nameof(CourseCapacityChanged.Capacity))]
int Capacity,
[Increment<CourseSeatTaken>]
[Decrement<CourseSeatReleased>]
int Subscribed);
[FromEvent<StudentJoinedCourse>]
public record StudentCourses(
Guid Id,
[Increment<StudentJoinedCourse>]
[Decrement<StudentLeftCourse>]
int Subscribed);
public enum SubscribeOutcome
{
Subscribed,
UnknownCourse,
CourseIsFull,
StudentHasTooManyCourses,
ChangedConcurrently,
Failed
}
public static class SubscribeStudentToCourse
{
const int MaxCoursesPerStudent = 5;
public static async Task<SubscribeOutcome> Handle(IEventStore eventStore, Guid courseId, Guid studentId)
{
var reads = eventStore.GetDecisionReads();
var course = await reads.GetDetached<CourseSeats>(courseId);
var student = await reads.GetDetached<StudentCourses>(studentId);
if (course.Instance is not { } seats)
{
return SubscribeOutcome.UnknownCourse;
}
if (seats.Subscribed >= seats.Capacity)
{
return SubscribeOutcome.CourseIsFull;
}
if ((student.Instance?.Subscribed ?? 0) >= MaxCoursesPerStudent)
{
return SubscribeOutcome.StudentHasTooManyCourses;
}
// One fact, two event sources, two event types: each read model counts only the types of its own source.
var result = await eventStore.EventLog.AppendMany(
[
new EventForEventSourceId(courseId, new CourseSeatTaken(studentId)),
new EventForEventSourceId(studentId, new StudentJoinedCourse(courseId))
],
guardedBy: [course, student]);
if (result.GetDecisionConflicts([course, student]).Any())
{
// Either side changed after it was read. Nothing was appended for either source.
return SubscribeOutcome.ChangedConcurrently;
}
return result.IsSuccess ? SubscribeOutcome.Subscribed : SubscribeOutcome.Failed;
}
}

View C# snippet source on GitHub

Chronicle stores one event source ID per event, so one fact about two entities is written as two events, one per source. Where DCB tags one event with both entities, Chronicle records the fact once for each. DCB examples in Chronicle says when that is acceptable. The two events have their own types, CourseSeatTaken and StudentJoinedCourse, so each read model folds only its own. AppendMany writes them atomically: either both are appended or neither is. Both decision reads guard the same append. If another student takes the last seat, or the student subscribes elsewhere, after you read, the append is rejected and neither event is written.

GetDecisionConflicts tells you which read changed, so you can report “the course is now full” and not only “try again”.

In a web request, use the request’s unit of work: IDecisionReads.Get<T> enrolls each read, and Transactional.Append stages the events. The middleware that AddCratisChronicle registers starts a unit of work for every request. See Add Chronicle to an ASP.NET Core app.

using Cratis.Chronicle.AspNetCore.Transactions;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
public static class SubscribeEndpoint
{
public static void Map(IEndpointRouteBuilder routes) =>
routes.MapPost("/courses/{courseId:guid}/students/{studentId:guid}", Handle);
static async Task<IResult> Handle(
Guid courseId,
Guid studentId,
HttpContext http,
IDecisionReads reads,
IEventLog eventLog)
{
// Get enrolls each read in the request's unit of work.
var course = await reads.Get<CourseSeats>(courseId);
var student = await reads.Get<StudentCourses>(studentId);
if (course.Instance is not { } seats)
{
return Results.NotFound();
}
if (seats.Subscribed >= seats.Capacity || (student.Instance?.Subscribed ?? 0) >= 5)
{
return Results.UnprocessableEntity();
}
// Transactional.Append stages the event in the unit of work. A plain eventLog.Append
// would be written immediately and would not be guarded by the decision reads.
await eventLog.Transactional.Append(courseId, new CourseSeatTaken(studentId));
await eventLog.Transactional.Append(studentId, new StudentJoinedCourse(courseId));
// The middleware commits after the response is written, which is too late to report a
// conflict. Commit here, while the response can still change.
var unitOfWork = await http.Features.Get<IUnitOfWorkCompletionFeature>()!.CommitAsync();
if (unitOfWork.GetDecisionConflicts().Any())
{
return Results.Conflict();
}
return unitOfWork.IsSuccess ? Results.NoContent() : Results.UnprocessableEntity();
}
}

View C# snippet source on GitHub

Three details decide whether the rule holds:

  • Commit before you respond. The middleware commits after the action returns. By then the response is written, so a conflict cannot change it, and nothing tells the caller. Commit with IUnitOfWorkCompletionFeature.CommitAsync() and branch on the result. The middleware does not commit a completed unit again.
  • Stage with Transactional.Append. A plain eventLog.Append is written immediately and takes no part in the unit of work, so the decision reads would guard nothing.
  • Use Get for the unit of work and GetDetached otherwise. Get throws without an ambient unit of work.

For ownership rules and the full list of unit-of-work behavior see Decision reads and Transactions and unit of work.

Two people open the same project and both rename it. The second save should not silently replace the first. The decision is based on what the user saw, not on what the server read, so build the scope from the revision the user saw.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ProjectRenamed(string Name);
public static class RenameProject
{
// Capture this BEFORE you load the form data, then return it with the form. Load the data from the
// event log or a passive read model (a fresh fold), never from a materialized read model.
public static async Task<EventSequenceNumber> GetRevision(IEventLog eventLog, EventSourceId projectId)
{
var tail = await eventLog.GetTailSequenceNumber(projectId);
// A project with no events has no tail (Unavailable). An explicit scope skips that value, so
// return BeforeFirst: "no matching event had been appended when the user loaded the form".
return tail.IsUnavailable ? EventSequenceNumber.BeforeFirst : tail;
}
public static async Task<bool> Rename(
IEventLog eventLog,
EventSourceId projectId,
string name,
EventSequenceNumber revisionTheUserSaw)
{
// Reject if anyone renamed the project after the revision the user saw.
var scope = new ConcurrencyScopeBuilder()
.WithEventSourceId(projectId)
.WithSequenceNumber(revisionTheUserSaw)
.WithEventType<ProjectRenamed>()
.Build();
var result = await eventLog.Append(projectId, new ProjectRenamed(name), concurrencyScope: scope);
return result.IsSuccess;
}
}

View C# snippet source on GitHub

Order matters. Capture the revision first, then load the data for the form, and return the revision with the form. Load the data from the event log, a passive read model or a decision read, so it includes every event up to the captured revision. Do not load it from a materialized read model: it updates asynchronously and may lag behind the captured tail. The revision would then claim the user saw events the form never showed, and the scope would accept a save made over a change the user did not see. Send the revision back with the change. The scope rejects the append when a ProjectRenamed event for that project was appended after that revision. Narrowing to ProjectRenamed means another person editing a different field does not cause a conflict. List every event type that should invalidate the user’s view, and no others.

If the project has no events yet, the tail is Unavailable, which an explicit scope skips. The example turns it into EventSequenceNumber.BeforeFirst, which means “no ProjectRenamed event yet”. ExpectingNoMatchingEvent() or a constraint also work.

Selling a seat too many is rare, and you can fix it afterwards. Guarding every reservation costs a fold and can reject customers for a rare problem. Append normally, then let a reactor notice and append a correcting event.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.ReadModels;
[EventType]
public record ConcertAnnounced(string Name, int Capacity);
[EventType]
public record SeatReserved(string ReservedBy);
[EventType]
public record ConcertOverbooked(int Excess);
[Passive]
[FromEvent<ConcertAnnounced>]
public record ConcertSeats(
Guid Id,
int Capacity,
[Count<SeatReserved>]
int Reserved);
public class OverbookingRepair(IReadModels readModels) : IReactor
{
// A passive read model is folded when it is read, so it includes the reservation that triggered this call.
public async Task<ConcertOverbooked?> OnSeatReserved(SeatReserved @event, EventContext context)
{
var seats = await readModels.GetInstanceById<ConcertSeats>(context.EventSourceId);
return seats.Reserved > seats.Capacity
? new ConcertOverbooked(seats.Reserved - seats.Capacity)
: null;
}
}

View C# snippet source on GitHub

The reactor reads a passive read model, which is folded when it is read, so the count includes the reservation that triggered the call. It returns a new fact, ConcertOverbooked, and does not roll anything back. Something else, such as a person or another reactor, acts on that fact.

Repair has limits:

  • It appends after the violation. For some time the system holds an invalid state.
  • A side effect you cannot undo, such as a confirmation email, cannot be repaired by an event.
  • The repair is itself unguarded and can run more than once. Make the event safe to receive twice.

Use it only when a short, visible violation is acceptable. Otherwise use a decision read.

A test that never interleaves a competing writer cannot show the rule holds. Read, append a competing event, then do the guarded append.

using Cratis.Chronicle;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
public static class LoanRaceCheck
{
// Returns true when the guard rejected the second writer. Run it against a test event store
// that already holds a registered member.
public static async Task<bool> SecondWriterIsRejected(IEventStore eventStore, Guid memberId)
{
var member = await eventStore.GetDecisionReads().GetDetached<MemberLoans>(memberId);
// A competing writer appends after the read and before the guarded append.
await eventStore.EventLog.Append(memberId, new LoanTaken("978-0-13-468599-1"));
var result = await eventStore.EventLog.AppendMany(
[new EventForEventSourceId(memberId, new LoanTaken("978-0-596-52068-7"))],
guardedBy: [member]);
return result.GetDecisionConflicts([member]).Any();
}
}

View C# snippet source on GitHub

The method returns true when the guard rejected the second append. Run it against an event store that holds a registered member. A plain read model cannot fail this test, because it has no guard.