Skip to content

Variants

A variant block declares one of several mutually exclusive named read models sharing the enclosing projection’s logical identity. An entity is in exactly one variant at a time. Entering one variant removes it from every other variant of the same projection — the compiler wires that exclusion for you; you never write a remove with for a sibling’s entering event yourself.

This is for a shape like a Kanban board, where a work item is a backlog item, then a development item, then a pull request item — never more than one of those at once, and each stage has its own properties.

projection WorkItem
variant BacklogItem
enters on IssueCreated
variant DevelopmentItem
enters on IssueStarted
variant PullRequestItem
enters on PullRequestCreated

Here WorkItem is not itself a read model — it is the shared identity the three variants are grouped under. Each variant produces its own read model, named after the variant: BacklogItem, DevelopmentItem, PullRequestItem. A query or a command’s reads names a variant exactly as it would name any other projection’s read model.

enters on <EventType> declares the event that activates a variant — the only event allowed to create that variant’s instance. A variant must declare at least one.

variant PullRequestItem
enters on PullRequestCreated

Like an ordinary from event, enters on accepts an explicit key:

variant PullRequestItem
enters on PullRequestCreated key issueId

Everything a variant declares beyond its entering event is update-only — it can bring an already-active variant up to date, but it can never create one, and it can never resurrect an entity into a variant it has since left:

projection WorkItem
variant PullRequestItem
enters on PullRequestCreated
from BuildCompleted
buildStatus = status

If BuildCompleted arrives for an issue that never had a pull request created, no PullRequestItem is created. This is not a rule you have to remember to apply carefully — it is what makes a variant a variant, rather than an ordinary projection that happens to share a name with some others.

A block declared at the projection level, outside every variant, is a shared handler applied to every variant. Every variant with a known read-model shape must declare the mapped property; otherwise compilation reports PLAY0383. An unknown or imported shape stays undecided. There is no separate keyword for “shared” — being outside a variant block is what makes it one:

projection WorkItem
from TitleChanged
title = title
variant BacklogItem
enters on IssueCreated
variant PullRequestItem
enters on PullRequestCreated

Here TitleChanged updates title on whichever variant is currently active, and creates none of the others — exactly like a variant’s own non-entering events, a shared handler is update-only.

If a variant does not have the property a shared handler maps, that is a declaration error, not a silent skip or a runtime failure — the same discipline the Chronicle client SDKs enforce for the equivalent [GlobalFor<T>] attribute (with a compile-time analyzer for the model-bound authoring style).

projection WorkItem
from TitleChanged
title = title
variant BacklogItem
enters on IssueCreated
variant DevelopmentItem
enters on IssueStarted
variant PullRequestItem
enters on PullRequestCreated
from BuildCompleted
buildStatus = status
  • title is kept up to date on whichever variant is currently active.
  • An issue starts as a BacklogItem.
  • IssueStarted moves it to DevelopmentItem, removing it from BacklogItem.
  • PullRequestCreated moves it to PullRequestItem, removing it from DevelopmentItem.
  • BuildCompleted updates buildStatus on PullRequestItem only, and never resurrects the item into that variant on its own.

PLAY0382 rejects a variant without an enters on event. PLAY0384 rejects repeated variant names, and PLAY0385 rejects an entering event claimed by more than one variant of the same projection. PLAY0383 reports a projection-level shared mapping whose target is absent from a known variant read-model shape. These diagnostics are produced by Screenplay’s parsing and validation layer, so Chronicle’s hosted compiler also receives them.

In the executable semantic model (ESM), WorkItem is a group, not a read model or a standalone projection. Each variant produces one independent projection on its own read model. Its entering events are create-or-update from transitions. Its other from blocks, including shared blocks merged first, become update-only joins on that variant’s identifier using the original event key (or the event source). A join can match multiple existing instances but never creates one. Each sibling’s entering event adds a removal keyed by event source identity. This mirrors Chronicle’s client-side variant reclassification; Chronicle’s declaration-language lowering is tracked separately in Cratis/Chronicle#4109.

The ESM projection name and semantic address use <length of group name>:<group name>:<variant name> (for example 8:WorkItem:BacklogItem), avoiding collisions between groups and variants even when names contain separators. The ESM read-model name remains BacklogItem. A read model must have an unambiguous keyed query for its identifier, just as it does for an ordinary projection. A projection’s sequence remains unsupported in the portable ESM.

  1. Name variants for what the entity is at that stage, not for the event that created it.
  2. Keep the entering event minimal. It is the one event allowed to create the variant, so the fewer properties it needs to set, the less there is to get wrong.
  3. Prefer a shared handler over repeating the same mapping in every variant when every variant genuinely carries the property.
  4. Don’t reach for variants when a single projection with a status property would do. Variants are for genuinely different shapes with different properties, not for a single read model that merely changes one field.
  • From Event - The ordinary create-or-update mapping a variant’s enters on event resembles.
  • Removal - The remove with mechanism variants build mutual exclusion out of, without you writing it.
  • Grammar (EBNF) - The formal variant and enters on productions.