Skip to content

Children Collections

Model-bound projections support child collections through the ChildrenFrom attribute, allowing you to build hierarchical read models with parent-child relationships.

The ChildrenFrom attribute defines how child entities are added to a collection:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbChildrenLineItemAdded(
Guid ItemId,
string ProductName,
int Quantity,
decimal Price);
public record MbChildrenOrder(
[Key]
Guid OrderId,
[ChildrenFrom<MbChildrenLineItemAdded>(key: nameof(MbChildrenLineItemAdded.ItemId))]
IEnumerable<MbChildrenLineItem> Items);
public record MbChildrenLineItem(
[Key] Guid Id, // Chronicle automatically discovers this as the key
string ProductName,
int Quantity,
decimal Price);

In this example, the [Key] attribute on the LineItem.Id property is automatically discovered by Chronicle, so you don’t need to specify identifiedBy explicitly in the ChildrenFrom attribute.

  • key (optional): Property on the event that identifies the child. Defaults to EventSourceId
  • identifiedBy (optional): Property on the child model that identifies it. If not specified, Chronicle will:
    1. Look for a property with the [Key] attribute
    2. Look for a property named Id (case-insensitive)
    3. Fall back to EventSourceId if neither is found
  • parentKey (optional): Property that identifies the parent. Defaults to EventSourceId
    • Use this when the parent identifier is a property in the event content rather than the EventSourceId
    • Example: parentKey: nameof(LineItemAdded.OrderId) when OrderId is in the event

Auto-mapping is enabled by default. To disable it for a child model, apply the [NoAutoMap] attribute on the child type.

Note: With automatic key discovery, you typically don’t need to specify identifiedBy explicitly. Just mark your child model’s key property with [Key] attribute, or name it Id, and Chronicle will automatically discover it.

By default, ChildrenFrom automatically maps properties from the event to the child model when property names match. This behavior is similar to the FromEvent attribute:

[EventType]
public record MbChildrenAutoMapLineItemAdded(
Guid ItemId,
string ProductName,
int Quantity,
decimal Price);
public record MbChildrenAutoMapOrder(
[Key]
Guid OrderId,
[ChildrenFrom<MbChildrenAutoMapLineItemAdded>(key: nameof(MbChildrenAutoMapLineItemAdded.ItemId))]
IEnumerable<MbChildrenAutoMapLineItem> Items);
public record MbChildrenAutoMapLineItem(
[Key] Guid Id,
string ProductName, // Automatically mapped from MbChildrenAutoMapLineItemAdded.ProductName
int Quantity, // Automatically mapped from MbChildrenAutoMapLineItemAdded.Quantity
decimal Price); // Automatically mapped from MbChildrenAutoMapLineItemAdded.Price

You can disable auto-mapping if you want to control property mapping explicitly:

using Cratis.Chronicle.Projections;
[EventType]
public record MbChildrenNoAutoMapLineItemAdded(
Guid ItemId,
string ProductName,
int Quantity,
decimal Price);
public record MbChildrenNoAutoMapOrder(
[Key]
Guid OrderId,
[ChildrenFrom<MbChildrenNoAutoMapLineItemAdded>(key: nameof(MbChildrenNoAutoMapLineItemAdded.ItemId))]
IEnumerable<MbChildrenNoAutoMapLineItem> Items);
[NoAutoMap]
public record MbChildrenNoAutoMapLineItem(
[Key] Guid Id,
// Now you must use SetFrom for each property
[SetFrom<MbChildrenNoAutoMapLineItemAdded>(nameof(MbChildrenNoAutoMapLineItemAdded.ProductName))]
string ProductName,
[SetFrom<MbChildrenNoAutoMapLineItemAdded>(nameof(MbChildrenNoAutoMapLineItemAdded.Quantity))]
int Quantity,
[SetFrom<MbChildrenNoAutoMapLineItemAdded>(nameof(MbChildrenNoAutoMapLineItemAdded.Price))]
decimal Price);

All projection attributes work recursively on child types. The child type’s properties are automatically scanned for projection attributes:

[EventType]
public record MbChildrenCountersItemAddedToCart(Guid ItemId, string ProductName, decimal Price, int InitialQuantity);
[EventType]
public record MbChildrenCountersQuantityIncreased(Guid ItemId);
[EventType]
public record MbChildrenCountersQuantityDecreased(Guid ItemId);
public record MbChildrenCountersShoppingCart(
[Key]
Guid CartId,
[ChildrenFrom<MbChildrenCountersItemAddedToCart>(
key: nameof(MbChildrenCountersItemAddedToCart.ItemId))]
IEnumerable<MbChildrenCountersCartItem> Items);
// Child type with its own projection attributes
public record MbChildrenCountersCartItem(
[Key] Guid Id,
[SetFrom<MbChildrenCountersItemAddedToCart>(nameof(MbChildrenCountersItemAddedToCart.ProductName))]
string ProductName,
[SetFrom<MbChildrenCountersItemAddedToCart>(nameof(MbChildrenCountersItemAddedToCart.Price))]
decimal Price,
[SetFrom<MbChildrenCountersItemAddedToCart>(nameof(MbChildrenCountersItemAddedToCart.InitialQuantity))]
[Increment<MbChildrenCountersQuantityIncreased>]
[Decrement<MbChildrenCountersQuantityDecreased>]
int Quantity);

When an ItemAddedToCart event occurs:

  1. A new CartItem is added to the collection
  2. Properties are mapped from the event to the child
  3. The child’s own attributes are processed

When a QuantityIncreased event occurs later:

  • The projection finds the matching child by ID
  • Increments the Quantity on that specific child

Use a class-level FromEvent on the child type when later child events should auto-map into the existing child instance. If those events come from the child’s own event source, specify parentKey so Chronicle can still resolve the parent document:

[EventType]
public record MbChildrenChildFromEventConfigurationAdded(Guid DashboardId, Guid ConfigurationId, string Name);
[EventType]
public record MbChildrenChildFromEventConfigurationRenamed(Guid DashboardId, Guid Id, string Name);
public record MbChildrenChildFromEventDashboard(
[Key] Guid Id,
string Name,
[ChildrenFrom<MbChildrenChildFromEventConfigurationAdded>(
key: nameof(MbChildrenChildFromEventConfigurationAdded.ConfigurationId),
parentKey: nameof(MbChildrenChildFromEventConfigurationAdded.DashboardId))]
IEnumerable<MbChildrenChildFromEventConfiguration> Configurations);
[FromEvent<MbChildrenChildFromEventConfigurationRenamed>(parentKey: nameof(MbChildrenChildFromEventConfigurationRenamed.DashboardId))]
public record MbChildrenChildFromEventConfiguration(
[Key] Guid Id,
string Name);

ChildrenFrom handles how the child is first added to the collection. The child type’s own FromEvent handles later updates, and parentKey is the equivalent of .UsingParentKey(...) in a fluent child projection. You do not need identifiedBy here because Configuration.Id is marked with [Key].

Use RemovedWith to remove children from collections. You can apply it either on the collection property or on the child type class:

[EventType]
public record MbChildrenRemovalPropertyLineItemAdded(Guid ItemId, string Description);
[EventType]
public record MbChildrenRemovalPropertyLineItemRemoved(Guid ItemId);
public record MbChildrenRemovalPropertyOrder(
[Key] Guid Id,
[ChildrenFrom<MbChildrenRemovalPropertyLineItemAdded>(key: nameof(MbChildrenRemovalPropertyLineItemAdded.ItemId))]
[RemovedWith<MbChildrenRemovalPropertyLineItemRemoved>(key: nameof(MbChildrenRemovalPropertyLineItemRemoved.ItemId))]
IEnumerable<MbChildrenRemovalPropertyOrderLine> Lines);
public record MbChildrenRemovalPropertyOrderLine(
[Key] Guid Id,
string Description);

Apply RemovedWith directly on the child type for better separation of concerns:

[EventType]
public record MbChildrenRemovalClassLineItemAdded(Guid ItemId, string Description);
[EventType]
public record MbChildrenRemovalClassLineItemRemoved(Guid OrderId, Guid ItemId);
public record MbChildrenRemovalClassOrder(
[Key] Guid Id,
[ChildrenFrom<MbChildrenRemovalClassLineItemAdded>(key: nameof(MbChildrenRemovalClassLineItemAdded.ItemId))]
IEnumerable<MbChildrenRemovalClassOrderLine> Lines);
[RemovedWith<MbChildrenRemovalClassLineItemRemoved>(
key: nameof(MbChildrenRemovalClassLineItemRemoved.ItemId),
parentKey: nameof(MbChildrenRemovalClassLineItemRemoved.OrderId))]
public record MbChildrenRemovalClassOrderLine(
[Key] Guid Id,
string Description);

For removal based on events from different streams (joins), use RemovedWithJoin on the collection property (it also works at the child type’s class level, the same way RemovedWith does above):

[EventType]
public record MbChildrenRemovedFeatureActivated(Guid FeatureId, string Name);
[EventType]
public record MbChildrenRemovedFeatureDeactivated(Guid FeatureId);
public record MbChildrenRemovedSubscription(
[Key]
Guid SubscriptionId,
[ChildrenFrom<MbChildrenRemovedFeatureActivated>(key: nameof(MbChildrenRemovedFeatureActivated.FeatureId))]
[RemovedWithJoin<MbChildrenRemovedFeatureDeactivated>(key: nameof(MbChildrenRemovedFeatureDeactivated.FeatureId))]
IEnumerable<MbChildrenRemovedFeature> Features);
public record MbChildrenRemovedFeature(
[Key] Guid FeatureId,
string Name);

