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.
Declaring a group of variants
Section titled “Declaring a group of variants”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);/** * 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 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<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); }}.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.
Sharing handlers across variants
Section titled “Sharing handlers across variants”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.
Complete example
Section titled “Complete example”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”- Pick an identity type that means something on its own —
DecVariantWorkItemin the examples above, not a marker interface with no purpose beyond grouping. It is the type every variant in the group points back to. - 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.
- Reach for variants when the shapes genuinely diverge. A single read model with a
Statusproperty 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. - 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.