Skip to content

Cratis Chronicle projection variants: model lifecycle stages

Projection variants in Cratis Chronicle give each lifecycle stage of an entity its own read model. An entering event creates one variant and triggers removal from the others in its group. Use variants when callers need a stage-specific view, such as backlog items before a pull request exists and pull-request items afterwards.

Each variant has its own projection and collection. The shapes can differ, with fields that only make sense in one stage, or share nearly all their properties. Either way, querying one collection selects that stage without a Status filter.

The projections process events asynchronously. Their declarations make the variants mutually exclusive through removal handlers; they do not make a transition across collections an atomic operation or create an instance before an entering event occurs.

In the .NET fluent API, call .VariantOf<TIdentity>(keyAccessor) on each projection in the group. TIdentity is the shared type that identifies the group, and the accessor selects the read-model property used to correlate updates. Declare at least one .EntersOn<TEvent>() event for each variant to identify what activates it:

/// <summary>
/// Anchors the logical identity shared by DecVariantBacklogItem and DecVariantPullRequestItem.
/// Deliberately not a read model itself, and does not need a common CLR base type with either variant.
/// </summary>
public class DecVariantWorkItem;
public record DecVariantBacklogItem(Guid Id, string Title);
public record DecVariantPullRequestItem(Guid Id, string PullRequestUrl);

View C# snippet source on GitHub

using Cratis.Chronicle.Events;
[EventType]
public record DecVariantIssueCreated(string Title);
[EventType]
public record DecVariantPullRequestCreated(string PullRequestUrl);

View C# snippet source on GitHub

using Cratis.Chronicle.Projections;
public class DecVariantBacklogItemProjection : IProjectionFor<DecVariantBacklogItem>
{
public void Define(IProjectionBuilderFor<DecVariantBacklogItem> builder) => builder
.VariantOf<DecVariantWorkItem>(_ => _.Id)
.EntersOn<DecVariantIssueCreated>();
}
public class DecVariantPullRequestItemProjection : IProjectionFor<DecVariantPullRequestItem>
{
public void Define(IProjectionBuilderFor<DecVariantPullRequestItem> builder) => builder
.VariantOf<DecVariantWorkItem>(_ => _.Id)
.EntersOn<DecVariantPullRequestCreated>();
}

View C# snippet source on GitHub

DecVariantWorkItem groups these projections. It need not be a read model or a common .NET base type for the variants. AutoMap is enabled by default, so Title and PullRequestUrl map by convention without an explicit .Set() call.

Append the entering events under the same event source ID for the same logical entity. The issue-created event creates the backlog item. The pull-request-created event creates the pull-request item and removes the corresponding backlog item as each projection processes it.

During discovery, Chronicle adds removal handlers for the entering events of the other variants in the group. You do not declare those removals yourself. A variant declared without an entering event is rejected when its definition is built. The group does not enforce a permitted sequence of business transitions; it defines which events activate and remove each shape.

Only the entering event can create a variant

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

Use .From<TEvent>() for events that update a variant without activating it. Unlike an ordinary projection’s From handler, a non-entering handler can update an existing variant but cannot create one:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record DecVariantUpdatingPullRequestCreated(string PullRequestUrl);
[EventType]
public record DecVariantUpdatingBuildCompleted(string BuildStatus);
public class DecVariantUpdatingWorkItem;
public record DecVariantUpdatingPullRequestItem(Guid Id, string PullRequestUrl, string BuildStatus);
/// <summary>
/// From&lt;DecVariantUpdatingBuildCompleted&gt; is declared exactly like an ordinary multi-event
/// projection. Because that event is NOT the one named with EntersOn, the builder automatically
/// reclassifies it into an update-only join on the variant's own key when the definition is built - it
/// can bring an already-active instance up to date, but it can never create one on its own.
/// </summary>
public class DecVariantUpdatingPullRequestItemProjection : IProjectionFor<DecVariantUpdatingPullRequestItem>
{
public void Define(IProjectionBuilderFor<DecVariantUpdatingPullRequestItem> builder) => builder
.VariantOf<DecVariantUpdatingWorkItem>(_ => _.Id)
.EntersOn<DecVariantUpdatingPullRequestCreated>()
.From<DecVariantUpdatingBuildCompleted>();
}

View C# snippet source on GitHub

Before registering the definition, the builder turns .From<DecVariantUpdatingBuildCompleted>() into an update-only join because that event is not named by .EntersOn<T>(). A join correlates an event with an existing read model; here it uses the property selected by .VariantOf<T>(_ => _.Id).

The join is also resolved from stored event history when the entering event creates the variant. A build-completed event stored before the pull-request-created event can therefore supply BuildStatus during creation, but cannot create the pull-request item on its own.

If a non-entering event also has an explicit .Join<TEvent>(), reclassification combines its From mappings with that join, including mappings inherited through .From<BaseEvent>(). The explicit join keeps its On and key settings and wins on conflicting property mappings, regardless of registration order.

A later build-completed event cannot recreate a pull-request item after the entity has left that variant. An entering event can activate it again, so choose entering events that represent the transitions your application allows.

Declare a shared update, such as a title change, with .From<TEvent>() on each variant’s builder. Each fluent projection produces its own definition; the fluent API has no equivalent of the model-bound [GlobalFor<T>] attribute.

If repeating those mappings makes the group hard to maintain, consider model-bound projections, where shared handlers can be declared for the group.

This example combines the two entering events with a build-status update. Only the pull-request entering event can create the pull-request item; the build event supplies an update:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record DecVariantFullIssueCreated(string Title);
[EventType]
public record DecVariantFullPullRequestCreated(string PullRequestUrl);
[EventType]
public record DecVariantFullBuildCompleted(string BuildStatus);
/// <summary>
/// Anchors the logical identity shared by DecVariantFullBacklogItem and
/// DecVariantFullPullRequestItem. Deliberately not a read model itself.
/// </summary>
public class DecVariantFullWorkItem;
public record DecVariantFullBacklogItem(Guid Id, string Title);
public record DecVariantFullPullRequestItem(Guid Id, string PullRequestUrl, string BuildStatus);
/// <summary>
/// The variant an entity is in before a pull request exists for it.
/// </summary>
public class DecVariantFullBacklogItemProjection : IProjectionFor<DecVariantFullBacklogItem>
{
public void Define(IProjectionBuilderFor<DecVariantFullBacklogItem> builder) => builder
.VariantOf<DecVariantFullWorkItem>(_ => _.Id)
.EntersOn<DecVariantFullIssueCreated>();
}
/// <summary>
/// The variant an entity enters once a pull request is created for it. BuildStatus comes from
/// DecVariantFullBuildCompleted - an event that is NOT this variant's entering event, so the builder
/// reclassifies it into an update-only join and it can never create the row on its own.
/// </summary>
public class DecVariantFullPullRequestItemProjection : IProjectionFor<DecVariantFullPullRequestItem>
{
public void Define(IProjectionBuilderFor<DecVariantFullPullRequestItem> builder) => builder
.VariantOf<DecVariantFullWorkItem>(_ => _.Id)
.EntersOn<DecVariantFullPullRequestCreated>()
.From<DecVariantFullBuildCompleted>();
}

View C# snippet source on GitHub

Name the shared identity type for the entity the stages represent, such as DecVariantWorkItem. Keep each read model focused on what its callers need in that stage; shared fields do not require a shared base class.

Plan queries around the separate collections. There is no built-in query spanning a variant group, so a caller looking for an entity regardless of stage must query the variants explicitly. A missing result in one collection can mean the entity has not entered that stage or has already left it.

For how asynchronously maintained collections become current, continue with eventual consistency in projections. If you want to express the same group through read-model attributes instead, see model-bound variants.