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);

View C# snippet source on GitHub

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:

  • Class/record-level projection annotations such as FromEvent or NotRewindable, or event-sequence selection
  • Member mapping attributes such as SetFrom, AddFrom, or Join

No explicit marker is required on the type. [Passive] alone is not a discovery signal: it configures how an already-defined model is read, and can also configure a reducer model.

Model-bound projections cover the common projection operations below. Specialized builder features, such as fluent dynamic dictionary counters, do not all have attribute equivalents.

  • 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: EventSequence, NotRewindable, Passive
  • Variants: Mutually exclusive read model shapes for the same logical entity
  • Fully Recursive: All attributes work at any nesting level - children, grandchildren, and beyond

See the following pages for detailed information on each feature:

  • Basic Mapping - SetFrom, AddFrom, SubtractFrom, SetFromContext
  • Set Constant Value - Set a property to a fixed value with SetValue
  • Clearing Values - Take a value away with ClearWith or a null SetValue
  • Convention-Based - Automatic property mapping with FromEvent (equivalent to AutoMap)
  • FromEvery - Update properties from all events
  • FromAll - Subscribe to all event types without filtering
  • Counters - Increment, Decrement, Count
  • Children - Managing child collections
  • Removal - Removing read models and children with RemovedWith, RemovedWithJoin
  • Joins - Joining with other events
  • Event Sequence Source - Reading from specific event sequences
  • Not Rewindable - Forward-only projections
  • Passive - On-demand projections
  • Variants - Mutually exclusive read model shapes for the same logical entity

Start with model-bound attributes, use fluent mappings when the definition needs structure, and use reducer code when state transitions are clearer; a self-reducing model is the default for new reducers. Choose how to build a read model works through the trade-offs.

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.Balance).To(e => e.InitialBalance));
}
public class MbIndexExplicitAccountInfo
{
public string Name { get; set; } = string.Empty;
public decimal Balance { get; set; }
}

View C# snippet source on GitHub

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);

View C# snippet source on GitHub

Fluent Projection (AutoMap is on by default):

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
.From<MbIndexAutoMapAccountOpened>();
}
public class MbIndexAutoMapAccountInfo
{
public string Name { get; set; } = string.Empty;
public decimal Balance { get; set; }
}

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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 that fluent projections get by default.

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