Decisions that hold, from model to frontend
A library has one copy of a book left, and two members press Borrow at the same moment. Each request reads the availability, sees one copy, decides the loan is fine and appends BookBorrowed. Each request was right about what it saw, and the library now has two loans for one copy.
Cratis lets the command say what it decided on, and the event log refuses the append if that changed. This tutorial builds that rule once, from the model to the screen. It follows the Dynamic Consistency Boundary idea in Chronicle and Arc.
The rule travels through five places:
Before you start
Section titled “Before you start”Every C# snippet on this page is an excerpt of a file in the Library/Lending sample, with the XML doc comments left out. The Screenplay model and the TypeScript in step 5 are written for this page and compiled on their own; they are not files in the sample.
- Clone Cratis/Samples and work from
Library/Lending. - Install the .NET SDK that the repository’s
global.jsonasks for (10.0.100 or a later feature band). - The commands need Arc 22.108.0 or later, which uses Chronicle 19.71.0 or later. Earlier Arc versions let a caller who sends the
X-Allowed-Severityheader atErrorskip a validator’s rule, which would make step 2 incorrect. - For step 1 you need the Screenplay compiler:
dotnet tool install --global Cratis.Screenplay.Tool.
1. Model the rule in Screenplay
Section titled “1. Model the rule in Screenplay”A Screenplay command can declare the state it decides against with reads. This model borrows a book only while availableCount is above zero:
concept Isbn : Stringconcept MemberId : Uuid
module Library
feature Lending
slice StateChange BorrowBook command BorrowBook isbn Isbn identifier memberId MemberId reads BookAvailability by isbn validate require BookAvailability.availableCount > 0 message "No copies of this book are available to borrow." produces BookBorrowed for isbn memberId = memberId
event CopyAdded isbn Isbn
event BookBorrowed memberId MemberId
event BookReturned memberId MemberId
projection BookAvailability => BookAvailability from CopyAdded key isbn increment availableCount from BookBorrowed decrement availableCount from BookReturned increment availableCountreads BookAvailability by isbn says that the command decides against that read model, looked up by the command’s isbn. The require line is the rule, and the projection is how the read model is folded from events.
Compile it with warnings as errors:
screenplay lending.play --warnaserror1 file(s) compiled - 0 error(s), 0 warning(s)The reads line is a declaration only. It records what the command consulted, so the model shows the read-model-to-command arrow of event modeling. It does not make the append conditional. What guards the append is Arc’s read protection, which you turn on in the next step in the code. The command reads protected preamble that would carry the guard through Screenplay is not part of a released Screenplay yet, so this page does not use it.
2. Write the Arc command and validator
Section titled “2. Write the Arc command and validator”The sample’s Lending project turns read protection on for every command in the project file. Arc’s default is Off:
<CratisReadProtection>On</CratisReadProtection>The command only says what happens. The rule is in the validator, which takes the read model as a constructor parameter:
[Command]public record BorrowBook(ISBN Isbn, MemberId MemberId, int BorrowDays = 21){ public BookBorrowed Handle() { var borrowedDate = DateTimeOffset.UtcNow; return new BookBorrowed(Isbn, "Book Title", MemberId, borrowedDate, borrowedDate.AddDays(BorrowDays)); }}
public class BorrowBookValidator : CommandValidator<BorrowBook>{ public BorrowBookValidator(BookAvailability? availability) => RuleFor(command => command.Isbn) .Must(_ => availability is { AvailableCount: > 0 }) .WithMessage("No copies of this book are available to borrow.");}Under On, Arc does not hand the validator a plain read. It notes the position of the event log, then reads BookAvailability fresh from the log for the command’s Isbn. Arc picks that key because ISBN is the only command property whose type matches the read model’s Id. It is null when the book has no events yet, which the rule treats as no copies.
When the validator passes, the command’s append carries a condition: if an event that BookAvailability folds arrives for that ISBN after the read, nothing from the command is written. The rule holds under On only. With Off, the same validator gives quick feedback and two requests can both pass.
Return events from Handle(). Under On, an event appended inside Handle through an injected event log is refused, because it would bypass the condition.
For key choice, which read models are admitted and what Arc refuses, see Reads and Refusals.
3. Fold the read model
Section titled “3. Fold the read model”BookAvailability is a read model that folds itself from events with static Reduce methods. An inventory event adds copies, and a reservation or a borrow takes one away:
[ReadModel]public record BookAvailability(ISBN Id, int AvailableCount = 0){ public static BookAvailability Reduce(BookAddedToInventory @event, BookAvailability? current, EventContext context) { var state = current ?? new BookAvailability(new ISBN(context.EventSourceId.Value)); return state with { AvailableCount = state.AvailableCount + @event.Count }; }
public static BookAvailability Reduce(BookReserved @event, BookAvailability? current, EventContext context) { var state = current ?? new BookAvailability(@event.Isbn); return state with { AvailableCount = state.AvailableCount - 1 }; }
public static BookAvailability Reduce(BookBorrowed @event, BookAvailability? current, EventContext context) { var state = current ?? new BookAvailability(@event.Isbn); return state with { AvailableCount = state.AvailableCount - 1 }; }
public static BookAvailability Reduce(BookReturned @event, BookAvailability? current, EventContext context) { var state = current ?? new BookAvailability(@event.Isbn); return state with { AvailableCount = state.AvailableCount + 1 }; }}The model is keyed by the book’s ISBN, which is also the event source the events are appended to. It is an unfiltered reducer without constructor dependencies, which is one of the shapes Arc can guard under On.
The events the model folds are also the events that guard the append: BookAddedToInventory, BookReserved, BookBorrowed and BookReturned for that ISBN. A reservation of the last copy therefore conflicts with a borrow of it. A rename of the book, or a borrow of a different book, does not.
A read model Arc cannot guard is refused under On with an error that names the fix. Joins, child collections and models keyed by an event property are examples. Keep the model flat and keyed by the event source.
4. Test the race
Section titled “4. Test the race”A spec for the rule alone checks the validator. The race needs a second writer between the command’s read and its append. CommandScenario.AppendConcurrently is that writer:
public class when_the_last_copy_is_borrowed_concurrently : Specification{ static readonly ISBN _isbn = new("978-0316389704"); readonly CommandScenario<BorrowBook> _scenario = new(); CommandResult _result;
void Establish() { _scenario.Given.ForEventSource(_isbn).Events(new BookAddedToInventory("The Last Wish", AuthorId.New(), 1)); _scenario.AppendConcurrently(_isbn, new BookBorrowed(_isbn, "The Last Wish", MemberId.New(), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(21))); }
async Task Because() => _result = await _scenario.Execute(new BorrowBook(_isbn, MemberId.New()));
void Destroy() => _scenario.Dispose();
[Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); [Fact] void should_report_a_conflict() => _result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.ConcurrencyViolation); [Fact] void should_append_nothing() => _scenario.AppendedEvents.Count.ShouldEqual(0);}The scenario seeds one copy, so the validator sees availability 1 and passes. The competing BookBorrowed is appended after the command’s read and before its commit. The command is refused with a concurrencyViolation and appends nothing. The competing event stays in the log, and AppendedEvents counts only the command’s own appends.
CommandScenario turns the decision reads on for you. The spec shows the race from the command’s side only: what the caller sees is in the next step.
The rule itself has its own spec, when_no_copy_is_available, and a second one proves that a caller cannot switch the rule off. A caller that sends X-Allowed-Severity at Error asks Arc to drop validation results at that severity. Because BorrowBook runs under On, Arc keeps the validator’s result anyway. The spec sends that severity through the command pipeline:
async Task Because() { var command = new BorrowBook(_isbn, MemberId.New()); await _scenario.Validate(command);
var provider = (IServiceProvider)typeof(CommandScenario<BorrowBook>) .GetField("_serviceProvider", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(_scenario)!; var pipeline = provider.GetRequiredService<ICommandPipeline>();
_result = await pipeline.Execute(command, provider, ValidationResultSeverity.Error); }
[Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); [Fact] void should_have_validation_errors() => _result.ShouldHaveValidationErrors(); [Fact] void should_append_nothing() => _scenario.AppendedEvents.Count.ShouldEqual(0);CommandScenario has no severity option, so the sample reaches into the scenario’s service provider to call the pipeline. That is a workaround in a spec, not an API to use in production code.
Run the Lending specs from the Samples repository root:
dotnet test Library/Lending/Lending.csproj --configuration Debug5. Handle the refusal in the frontend
Section titled “5. Handle the refusal in the frontend”The caller that loses the race gets an HTTP 400 with one validation result:
| Field | Value |
|---|---|
reason | concurrencyViolation |
reasonDetail | ReadChanged |
state | { readModel, key }, naming the read model and the key |
message | A developer diagnostic that says nothing from the command was written and to submit it again |
ReadIncomplete has the same reason and is also retryable. It means the read could not be completed after up to three attempts to fold it while events kept arriving. Nothing was written in either case, and Arc and Chronicle do not re-run the command. A failed rule, such as “no copies available”, has the reason rule instead.
ValidationResultReason.ConcurrencyViolation comes from @cratis/arc/validation. reasonDetail is a plain string with no TypeScript constant, so the two values are compared as strings. This function sorts one result into what the screen should do. BorrowBook is the proxy that a Debug build of the sample generates:
import { Guid } from '@cratis/fundamentals';import { ICommandResult } from '@cratis/arc/commands';import { ValidationResultReason } from '@cratis/arc/validation';import { BorrowBook } from './BorrowBook';
export type BorrowOutcome = | { kind: 'borrowed' } | { kind: 'availabilityChanged' } | { kind: 'refused'; messages: string[] } | { kind: 'failed' };
const decisionReadDetails = ['ReadChanged', 'ReadIncomplete'];
function isDecisionReadRefusal(result: ICommandResult<unknown>): boolean { return result.validationResults.some(validation => validation.reason === ValidationResultReason.ConcurrencyViolation && validation.reasonDetail !== undefined && decisionReadDetails.includes(validation.reasonDetail));}
export async function borrow(isbn: string, memberId: Guid): Promise<BorrowOutcome> { const command = new BorrowBook(); command.isbn = isbn; command.memberId = memberId;
const result = await command.execute(); if (result.isSuccess) { return { kind: 'borrowed' }; }
if (isDecisionReadRefusal(result)) { return { kind: 'availabilityChanged' }; }
const messages = result.validationResults .filter(validation => validation.reason === ValidationResultReason.Rule) .map(validation => validation.message); return messages.length > 0 ? { kind: 'refused', messages } : { kind: 'failed' };}What the screen does with each outcome:
| Outcome | Screen |
|---|---|
borrowed | Confirm the loan. |
availabilityChanged | Keep what the user entered. Refresh the availability, then offer to submit again. Do not clear the form and do not mark any field invalid: the input was fine, and the state moved. |
refused | Show the messages. A result with the reason rule carries copy that the rule’s author wrote for the user. |
failed | Show your own generic copy and check the server log. |
Only a rule result carries a message meant for the user. The message of a concurrencyViolation is a developer diagnostic in English: map it to your own copy, for example “The availability of this book changed while you were borrowing it. Check it and try again.”
With CommandForm from @cratis/arc.react, the same sorting goes in the onFailed callback, which receives the original ICommandResult. See Command results for the shape of the result and the other reasons.
Before the user submits again, re-read what they are deciding on. If you resubmit automatically, a loop can burn through retries on a book with no copies left; let the user see the new state first.
6. What is outside the guard
Section titled “6. What is outside the guard”The rule holds for this command under On. These are outside it:
- A writer without a guard. Another command, service or import that decides on availability without a protected read can still append a
BookBorrowed. The rule holds fully only when every writer that decides on it is guarded, or when it is a constraint. - Reads marked
[Unprotected]. Arc accepts an[Unprotected("reason")]read as an explicit exception. It guards nothing. - Revisions and redactions. Revising or redacting an earlier event does not move the position that a condition compares, so a decision made before the change can still commit.
- State outside the event log. A call to another service, or a value read from another database, is not part of the condition.
- Other event sequences. A read across event sequences is not guarded.
OffandMeasure. Both give plain reads.Measurereports whatOnwould do, and changes no result.- The conflicting event. The result names the read model and the key, not the event that moved the state.
The Arc read protection pitfalls page lists more cases. If the rule is only that something is unique or may happen once, a constraint is cheaper than a protected read.
Go deeper
Section titled “Go deeper”- The idea: The Dynamic Consistency Boundary and Keeping decisions correct under concurrency.
- If you know DCB, KurrentDB, Marten or Axon: Read this first.
- Arc: Getting started with read protection, Validators, Refusals and Test the race.
- Chronicle: How guards are derived and Read protection in Chronicle.
- The sample: Library/Lending and its README section on read protection.
- The write-side consistency learning path.
- The long form: the blog series Decisions that hold, from why the bug stays invisible to a decision guide and FAQ.