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.
Declaring a group of variants
Section titled “Declaring a group of variants”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);/** * Anchors the logical identity shared by DecVariantBacklogItem and DecVariantPullRequestItem. * Deliberately not a read model itself, and does not need a common supertype with either variant. */class DecVariantWorkItem
data class DecVariantBacklogItem(val id: String = "", val title: String = "")
data class DecVariantPullRequestItem(val id: String = "", val pullRequestUrl: String = "")/** * Anchors the logical identity shared by DecVariantBacklogItem and DecVariantPullRequestItem. * Deliberately not a read model itself, and does not need a common supertype with either variant. */class DecVariantWorkItem {}
class DecVariantBacklogItem { public String id = ""; public String title = "";}
class DecVariantPullRequestItem { public String id = ""; public String pullRequestUrl = "";}# Anchors the logical identity shared by DecVariantBacklogItem and DecVariantPullRequestItem.# Deliberately not a read model itself, and does not need a common shape with either variant.defmodule MyApp.ReadModels.DecVariantWorkItem doend
defmodule MyApp.ReadModels.DecVariantBacklogItem do use Chronicle.ReadModels.ReadModel
defstruct [:id, :title]end
defmodule MyApp.ReadModels.DecVariantPullRequestItem do use Chronicle.ReadModels.ReadModel
defstruct [:id, :pull_request_url]end/** * Anchors the logical identity shared by DecVariantBacklogItem and DecVariantPullRequestItem. * Deliberately not a read model itself, and does not need a common shape with either variant. */class DecVariantWorkItem {}
class DecVariantBacklogItem { id = ''; title = '';}
class DecVariantPullRequestItem { id = ''; pullRequestUrl = '';}using Cratis.Chronicle.Events;
[EventType]public record DecVariantIssueCreated(string Title);
[EventType]public record DecVariantPullRequestCreated(string PullRequestUrl);import io.cratis.chronicle.events.EventType
@EventTypedata class DecVariantIssueCreated(val title: String)
@EventTypedata class DecVariantPullRequestCreated(val pullRequestUrl: String)import io.cratis.chronicle.events.EventType;
@EventTyperecord DecVariantIssueCreated(String title) {}
@EventTyperecord DecVariantPullRequestCreated(String pullRequestUrl) {}defmodule MyApp.Events.DecVariantIssueCreated do use Chronicle.Events.EventType, id: "dec-variant-issue-created-v1"
defstruct [:title]end
defmodule MyApp.Events.DecVariantPullRequestCreated do use Chronicle.Events.EventType, id: "dec-variant-pull-request-created-v1"
defstruct [:pull_request_url]endimport { eventType } from '@cratis/chronicle';
@eventType()class DecVariantIssueCreated { title = '';}
@eventType()class DecVariantPullRequestCreated { 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>();}import io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
class DecVariantBacklogItemProjection : IProjectionFor<DecVariantBacklogItem> { override fun define(builder: IProjectionBuilderFor<DecVariantBacklogItem>) { builder .variantOf(DecVariantWorkItem::class, DecVariantBacklogItem::id) .entersOn(DecVariantIssueCreated::class) }}
class DecVariantPullRequestItemProjection : IProjectionFor<DecVariantPullRequestItem> { override fun define(builder: IProjectionBuilderFor<DecVariantPullRequestItem>) { builder .variantOf(DecVariantWorkItem::class, DecVariantPullRequestItem::id) .entersOn(DecVariantPullRequestCreated::class) }}import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
class DecVariantBacklogItemProjection implements IProjectionFor<DecVariantBacklogItem> { @Override public void define(IProjectionBuilderFor<DecVariantBacklogItem> builder) { builder .variantOf(DecVariantWorkItem.class, "id") .entersOn(DecVariantIssueCreated.class); }}
class DecVariantPullRequestItemProjection implements IProjectionFor<DecVariantPullRequestItem> { @Override public void define(IProjectionBuilderFor<DecVariantPullRequestItem> builder) { builder .variantOf(DecVariantWorkItem.class, "id") .entersOn(DecVariantPullRequestCreated.class); }}defmodule MyApp.Projections.DecVariantBacklogItemProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecVariantBacklogItem
alias MyApp.Events.DecVariantIssueCreated alias MyApp.ReadModels.DecVariantWorkItem
variant_of DecVariantWorkItem, key: :id enters_on DecVariantIssueCreated
from DecVariantIssueCreated, set: [id: :event_source_id, title: :title]end
defmodule MyApp.Projections.DecVariantPullRequestItemProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecVariantPullRequestItem
alias MyApp.Events.DecVariantPullRequestCreated alias MyApp.ReadModels.DecVariantWorkItem
variant_of DecVariantWorkItem, key: :id enters_on DecVariantPullRequestCreated
from DecVariantPullRequestCreated, set: [id: :event_source_id, pull_request_url: :pull_request_url]endimport { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@projection()class DecVariantBacklogItemProjection implements IProjectionFor<DecVariantBacklogItem> { define(builder: IProjectionBuilderFor<DecVariantBacklogItem>): void { builder .variantOf(DecVariantWorkItem, m => m.id) .entersOn(DecVariantIssueCreated); }}
@projection()class DecVariantPullRequestItemProjection implements IProjectionFor<DecVariantPullRequestItem> { define(builder: IProjectionBuilderFor<DecVariantPullRequestItem>): void { builder .variantOf(DecVariantWorkItem, m => m.id) .entersOn(DecVariantPullRequestCreated); }}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<DecVariantUpdatingBuildCompleted> 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>();}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
@EventTypedata class DecVariantUpdatingPullRequestCreated(val pullRequestUrl: String)
@EventTypedata class DecVariantUpdatingBuildCompleted(val buildStatus: String)
class DecVariantUpdatingWorkItem
data class DecVariantUpdatingPullRequestItem(val id: String = "", val pullRequestUrl: String = "", val buildStatus: String = "")
/** * from(DecVariantUpdatingBuildCompleted::class) 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. */class DecVariantUpdatingPullRequestItemProjection : IProjectionFor<DecVariantUpdatingPullRequestItem> { override fun define(builder: IProjectionBuilderFor<DecVariantUpdatingPullRequestItem>) { builder .variantOf(DecVariantUpdatingWorkItem::class, DecVariantUpdatingPullRequestItem::id) .entersOn(DecVariantUpdatingPullRequestCreated::class) .from(DecVariantUpdatingBuildCompleted::class) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
@EventTyperecord DecVariantUpdatingPullRequestCreated(String pullRequestUrl) {}
@EventTyperecord DecVariantUpdatingBuildCompleted(String buildStatus) {}
class DecVariantUpdatingWorkItem {}
class DecVariantUpdatingPullRequestItem { public String id = ""; public String pullRequestUrl = ""; public String buildStatus = "";}
/** * from(DecVariantUpdatingBuildCompleted.class) 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. */class DecVariantUpdatingPullRequestItemProjection implements IProjectionFor<DecVariantUpdatingPullRequestItem> { @Override public void define(IProjectionBuilderFor<DecVariantUpdatingPullRequestItem> builder) { builder .variantOf(DecVariantUpdatingWorkItem.class, "id") .entersOn(DecVariantUpdatingPullRequestCreated.class) .from(DecVariantUpdatingBuildCompleted.class); }}defmodule MyApp.Events.DecVariantUpdatingPullRequestCreated do use Chronicle.Events.EventType, id: "dec-variant-updating-pull-request-created-v1"
defstruct [:pull_request_url]end
defmodule MyApp.Events.DecVariantUpdatingBuildCompleted do use Chronicle.Events.EventType, id: "dec-variant-updating-build-completed-v1"
defstruct [:build_status]end
defmodule MyApp.ReadModels.DecVariantUpdatingWorkItem doend
defmodule MyApp.ReadModels.DecVariantUpdatingPullRequestItem do use Chronicle.ReadModels.ReadModel
defstruct [:id, :pull_request_url, :build_status]end
# `from DecVariantUpdatingBuildCompleted` is declared exactly like an ordinary multi-event# projection. Because that event is NOT the one named with enters_on, it is automatically# reclassified 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.defmodule MyApp.Projections.DecVariantUpdatingPullRequestItemProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecVariantUpdatingPullRequestItem
alias MyApp.Events.{DecVariantUpdatingPullRequestCreated, DecVariantUpdatingBuildCompleted} alias MyApp.ReadModels.DecVariantUpdatingWorkItem
variant_of DecVariantUpdatingWorkItem, key: :id enters_on DecVariantUpdatingPullRequestCreated
from DecVariantUpdatingPullRequestCreated, set: [id: :event_source_id, pull_request_url: :pull_request_url]
from DecVariantUpdatingBuildCompleted, set: [build_status: :build_status]endimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class DecVariantUpdatingPullRequestCreated { pullRequestUrl = '';}
@eventType()class DecVariantUpdatingBuildCompleted { buildStatus = '';}
class DecVariantUpdatingWorkItem {}
class DecVariantUpdatingPullRequestItem { id = ''; pullRequestUrl = ''; buildStatus = '';}
/** * .from(DecVariantUpdatingBuildCompleted) 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. */@projection()class DecVariantUpdatingPullRequestItemProjection implements IProjectionFor<DecVariantUpdatingPullRequestItem> { define(builder: IProjectionBuilderFor<DecVariantUpdatingPullRequestItem>): void { builder .variantOf(DecVariantUpdatingWorkItem, m => m.id) .entersOn(DecVariantUpdatingPullRequestCreated) .from(DecVariantUpdatingBuildCompleted); }}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.
Sharing handlers across variants
Section titled “Sharing handlers across variants”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.
Complete example
Section titled “Complete example”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>();}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
@EventTypedata class DecVariantFullIssueCreated(val title: String)
@EventTypedata class DecVariantFullPullRequestCreated(val pullRequestUrl: String)
@EventTypedata class DecVariantFullBuildCompleted(val buildStatus: String)
/** * Anchors the logical identity shared by DecVariantFullBacklogItem and * DecVariantFullPullRequestItem. Deliberately not a read model itself. */class DecVariantFullWorkItem
data class DecVariantFullBacklogItem(val id: String = "", val title: String = "")
data class DecVariantFullPullRequestItem(val id: String = "", val pullRequestUrl: String = "", val buildStatus: String = "")
/** The variant an entity is in before a pull request exists for it. */class DecVariantFullBacklogItemProjection : IProjectionFor<DecVariantFullBacklogItem> { override fun define(builder: IProjectionBuilderFor<DecVariantFullBacklogItem>) { builder .variantOf(DecVariantFullWorkItem::class, DecVariantFullBacklogItem::id) .entersOn(DecVariantFullIssueCreated::class) }}
/** * 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. */class DecVariantFullPullRequestItemProjection : IProjectionFor<DecVariantFullPullRequestItem> { override fun define(builder: IProjectionBuilderFor<DecVariantFullPullRequestItem>) { builder .variantOf(DecVariantFullWorkItem::class, DecVariantFullPullRequestItem::id) .entersOn(DecVariantFullPullRequestCreated::class) .from(DecVariantFullBuildCompleted::class) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
@EventTyperecord DecVariantFullIssueCreated(String title) {}
@EventTyperecord DecVariantFullPullRequestCreated(String pullRequestUrl) {}
@EventTyperecord DecVariantFullBuildCompleted(String buildStatus) {}
/** * Anchors the logical identity shared by DecVariantFullBacklogItem and * DecVariantFullPullRequestItem. Deliberately not a read model itself. */class DecVariantFullWorkItem {}
class DecVariantFullBacklogItem { public String id = ""; public String title = "";}
class DecVariantFullPullRequestItem { public String id = ""; public String pullRequestUrl = ""; public String buildStatus = "";}
/** The variant an entity is in before a pull request exists for it. */class DecVariantFullBacklogItemProjection implements IProjectionFor<DecVariantFullBacklogItem> { @Override public void define(IProjectionBuilderFor<DecVariantFullBacklogItem> builder) { builder .variantOf(DecVariantFullWorkItem.class, "id") .entersOn(DecVariantFullIssueCreated.class); }}
/** * 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. */class DecVariantFullPullRequestItemProjection implements IProjectionFor<DecVariantFullPullRequestItem> { @Override public void define(IProjectionBuilderFor<DecVariantFullPullRequestItem> builder) { builder .variantOf(DecVariantFullWorkItem.class, "id") .entersOn(DecVariantFullPullRequestCreated.class) .from(DecVariantFullBuildCompleted.class); }}defmodule MyApp.Events.DecVariantFullIssueCreated do use Chronicle.Events.EventType, id: "dec-variant-full-issue-created-v1"
defstruct [:title]end
defmodule MyApp.Events.DecVariantFullPullRequestCreated do use Chronicle.Events.EventType, id: "dec-variant-full-pull-request-created-v1"
defstruct [:pull_request_url]end
defmodule MyApp.Events.DecVariantFullBuildCompleted do use Chronicle.Events.EventType, id: "dec-variant-full-build-completed-v1"
defstruct [:build_status]end
# Anchors the logical identity shared by DecVariantFullBacklogItem and# DecVariantFullPullRequestItem. Deliberately not a read model itself.defmodule MyApp.ReadModels.DecVariantFullWorkItem doend
defmodule MyApp.ReadModels.DecVariantFullBacklogItem do use Chronicle.ReadModels.ReadModel
defstruct [:id, :title]end
defmodule MyApp.ReadModels.DecVariantFullPullRequestItem do use Chronicle.ReadModels.ReadModel
defstruct [:id, :pull_request_url, :build_status]end
# The variant an entity is in before a pull request exists for it.defmodule MyApp.Projections.DecVariantFullBacklogItemProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecVariantFullBacklogItem
alias MyApp.Events.DecVariantFullIssueCreated alias MyApp.ReadModels.DecVariantFullWorkItem
variant_of DecVariantFullWorkItem, key: :id enters_on DecVariantFullIssueCreated
from DecVariantFullIssueCreated, set: [id: :event_source_id, title: :title]end
# The variant an entity enters once a pull request is created for it. build_status comes from# DecVariantFullBuildCompleted - an event that is NOT this variant's entering event, so it is# reclassified into an update-only join and can never create the row on its own.defmodule MyApp.Projections.DecVariantFullPullRequestItemProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecVariantFullPullRequestItem
alias MyApp.Events.{DecVariantFullPullRequestCreated, DecVariantFullBuildCompleted} alias MyApp.ReadModels.DecVariantFullWorkItem
variant_of DecVariantFullWorkItem, key: :id enters_on DecVariantFullPullRequestCreated
from DecVariantFullPullRequestCreated, set: [id: :event_source_id, pull_request_url: :pull_request_url]
from DecVariantFullBuildCompleted, set: [build_status: :build_status]endimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class DecVariantFullIssueCreated { title = '';}
@eventType()class DecVariantFullPullRequestCreated { pullRequestUrl = '';}
@eventType()class DecVariantFullBuildCompleted { buildStatus = '';}
/** * Anchors the logical identity shared by DecVariantFullBacklogItem and * DecVariantFullPullRequestItem. Deliberately not a read model itself. */class DecVariantFullWorkItem {}
class DecVariantFullBacklogItem { id = ''; title = '';}
class DecVariantFullPullRequestItem { id = ''; pullRequestUrl = ''; buildStatus = '';}
/** The variant an entity is in before a pull request exists for it. */@projection()class DecVariantFullBacklogItemProjection implements IProjectionFor<DecVariantFullBacklogItem> { define(builder: IProjectionBuilderFor<DecVariantFullBacklogItem>): void { builder .variantOf(DecVariantFullWorkItem, m => m.id) .entersOn(DecVariantFullIssueCreated); }}
/** * 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. */@projection()class DecVariantFullPullRequestItemProjection implements IProjectionFor<DecVariantFullPullRequestItem> { define(builder: IProjectionBuilderFor<DecVariantFullPullRequestItem>): void { builder .variantOf(DecVariantFullWorkItem, m => m.id) .entersOn(DecVariantFullPullRequestCreated) .from(DecVariantFullBuildCompleted); }}Best practices
Section titled “Best practices”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.