Skip to content

Model lifecycle stages with Cratis Chronicle read model variants

Cratis Chronicle read model variants represent lifecycle stages as separate model-bound projections. Use them when a query should return only entities in one stage, such as backlog items or pull requests, without filtering a shared collection by status.

Each variant has its own properties and collection. A backlog item can carry a title while a pull request also carries its URL and build status. The shapes can be nearly identical, too: separate collections are useful whenever callers want to query a stage directly.

An entering event creates that stage’s read model and removes the entity from the other variants in its group. Before an entity has entered a variant, it need not exist in any of them.

In C#, apply [VariantOf<TIdentity>] to every read model in the group and [EntersOn<TEvent>] to name its entering event. The identity type groups the variants; the entity’s key identifies the instance moving between them. Each variant must declare a key and an entering event:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbVariantIssueCreated(string Title);
[EventType]
public record MbVariantPullRequestCreated(string PullRequestUrl);
/// <summary>
/// Anchors the logical identity shared by every variant. It does not need to be a
/// read model itself, and it does not need a common CLR base type with any of the variants.
/// </summary>
public class MbVariantWorkItem;
[VariantOf<MbVariantWorkItem>]
[EntersOn<MbVariantIssueCreated>]
public record MbVariantBacklogItem([property: Key] Guid Id, string Title);
[VariantOf<MbVariantWorkItem>]
[EntersOn<MbVariantPullRequestCreated>]
public record MbVariantPullRequestItem([property: Key] Guid Id, [property: SetFrom<MbVariantPullRequestCreated>] string PullRequestUrl);

View C# snippet source on GitHub

MbVariantWorkItem is the shared identity type in this example. It does not have to be a read model, and the variants do not have to inherit from it.

The issue-created event creates a backlog item and maps Title by name. A pull-request-created event for the same entity creates a pull request item and removes its backlog item. Chronicle adds that removal behavior from the group declaration; you do not need a separate RemovedWith attribute.

A pull request can also be the first variant the entity enters. The group does not require a backlog item to exist before accepting its entering event.

Only the entering event can create a variant

Section titled “Only the entering event can create a variant”

Use the usual property mappings for events that update a variant after entry. Chronicle treats these non-entering events as update-only: they can change an existing instance but cannot create one.

[EventType]
public record MbVariantUpdatingPullRequestCreated(string PullRequestUrl);
[EventType]
public record MbVariantUpdatingBuildCompleted(string BuildStatus);
public class MbVariantUpdatingWorkItem;
/// <summary>
/// BuildStatus is mapped from MbVariantUpdatingBuildCompleted - an event that is NOT this
/// variant's entering event, so it is automatically reclassified into an update-only join. It can bring
/// an already-active instance up to date, but it can never create one on its own.
/// </summary>
[VariantOf<MbVariantUpdatingWorkItem>]
[EntersOn<MbVariantUpdatingPullRequestCreated>]
public record MbVariantUpdatingPullRequestItem(
[property: Key] Guid Id,
[property: SetFrom<MbVariantUpdatingPullRequestCreated>] string PullRequestUrl,
[property: SetFrom<MbVariantUpdatingBuildCompleted>] string BuildStatus);

View C# snippet source on GitHub

BuildStatus comes from MbVariantUpdatingBuildCompleted, while the pull-request-created event controls entry. Chronicle converts the build mapping into a join on the variant’s own key. A join updates matching read models without creating a missing one, so a late build event cannot recreate a pull request that has moved to another stage.

The join also resolves against stored history when the entering event creates the variant. If a build-completed event was appended first, it does not create a pull request item on its own, but its stored value can populate BuildStatus when the pull request enters. Non-entering events are update-only, not discarded history.

If you also declare [Join<TEvent>] for that non-entering event, Chronicle preserves the explicit join’s mappings and routing. It adds non-conflicting From mappings, and the join wins where both map the same property. The join’s key remains the event source ID. Its On uses the attribute’s explicit property name or defaults to the annotated member’s name. Specify on: nameof(Id) if it should match the variant’s Id; an explicit join does not automatically switch to the variant key.

These entry rules apply to non-entering events. An entering event still creates its variant, including during replay. See Joins for how join keys select existing read models.

