Skip to content

Convention-Based Mapping

Convention-based model-bound mapping lets a client say: “this read model is projected from this event, and matching property names should map automatically.” It is the model-bound counterpart to AutoMap in a declarative projection.

Use it when event properties and read model properties share names and compatible shapes. Add explicit mappings only for the properties that need a different source name, a context value, or a different operation.

Convention-based mapping follows the same projection rules as AutoMap:

  1. The event and read model property names must match.
  2. The property types must be compatible.
  3. Nested objects can be mapped when their internal property names match.
  4. Collections can be mapped when the element shape is compatible.
  5. Properties that do not exist on the event are skipped for that event.

The client syntax differs, but the Chronicle projection definition still contains the event type, key expression, read model, and AutoMap behavior.

Apply the client’s model-bound “from event” marker at the read model level. Matching properties are then mapped without per-property set mappings.

Convention-based mapping
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record ConventionUserRegistered(
string Name,
string Email,
DateTimeOffset RegisteredAt);
[FromEvent<ConventionUserRegistered>]
public record ConventionUser(
[Key] Guid Id,
string Name,
string Email,
DateTimeOffset RegisteredAt);

This is equivalent to writing explicit set mappings for every property:

Equivalent explicit mappings
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record ExplicitConventionUserRegistered(
string Name,
string Email,
DateTimeOffset RegisteredAt);
public record ExplicitConventionUser(
[Key] Guid Id,
[SetFrom<ExplicitConventionUserRegistered>(nameof(ExplicitConventionUserRegistered.Name))]
string Name,
[SetFrom<ExplicitConventionUserRegistered>(nameof(ExplicitConventionUserRegistered.Email))]
string Email,
[SetFrom<ExplicitConventionUserRegistered>(nameof(ExplicitConventionUserRegistered.RegisteredAt))]
DateTimeOffset RegisteredAt);

A read model can use convention mapping from more than one event type. Each event updates the matching properties it contains and leaves the rest unchanged.

Multiple convention events
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record ConventionUserProfileCreated(string Name, string Email);
[EventType]
public record ConventionUserProfileUpdated(string Name, string Email, string Phone);
[FromEvent<ConventionUserProfileCreated>]
[FromEvent<ConventionUserProfileUpdated>]
public record ConventionUserProfile(
[Key] Guid Id,
string Name,
string Email,
string Phone);

By default, convention-based mappings use the event source id as the read model key. Use a custom key when the event carries the identifier of the read model instance in its content.

Custom key
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record ConventionUserRegisteredWithKey(
Guid UserId,
string Name,
string Email);
[FromEvent<ConventionUserRegisteredWithKey>(key: nameof(ConventionUserRegisteredWithKey.UserId))]
public record ConventionUserById(
[Key] Guid Id,
string Name,
string Email);

Custom keys are useful when:

  • the event source id is not the read model id
  • one event source can update multiple read model instances
  • an event from one source updates a cross-source or cross-aggregate view

Convention-based model-bound mapping and declarative AutoMap describe the same mapping behavior through different APIs.

Model-bound and declarative AutoMap
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record ConventionEquivalentUserRegistered(string Name, string Email);
[FromEvent<ConventionEquivalentUserRegistered>]
public record ConventionEquivalentUser(
[Key] Guid Id,
string Name,
string Email);
public class ConventionEquivalentProjection : IProjectionFor<ConventionEquivalentUser>
{
public void Define(IProjectionBuilderFor<ConventionEquivalentUser> builder) =>
builder.From<ConventionEquivalentUserRegistered>();
}

Use the model-bound form when the mapping belongs naturally on the read model. Use a declarative projection when you need joins, richer key selection, or a projection definition that should stay separate from the read model shape.

Convention mapping also works for nested values and collections when the names and shapes match.

