Skip to content

Model-Bound Projections

Model-bound projections let the read model describe how it is built. Instead of creating a separate IProjectionFor<T> class, you put the projection metadata directly on the read model type and Chronicle builds the projection definition from those attributes.

Start with convention-based mapping and add explicit attributes only where the event and read model use different names. That keeps the common case small while still making the exceptions visible.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbIndexAccountOpened(string Name, decimal InitialBalance);
[FromEvent<MbIndexAccountOpened>]
public record MbIndexAccountInfo(
[Key]
Guid Id,
string Name,
[SetFrom<MbIndexAccountOpened>(nameof(MbIndexAccountOpened.InitialBalance))]
decimal Balance);

In this example:

  • [FromEvent<AccountOpened>] creates or updates an AccountInfo instance when an AccountOpened event is processed
  • [Key] marks the read model identifier
  • Name maps by convention because both the event and the read model use the same property name
  • Balance uses [SetFrom<TEvent>] because the event property is called InitialBalance

Types are automatically discovered by the presence of:

  • Any class/record level projection mapping attributes (FromEvent, Passive, NotRewindable, etc.)
  • Any projection mapping attribute (SetFrom, AddFrom, Join, etc.)

No explicit marker attribute is required on the type itself.

Model-bound projections support all projection engine capabilities:

  • Property Mapping: SetFrom, AddFrom, SubtractFrom, SetFromContext
  • FromEvery: Set properties from all events
  • Counters: Increment, Decrement, Count
  • Relationships: Join, Children (with unlimited nesting depth)
  • Removal: RemovedWith, RemovedWithJoin
  • Convention-based: FromEvent for automatic property matching
  • Configuration: FromEventSequence, NotRewindable, Passive
  • Fully Recursive: All attributes work at any nesting level - children, grandchildren, and beyond

See the following pages for detailed information on each feature:

Prefer model-bound projections by default:

  • They keep projection metadata next to the read model
  • They cover the common Chronicle projection capabilities without a separate class
  • They make the happy path concise and consistent across features

Use fluent projections (IProjectionFor<T>) as a fallback when the projection cannot be expressed cleanly with model-bound attributes:

  • You need projection construction that is more dynamic or procedural
  • You intentionally want to separate the read model from the projection definition
  • You need specialized builder features that do not map naturally to attributes

Fluent Projection:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record MbIndexExplicitAccountOpened(string Name, decimal InitialBalance);
public class MbIndexExplicitAccountProjection : IProjectionFor<MbIndexExplicitAccountInfo>
{
public void Define(IProjectionBuilderFor<MbIndexExplicitAccountInfo> builder) => builder
.From<MbIndexExplicitAccountOpened>(_ => _
.Set(m => m.Name).To(e => e.Name)
.Set(m => m.Balance).To(e => e.InitialBalance));
}
public class MbIndexExplicitAccountInfo
{
public string Name { get; set; } = string.Empty;
public decimal Balance { get; set; }
}

Model-Bound Projection:

using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
public record MbIndexExplicitMbAccountInfo(
[Key] Guid Id,
[SetFrom<MbIndexExplicitAccountOpened>(nameof(MbIndexExplicitAccountOpened.Name))] string Name,
[SetFrom<MbIndexExplicitAccountOpened>(nameof(MbIndexExplicitAccountOpened.InitialBalance))] decimal Balance);

Fluent Projection with AutoMap:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record MbIndexAutoMapAccountOpened(string Name, decimal Balance);
public class MbIndexAutoMapAccountProjection : IProjectionFor<MbIndexAutoMapAccountInfo>
{
public void Define(IProjectionBuilderFor<MbIndexAutoMapAccountInfo> builder) => builder
.AutoMap()
.From<MbIndexAutoMapAccountOpened>();
}
public class MbIndexAutoMapAccountInfo
{
public string Name { get; set; } = string.Empty;
public decimal Balance { get; set; }
}

Model-Bound Projection with FromEvent:

using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<MbIndexAutoMapAccountOpened>]
public record MbIndexAutoMapMbAccountInfo(
[Key] Guid Id,
string Name, // Automatically mapped from MbIndexAutoMapAccountOpened.Name
decimal Balance); // Automatically mapped from MbIndexAutoMapAccountOpened.Balance

Both approaches produce the same result. Model-bound projections with FromEvent are particularly concise when property names match between events and read models, providing the same automatic mapping benefits as .AutoMap() in fluent projections.

Once you’ve defined a model-bound projection, you can retrieve and observe the resulting read models using the IReadModels API: