Skip to content

Variants

Some entities do not have one shape for their whole lifetime — a work item is a backlog entry until a pull request exists for it, then it is a pull request until it merges. Modeling that as a single read model with a Status property and an ever-growing set of nullable columns makes every query filter on state before it can trust a field, and a shape meant for one stage leaks properties that only make sense in another.

Variants let you declare several mutually exclusive read models for the same logical entity instead. Each variant is an ordinary fluent projection with its own shape — only the properties that stage of the entity’s life actually has. Entering one variant automatically removes the entity from every other variant in the group, so at any point in time an entity exists in exactly one of them.

Use .VariantOf<TIdentity>(keyAccessor) on every projection that represents one stage, and .EntersOn<TEvent>() to say which event activates that stage. TIdentity anchors the group — every projection declaring the same identity type is mutually exclusive with every other:

/// <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);
using Cratis.Chronicle.Events;
[EventType]
public record DecVariantIssueCreated(string Title);
[EventType]
public record DecVariantPullRequestCreated(string PullRequestUrl);
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>();
}

DecVariantWorkItem does not have to be a read model itself, and it does not need a common CLR base type with any of the variants — its only job is to be a shared type every projection in the group points at. AutoMap is enabled by default, exactly as for any other fluent projection, so Title and PullRequestUrl map by convention without an explicit .Set() call.

When an IssueCreated event is processed, a DecVariantBacklogItem is created — its own event, handled by its own projection. When a PullRequestCreated event is processed for the same entity, DecVariantPullRequestItem is created and the DecVariantBacklogItem for that entity is removed — no explicit removal call needed, the group takes care of it, cross-wired automatically the first time every variant in the group has been discovered. Variants are not required to be entered in any particular order, only to be mutually exclusive once entered.

Only the entering event can create a variant

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

Everything a variant projects from besides its entering event is declared exactly like an ordinary multi-event projection — the same .From<TEvent>() call you would use on any projection. The only difference is what that mapping is allowed to do: it can update an already-active instance of the variant, but it can never 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>();
}

.From<DecVariantUpdatingBuildCompleted>() looks like an ordinary event subscription, but because DecVariantUpdatingBuildCompleted is not the event named with .EntersOn<T>(), the builder reclassifies it into an update-only join — keyed on the same property .VariantOf<T>(_ => _.Id) declared — before the definition ever reaches the projection engine. The entering event stays the only door into the variant.

Unlike model-bound projections, the fluent API has no equivalent of [GlobalFor<T>]. Each fluent projection is its own class producing its own definition, so a mapping every variant needs — a title every stage keeps up to date, for example — is declared with .From<TEvent>() on each variant’s builder individually. If the same shared handler is repeated across several variants often enough to be a maintenance concern, that repetition is itself a reason to reach for model-bound projections for that group instead.

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>();
}
  1. Pick an identity type that means something on its ownDecVariantWorkItem in the examples above, not a marker interface with no purpose beyond grouping. It is the type every variant in the group points back to.
  2. Give every variant only the properties that stage of the entity actually has. A property every stage needs is still declared on each variant’s builder individually — see “Sharing handlers” above.
  3. Reach for variants when the shapes genuinely diverge. A single read model with a Status property is still the right choice when every stage shares almost all of its properties and differs only in a flag or two — see Choosing a read-model style.
  4. Remember each variant is its own collection. There is no built-in query that spans a whole group — a caller that needs “this entity, whichever stage it’s currently in” queries each variant explicitly rather than assuming a single combined collection.