Matching nested structures and collections
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
public record ConventionAddress(string Street, string City, string PostalCode);
public record ConventionLineItem(string ProductName, decimal UnitPrice, int Quantity);
[EventType]
public record ConventionCustomerRegistered(
string FirstName,
string LastName,
ConventionAddress BillingAddress,
ConventionAddress ShippingAddress);
[EventType]
public record ConventionOrderCreated(
string CustomerEmail,
ConventionLineItem[] Items,
string[] Tags);
[FromEvent<ConventionCustomerRegistered>]
public record ConventionCustomer(
[Key] Guid Id,
string FirstName,
string LastName,
ConventionAddress BillingAddress,
ConventionAddress ShippingAddress);
[FromEvent<ConventionOrderCreated>]
public record ConventionOrder(
[Key] Guid Id,
string CustomerEmail,
ConventionLineItem[] Items,
string[] Tags);

Events do not need to contain every read model property. Chronicle maps the properties that exist on each event.

Partial event shapes
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record ConventionPartialUserRegistered(string Email);
[EventType]
public record ConventionPartialUserCompleted(
string FirstName,
string LastName,
string Phone);
[FromEvent<ConventionPartialUserRegistered>]
[FromEvent<ConventionPartialUserCompleted>]
public record ConventionPartialUser(
[Key] Guid Id,
string Email,
string FirstName,
string LastName,
string Phone);

Convention mapping applies to every event the read model subscribes to — including an event pulled in only to [Count], [Increment], or [Join]. If such an event carries a property whose name matches one you set explicitly, convention mapping would otherwise overwrite your value with the unrelated event’s. Chronicle handles the common form of this automatically and gives you an attribute for the rest.

An event a read model subscribes to only to aggregate[Count], [Increment], [Decrement], [Add], or [Subtract] — does not contribute its other properties to convention mapping. Counting an event does not copy its unrelated fields onto the read model, so a same-named property on it cannot overwrite an explicit value. No annotation is needed.

Aggregating an event does not map its other properties
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record AggOnlyArrangementSet(string Location);
[EventType]
public record AggOnlyCandidateSubmitted(string Name, string Location);
[FromEvent<AggOnlyArrangementSet>]
public record AggOnlyAssignmentSummary(
[Key]
Guid Id,
// AggOnlyCandidateSubmitted is subscribed only to be counted, so its identically named
// Location is not auto-mapped over the value sourced from AggOnlyArrangementSet.
[SetFrom<AggOnlyArrangementSet>(nameof(AggOnlyArrangementSet.Location))]
string Location,
[Count<AggOnlyCandidateSubmitted>]
int CandidateCount);

For the collisions Chronicle cannot infer — a value-mapped ([SetFrom]) or [Join]ed event that carries an identically named property — apply [NoAutoMap] to the read model property (or record parameter). That property is then set only from its explicit source; convention mapping never touches it, while every other property keeps mapping.

Exclude a single property from convention mapping
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record NoAutoMapWorkArrangementSet(string Location, int WorkMode);
[EventType]
public record NoAutoMapCandidateSubmitted(string Name, string Location);
[FromEvent<NoAutoMapWorkArrangementSet>]
public record NoAutoMapAssignmentSummary(
[Key]
Guid Id,
// Location is sourced only from NoAutoMapWorkArrangementSet. NoAutoMapCandidateSubmitted is
// value-mapped (for CandidateName) and also carries a Location; [NoAutoMap] stops that Location
// from being auto-mapped over the explicit value, while every other property keeps mapping.
[SetFrom<NoAutoMapWorkArrangementSet>(nameof(NoAutoMapWorkArrangementSet.Location))]
[NoAutoMap]
string Location,
[SetFrom<NoAutoMapCandidateSubmitted>(nameof(NoAutoMapCandidateSubmitted.Name))]
string CandidateName);

[NoAutoMap] also works at the read model (class) level to disable convention mapping for the whole read model. Use the property form when you only need to protect a single property and want convention mapping everywhere else.

Use convention-based mapping when:

  • the event and read model use consistent names
  • most properties are simple set mappings
  • the read model is easiest to understand next to its event mappings
  • you want to keep explicit attributes or decorators for the exceptions only

Use explicit mappings when:

  • names differ between the event and read model
  • the property comes from event context
  • the property needs add, subtract, count, or another operation
  • the mapping needs a custom key for only one event
  • being explicit is clearer than relying on naming

Convention-based mapping is evaluated when the projection definition is built. Event processing uses the compiled projection definition, so there is no per-event reflection penalty compared with explicit set mappings.