Rules that need no guard
Use this page before you add a guard to a rule that does not need one. Each section says what you give up.
A rare violation you can repair
Section titled “A rare violation you can repair”| Rule | A flight may be overbooked briefly. A reactor cancels the reservations that exceed the capacity |
| Mechanism | The default append, and a reactor that appends a correcting event |
| What you give up | Between the violation and the repair the data is wrong. A repair cannot undo an irreversible side effect |
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Reactors;
[EventType]public record SeatReserved(Guid ReservationId);
[EventType]public record ReservationCancelled(Guid ReservationId, string Reason);
public class OverbookingRepair(IEventStore eventStore) : IReactor{ const int Capacity = 100;
[OnceOnly] public async Task<ReservationCancelled?> Reserved(SeatReserved @event, EventContext context) { var events = await eventStore.EventLog.GetFromSequenceNumber( EventSequenceNumber.First, context.EventSourceId); if (events.Any(_ => _.Content is ReservationCancelled cancelled && cancelled.ReservationId == @event.ReservationId)) { return null; }
var upToThis = events.Where(_ => _.Context.SequenceNumber <= context.SequenceNumber).ToList(); var active = upToThis.Count(_ => _.Content is SeatReserved) - upToThis.Count(_ => _.Content is ReservationCancelled);
return active > Capacity ? new ReservationCancelled(@event.ReservationId, "The flight was overbooked.") : null; }}Returning null records nothing. [OnceOnly] keeps the repair out of replays. It does not make delivery exactly-once, so the reactor skips a reservation it already cancelled, and it counts cancellations so a seat freed earlier is not held against a later reservation. A reactor is not a command, so it is not under read protection. Its own reads go through IEventStore and are not refused.
A hint
Section titled “A hint”| Rule | Show “no copies left” before the user submits |
| Mechanism | A query, or a validator over a read marked [Unprotected("why")] |
| What you give up | The hint can be stale. The rule is enforced elsewhere |
using Cratis.Arc.Chronicle.ReadModels;using Cratis.Arc.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections.ModelBound;using FluentValidation;
public record Isbn(string Value) : EventSourceId(Value);
[EventType]public record BookReserved();
public record Reservations( EventSourceId Id, [Increment<BookReserved>] int Count);
[Command]public record ReserveBook(Isbn Isbn){ public BookReserved Handle() => new();}
public class ReserveBookValidator : CommandValidator<ReserveBook>{ public ReserveBookValidator([Unprotected("hint only; the reservation limit is a constraint")] Reservations? reservations) => RuleFor(command => command.Isbn) .Must(_ => reservations is not { Count: >= 10 }) .WithMessage("This book has too many reservations.");}The reason is the part a reviewer reads. State what holds the rule instead.
External data
Section titled “External data”| Rule | A purchase uses the current price, and a book can be bought only while a copy is available |
| Mechanism | Provide() fetches the price. A read model parameter guards your own state. The price goes into the event |
| What you give up | The external value is not guarded. The event records what the decision used |
using Cratis.Arc.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections.ModelBound;using FluentValidation;
public interface IPriceList{ Task<decimal> PriceOf(EventSourceId bookId);}
public record QuotedPrice(decimal Amount);
public record BookId(string Value) : EventSourceId(Value);
[EventType]public record CopyStocked();
[EventType]public record CopySold(decimal Price);
public record CopiesInStock( EventSourceId Id, [Increment<CopyStocked>, Decrement<CopySold>] int Available);
[Command]public record SellCopy(BookId Book){ public async Task<QuotedPrice> Provide(IPriceList prices) => new(await prices.PriceOf(Book));
public CopySold Handle(QuotedPrice price) => new(price.Amount);}
public class SellCopyValidator : CommandValidator<SellCopy>{ public SellCopyValidator(CopiesInStock? stock) => RuleFor(command => command.Book) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies are left.");}IPriceList is an unknown dependency. Read protection guards the stock and nothing about the price.
A hot key with a long history
Section titled “A hot key with a long history”| Rule | A room accepts at most 20 bookings a month |
| Mechanism | Key the model by the period that bounds the rule, not by the room |
| What you give up | Nothing. A guarded read folds the key’s history every time, so keep it short |
using Cratis.Arc.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections.ModelBound;using FluentValidation;
[EventType]public record RoomBooked(Guid RoomId, DateOnly Day);
public record RoomMonth(EventSourceId Id, [Increment<RoomBooked>] int Bookings);
[Command]public record BookRoom(Guid RoomId, DateOnly Day) : ICanProvideEventSourceId{ public EventSourceId GetEventSourceId() => $"{RoomId:D}:{Day:yyyy-MM}";
public RoomBooked Handle() => new(RoomId, Day);}
public class BookRoomValidator : CommandValidator<BookRoom>{ public const int MaximumBookingsPerMonth = 20;
public BookRoomValidator(RoomMonth? month) => RuleFor(command => command.Day) .Must(_ => month is not { Bookings: >= MaximumBookingsPerMonth }) .WithMessage("The room is fully booked this month.");}Each month is a new event source, so each decision folds at most one month of bookings. The key must not be blank or *, must be trimmed, and must not contain #.