Declare common mappings once on a type marked [GlobalFor<TIdentity>]. Use the same identity type as the variants so, for example, one title-change mapping keeps the title current in whichever stage is active:

[EventType]
public record MbVariantSharedIssueCreated(string Title);
[EventType]
public record MbVariantSharedPullRequestCreated(string PullRequestUrl);
[EventType]
public record MbVariantSharedTitleChanged(string Title);
public class MbVariantSharedWorkItem;
[VariantOf<MbVariantSharedWorkItem>]
[EntersOn<MbVariantSharedIssueCreated>]
public record MbVariantSharedBacklogItem([property: Key] Guid Id, string Title);
[VariantOf<MbVariantSharedWorkItem>]
[EntersOn<MbVariantSharedPullRequestCreated>]
public record MbVariantSharedPullRequestItem(
[property: Key] Guid Id,
string Title,
[property: SetFrom<MbVariantSharedPullRequestCreated>] string PullRequestUrl);
/// <summary>
/// Declares a mapping every variant of MbVariantSharedWorkItem shares. Every variant must have a
/// Title member - one that does not is a declaration error, not a silently skipped mapping.
/// </summary>
/// <param name="Title">The title every variant carrying one keeps up to date.</param>
[GlobalFor<MbVariantSharedWorkItem>]
public record MbVariantSharedHandlers([property: SetFrom<MbVariantSharedTitleChanged>] string Title);

View C# snippet source on GitHub

The shared handler maps the title-change event to Title in both variants. Like other non-entering mappings, this mapping updates an existing variant without creating an inactive one. The CHR0024 analyzer counts the group’s entering events and shared handlers as mapping sources; it discovers shared handlers only within the same compilation.

Every variant must declare the member that a shared mapping targets. A missing Title member is a declaration error caught during projection discovery; Chronicle does not silently skip the mapping for that variant.

This example combines entry, build-status updates, and a shared title mapping. Follow one entity key through the events: issue creation puts it in the backlog, pull request creation moves it out, and later build or title changes update only the active variant.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbVariantFullIssueCreated(string Title);
[EventType]
public record MbVariantFullPullRequestCreated(string PullRequestUrl);
[EventType]
public record MbVariantFullBuildCompleted(string BuildStatus);
[EventType]
public record MbVariantFullTitleChanged(string Title);
/// <summary>
/// Anchors the logical identity shared by MbVariantFullBacklogItem and MbVariantFullPullRequestItem.
/// Deliberately not a read model itself.
/// </summary>
public class MbVariantFullWorkItem;
/// <summary>
/// The variant an entity is in before a pull request exists for it.
/// </summary>
[VariantOf<MbVariantFullWorkItem>]
[EntersOn<MbVariantFullIssueCreated>]
public record MbVariantFullBacklogItem([property: Key] Guid Id, string Title);
/// <summary>
/// The variant an entity enters once a pull request is created for it. BuildStatus is
/// mapped from MbVariantFullBuildCompleted - an event that is NOT this variant's entering event, so it
/// becomes an update-only join and can never create the row on its own.
/// </summary>
[VariantOf<MbVariantFullWorkItem>]
[EntersOn<MbVariantFullPullRequestCreated>]
public record MbVariantFullPullRequestItem(
[property: Key] Guid Id,
string Title,
[property: SetFrom<MbVariantFullPullRequestCreated>] string PullRequestUrl,
[property: SetFrom<MbVariantFullBuildCompleted>] string BuildStatus);
/// <summary>
/// Declares a mapping every variant of MbVariantFullWorkItem shares.
/// </summary>
/// <param name="Title">The title every variant carrying one keeps up to date.</param>
[GlobalFor<MbVariantFullWorkItem>]
public record MbVariantFullSharedHandlers([property: SetFrom<MbVariantFullTitleChanged>] string Title);

View C# snippet source on GitHub

Choose an identity type that names the entity, such as the work item in these examples, and give each variant the properties its callers need. Keep common event mappings on a shared handler when they apply to every stage.

Plan queries around the separate collections. There is no built-in query spanning a variant group: a caller that needs an entity regardless of stage must query the variants explicitly, using the shared entity key where appropriate. For reading a particular variant by key, continue with Read-model consistency.