DCB examples in Chronicle
Use this page when you know a canonical example from dcb.events and want the Chronicle form. Each section says what DCB does, what Chronicle does instead and why. Chronicle gives an event one event source and no tags in consistency, so several answers differ from DCB on purpose. The overview is in Dynamic Consistency Boundary in Chronicle.
| Example | Chronicle answer |
|---|---|
| Course subscription | Two decision reads, two events, one AppendMany |
| Unique username | A [Unique] constraint |
| Invoice number | A numbering event source with one decision read |
| Opt-in token | Both events on the sign-up’s event source |
| Dynamic product price | One decision read per product, one event on the order |
| Idempotency | A [Unique] constraint on the token |
Course subscription
Section titled “Course subscription”The rules. A course accepts at most N students, and a student joins at most five courses.
DCB. One StudentSubscribedToCourse event tagged with the student and the course. One decision model, five small projections composed, one append condition.
Chronicle. A rule that spans two entities needs a decision read per entity, because a decision read folds one event source. Take one read for the course and one for the student, then append one event of its own type to each event source in a single AppendMany with both reads in guardedBy. The append is atomic. If another student takes the last seat, or the student joins another course after you read, the append is rejected and neither event is written.
The code is the A rule across two keys scenario.
What this costs. The fact is recorded twice. That is acceptable here because each copy is a fact in its own entity’s story. Use two event types, as the scenario does: CourseSeatTaken and CourseSeatReleased on the course, StudentJoinedCourse and StudentLeftCourse on the student. Each read model counts only the types of its own source. Appending one type to both sources would also create rows keyed by the wrong ids, such as seat rows keyed by student ids. If you cannot accept two events for one fact, no Chronicle form removes the second one: the fact must be visible to two keys, and each key sees only its own event source.
Keeping it small. Each read folds only the events its rule needs: seats and capacity for the course, the student’s subscription count for the student. Do not build one wide model of both. That is DCB’s own advice, and in Chronicle a wide model also fails the read’s admission checks.
Unique username
Section titled “Unique username”The rule. A username is unique across all users.
DCB. Tag every event that claims or releases a username with username:<name> and query that tag in the decision model.
Chronicle. Declare a constraint. The kernel checks it inside the append, for every writer, without a read. There is nothing to guard and nothing to retry.
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."; }}Normalize the value before it reaches the event, as DCB does: jamesbond and JamesBond are two values to a constraint. To release a name when a user is removed or renamed, use model-bound constraints or declarative constraints. A decision read cannot do this job: it guards one key’s history, not a value across keys.
Invoice number
Section titled “Invoice number”The rule. Invoices get numbers that form an unbroken sequence.
DCB. Query every InvoiceCreated event, take the last number plus one, and append with a condition on the same query.
Chronicle. Make the sequence one event source. Every InvoiceCreated event is appended to a numbering event source with a fixed id. A decision read of the last number on that source guards the append. One event is written, on one source, and nothing is recorded twice.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.ReadModels;
[EventType]public record InvoiceCreated(int Number, string Customer);
// The decision model: the last number issued. Every invoice is created on one numbering event source.[FromEvent<InvoiceCreated>]public record InvoiceNumbering(string Id, [SetFrom<InvoiceCreated>(nameof(InvoiceCreated.Number))] int Last);
public static class CreateInvoice{ const string NumberingSource = "invoice-numbering";
public static async Task<int?> Handle(IEventStore eventStore, string customer) { var numbering = await eventStore.GetDecisionReads().GetDetached<InvoiceNumbering>(new ReadModelKey(NumberingSource)); var next = (numbering.Instance?.Last ?? 0) + 1;
// If another invoice was created after the read, the append is rejected and nothing is written. var result = await eventStore.EventLog.AppendMany( [new EventForEventSourceId(NumberingSource, new InvoiceCreated(next, customer))], guardedBy: [numbering]);
return result.IsSuccess ? next : null; }}Two requests that read the same last number both try to append. The second is rejected, and it reads again and gets the next number.
What this costs. The numbering source is a hot key. Every decision read folds its whole history, and every writer conflicts with every other, so two invoices cannot be created at the same moment. Both are inherent to an unbroken sequence. If the numbering restarts each year, use one numbering source per year to keep the history short. The invoice read model is keyed by the invoice number carried in the event, not by the numbering source, so later invoice events can live on the invoice’s own event source.
Opt-in token
Section titled “Opt-in token”The rule. A sign-up is confirmed once, with the token that was sent, before it expires.
DCB. Tag SignUpInitiated and SignUpConfirmed with the email and the token, and query both tags to find a pending sign-up.
Chronicle. Both events go on the sign-up’s own event source, identified by a sign-up ID that the confirmation link carries. The token is compared with what the initiating event stored, so it needs no place in the boundary. A decision read of that one key checks the token, the expiry and whether it is already confirmed, and guards the confirmation against a concurrent one.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.ReadModels;
[EventType]public record SignUpInitiated(string Email, string TokenHash, DateTimeOffset ExpiresAt);
[EventType]public record SignUpConfirmed;
// Both events live on the sign-up's own event source. The token is checked against the sign-up// the caller names, so the token itself needs no place in the boundary.[FromEvent<SignUpInitiated>]public record PendingSignUp(Guid Id, string TokenHash, DateTimeOffset ExpiresAt, [Count<SignUpConfirmed>] int Confirmations);
public enum ConfirmOutcome{ Confirmed, UnknownSignUp, AlreadyConfirmed, WrongToken, Expired, ChangedConcurrently, Failed}
public static class ConfirmSignUp{ public static async Task<ConfirmOutcome> Handle(IEventStore eventStore, Guid signUpId, string tokenHash, DateTimeOffset now) { var read = await eventStore.GetDecisionReads().GetDetached<PendingSignUp>(signUpId); if (read.Instance is not { } signUp) { return ConfirmOutcome.UnknownSignUp; }
if (signUp.Confirmations > 0) { return ConfirmOutcome.AlreadyConfirmed; }
if (signUp.TokenHash != tokenHash) { return ConfirmOutcome.WrongToken; }
if (signUp.ExpiresAt < now) { return ConfirmOutcome.Expired; }
// Two requests with the same valid token race: the second append is rejected. var result = await eventStore.EventLog.AppendMany( [new EventForEventSourceId(signUpId, new SignUpConfirmed())], guardedBy: [read]);
if (result.GetDecisionConflicts([read]).Any()) { return ConfirmOutcome.ChangedConcurrently; }
return result.IsSuccess ? ConfirmOutcome.Confirmed : ConfirmOutcome.Failed; }}Three details matter:
- Do not use the email as the event source ID. Event source IDs are stored in plaintext and cannot be encrypted. A sign-up ID carries no personal data.
- Store a hash of the token in the event, or mark the property
[Encrypted]. See encrypted values. - If the caller has only an email, not a sign-up ID, look the ID up in a read model first. That lookup is eventually consistent and only finds the key. The decision read of the key is what holds the rule.
Dynamic product price
Section titled “Dynamic product price”The rule. An order is accepted only at a price that is valid now, or valid recently when the price just changed. An order can contain any number of products.
DCB. Build one decision model per product in the cart when the order is placed, each tagged with its product, and compose them into one condition.
Chronicle. This is a reduced variant of DCB’s rule: the model keeps the current price and the one before it, and accepts the displayed price when it is the current one or the previous one within the grace period. DCB accepts any price within the grace window, even after several changes. Take one decision read per product in the cart, and append one event on the order’s own event source with all the reads in guardedBy. The product keys are only read, never appended to. If any product’s price changed after you read it, the append is rejected and the order is not written.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.ReadModels;
[EventType]public record ProductDefined(decimal Price);
[EventType]public record ProductPriceChanged(decimal OldPrice, decimal NewPrice);
public record OrderedItem(string ProductId, decimal DisplayedPrice, int Quantity);
[EventType]public record ProductsOrdered(IReadOnlyList<OrderedItem> Items);
// The decision model for one product: its current price, the one before and when it changed.// A reduced variant: a price older than the previous one is never accepted, even inside the grace period.[FromEvent<ProductDefined>]public record ProductPrice( string Id, [SetFrom<ProductDefined>(nameof(ProductDefined.Price))] [SetFrom<ProductPriceChanged>(nameof(ProductPriceChanged.NewPrice))] decimal Current, [SetFrom<ProductPriceChanged>(nameof(ProductPriceChanged.OldPrice))] decimal Previous, [SetFromContext<ProductPriceChanged>(nameof(EventContext.Occurred))] DateTimeOffset ChangedAt);
public static class OrderProducts{ static readonly TimeSpan GracePeriod = TimeSpan.FromMinutes(10);
public static async Task<bool> Handle(IEventStore eventStore, EventSourceId orderId, IReadOnlyList<OrderedItem> items, DateTimeOffset now) { var reads = eventStore.GetDecisionReads(); var guards = new List<IDecisionRead>();
// One decision read per product in the cart. The set is dynamic: it is whatever the order contains. foreach (var item in items) { var read = await reads.GetDetached<ProductPrice>(new ReadModelKey(item.ProductId)); if (read.Instance is not { } price) { return false; }
var isCurrent = price.Current == item.DisplayedPrice; var isRecentPrevious = price.Previous == item.DisplayedPrice && now - price.ChangedAt <= GracePeriod; if (!isCurrent && !isRecentPrevious) { return false; }
guards.Add(read); }
// One event on the order's own event source. The product keys are only read, never appended to, // and a price change after the reads rejects the append. var result = await eventStore.EventLog.AppendMany( [new EventForEventSourceId(orderId, new ProductsOrdered(items))], guardedBy: guards);
return result.IsSuccess; }}The set of reads is whatever the order contains, so it is dynamic in the same sense as DCB’s. One event records the order. Nothing is written to the products. The cost is one fold per product in the cart, so very large carts are the case to measure first.
Idempotency
Section titled “Idempotency”The rule. A retried request must not place the order twice.
DCB. Tag OrderPlaced with the client’s idempotency token and query that tag before appending.
Chronicle. Put [Unique] on the token property of the event. The client generates one token per order and sends the same token on every retry. The server generates a new event source ID for each attempt, so a retry is a different source with a token that is already claimed, and the kernel rejects it as a constraint violation. Treat the violation as “already placed”.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;using Cratis.Chronicle.EventSequences;
[EventType]public record OrderPlaced(string Customer, [property: Unique] string IdempotencyToken);
public enum PlaceOrderOutcome{ Placed, AlreadyPlaced, Failed}
public static class PlaceOrder{ // The caller generates the token once per order and sends the same token on every retry. // The order's event source id is generated here, so a retry is a different source with the same token. public static async Task<PlaceOrderOutcome> Handle(IEventLog eventLog, string customer, string idempotencyToken) { var result = await eventLog.Append(EventSourceId.New(), new OrderPlaced(customer, idempotencyToken));
if (result.HasConstraintViolations) { return PlaceOrderOutcome.AlreadyPlaced; }
return result.IsSuccess ? PlaceOrderOutcome.Placed : PlaceOrderOutcome.Failed; }}By default a unique constraint lets one event source reclaim its own value, so do not reuse the event source ID across retries, or the second event would be accepted. If the order ID comes from the client, derive the event source ID from the token and declare a unique event type constraint, so a second OrderPlaced for the same source is rejected. This protects against an accidental resubmission. It does not protect against someone who deliberately sends a fresh token.