Note: For comprehensive documentation on removal options including removing root read models, see Removal.

Here’s a comprehensive example showing children with full attribute support:

// Events
[EventType]
public record MbChildrenFullOrderCreated(string CustomerName);
[EventType]
public record MbChildrenFullLineItemAdded(
Guid ItemId,
string ProductName,
int InitialQuantity,
decimal UnitPrice);
[EventType]
public record MbChildrenFullQuantityAdjusted(Guid ItemId, int NewQuantity);
[EventType]
public record MbChildrenFullLineItemRemoved(Guid ItemId);
// Read Models
public record MbChildrenFullOrder(
[Key]
Guid Id,
[SetFrom<MbChildrenFullOrderCreated>(nameof(MbChildrenFullOrderCreated.CustomerName))]
string Customer,
[ChildrenFrom<MbChildrenFullLineItemAdded>(key: nameof(MbChildrenFullLineItemAdded.ItemId))]
[RemovedWith<MbChildrenFullLineItemRemoved>(key: nameof(MbChildrenFullLineItemRemoved.ItemId))]
IEnumerable<MbChildrenFullOrderLine> Lines);
public record MbChildrenFullOrderLine(
[Key] Guid Id,
[SetFrom<MbChildrenFullLineItemAdded>(nameof(MbChildrenFullLineItemAdded.ProductName))]
string Product,
[SetFrom<MbChildrenFullLineItemAdded>(nameof(MbChildrenFullLineItemAdded.InitialQuantity))]
[SetFrom<MbChildrenFullQuantityAdjusted>(nameof(MbChildrenFullQuantityAdjusted.NewQuantity))]
int Quantity,
[SetFrom<MbChildrenFullLineItemAdded>(nameof(MbChildrenFullLineItemAdded.UnitPrice))]
decimal UnitPrice);
  1. OrderCreated - Creates the parent Order with customer name
  2. LineItemAdded - Adds a new OrderLine to the collection with initial values
  3. QuantityAdjusted - Updates the Quantity on the matching OrderLine
  4. LineItemRemoved - Removes the OrderLine from the collection

Children can have their own children, creating deeply nested structures. All projection attributes (joins, removal, counters, context mapping, etc.) work recursively at every level:

// Events
[EventType]
public record MbChildrenNestedOrganizationCreated(string Name);
[EventType]
public record MbChildrenNestedDepartmentAdded(Guid Id, string Name);
[EventType]
public record MbChildrenNestedDepartmentRenamed(Guid Id, string NewName);
[EventType]
public record MbChildrenNestedTeamAdded(Guid Id, Guid DepartmentId, string Name);
[EventType]
public record MbChildrenNestedTeamRenamed(Guid Id, string NewName);
// Read Models - all attributes work at every nesting level
public record MbChildrenNestedOrganization(
[Key] Guid Id,
[SetFrom<MbChildrenNestedOrganizationCreated>]
string Name,
[ChildrenFrom<MbChildrenNestedDepartmentAdded>(
key: nameof(MbChildrenNestedDepartmentAdded.Id),
identifiedBy: nameof(MbChildrenNestedDepartment.Id))]
IEnumerable<MbChildrenNestedDepartment> Departments);
public record MbChildrenNestedDepartment(
[Key] Guid Id,
[SetFrom<MbChildrenNestedDepartmentAdded>]
[Join<MbChildrenNestedDepartmentRenamed>(eventPropertyName: nameof(MbChildrenNestedDepartmentRenamed.NewName))] // Joins work on children
string Name,
[ChildrenFrom<MbChildrenNestedTeamAdded>(
key: nameof(MbChildrenNestedTeamAdded.Id),
identifiedBy: nameof(MbChildrenNestedTeam.Id),
parentKey: nameof(MbChildrenNestedTeamAdded.DepartmentId))] // Nested children
IEnumerable<MbChildrenNestedTeam> Teams);
public record MbChildrenNestedTeam(
[Key] Guid Id,
[SetFrom<MbChildrenNestedTeamAdded>]
[Join<MbChildrenNestedTeamRenamed>(eventPropertyName: nameof(MbChildrenNestedTeamRenamed.NewName))] // Joins work on nested children too
string Name);

All projection attributes are fully supported on child types at any nesting level:

AttributeWorks on Children
SetFrom
AddFrom / SubtractFrom
SetFromContext
Join
Increment / Decrement / Count
ChildrenFrom (nested children)
RemovedWith / RemovedWithJoin
FromEvent (class-level)

This means you can build arbitrarily deep hierarchies with full projection capabilities at every level.

  1. Always use Key attribute on child types to identify them uniquely
  2. Leverage recursive attributes to build complex child projections without duplication
  3. Use RemovedWith to maintain collection integrity when items are removed
  4. Consider performance with large collections - deeply nested structures can impact query performance