Skip to content

Projection with children

Projections can manage hierarchical data by defining child collections. This allows you to build read models that contain arrays or lists of related data.

Use the child-collection builder to select the collection property, declare how each child is identified, and map the events that add, update, or remove items.

Projection with children
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record GroupCreatedForChildren(string Name, string Description);
[EventType]
public record UserAddedToGroupForChildren(string UserId, string Role);
[EventType]
public record UserRoleChangedForChildren(string UserId, string Role);
[EventType]
public record UserRemovedFromGroupForChildren(string UserId);
public record GroupForChildren(
string Name,
string Description,
IEnumerable<GroupMemberForChildren> Members);
public record GroupMemberForChildren(
string UserId,
string Role);
public class GroupProjectionForChildren : IProjectionFor<GroupForChildren>
{
public void Define(IProjectionBuilderFor<GroupForChildren> builder) => builder
.From<GroupCreatedForChildren>()
.Children(m => m.Members, children => children
.IdentifiedBy(m => m.UserId)
.From<UserAddedToGroupForChildren>(b => b
.UsingKey(e => e.UserId))
.From<UserRoleChangedForChildren>(b => b
.UsingKey(e => e.UserId))
.RemovedWith<UserRemovedFromGroupForChildren>(b => b
.UsingKey(e => e.UserId)));
}

The read model includes a collection property for the children:

Read model with children
public record GroupWithMembers(
string Name,
string Description,
IEnumerable<GroupMember> Members);
public record GroupMember(
string UserId,
string Role);

Events that affect children use keys to identify which child to update:

Child lifecycle events
using Cratis.Chronicle.Events;
[EventType]
public record GroupCreatedForChildEvents(string Name, string Description);
[EventType]
public record UserAddedToGroupForChildEvents(string UserId, string Role);
[EventType]
public record UserRoleChangedForChildEvents(string UserId, string Role);
[EventType]
public record UserRemovedFromGroupForChildEvents(string UserId);
  1. Root events (GroupCreated) update properties on the main read model
  2. Child events (UserAddedToGroup, UserRoleChanged) are routed to child items
  3. IdentifiedBy() specifies how to identify child items (by UserId in this example)
  4. UsingKey() tells the projection which property contains the child identifier
  5. Child items are created, updated, or remain unchanged based on the events

By default, when a child event is processed, the framework uses the EventSourceId to identify the parent. This works well when the event is appended with the parent’s identifier as the EventSourceId.

Default behavior (EventSourceId as parent key)

Section titled “Default behavior (EventSourceId as parent key)”

In most scenarios, you don’t need to specify the parent key explicitly:

Default parent key
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record GroupCreatedWithDefaultParentKey(string Name);
[EventType]
public record UserAddedWithDefaultParentKey(string UserId, string Role);
public record GroupWithDefaultParentKey(
string Name,
IEnumerable<GroupMemberWithDefaultParentKey> Members);
public record GroupMemberWithDefaultParentKey(
string UserId,
string Role);
public class GroupWithDefaultParentKeyProjection : IProjectionFor<GroupWithDefaultParentKey>
{
public void Define(IProjectionBuilderFor<GroupWithDefaultParentKey> builder) => builder
.From<GroupCreatedWithDefaultParentKey>()
.Children(m => m.Members, children => children
.IdentifiedBy(m => m.UserId)
.From<UserAddedWithDefaultParentKey>(b => b
.UsingKey(e => e.UserId)));
}

When you append the event:

Append child event to parent
using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
public class GroupMembershipWithDefaultParentKey(IEventStore eventStore)
{
public Task AddUserToGroup(EventSourceId groupId, string userId, string role) =>
eventStore.EventLog.Append(groupId, new UserAddedWithDefaultParentKey(userId, role));
}

The groupId (EventSourceId) is automatically used to find the parent Group.

If your event contains the parent key as a property (instead of using EventSourceId), use UsingParentKey():

Parent key from event content
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record GroupCreatedWithEventParentKey(string Name);
[EventType]
public record UserAddedWithEventParentKey(string GroupId, string UserId, string Role);
public record GroupWithEventParentKey(
string Name,
IEnumerable<GroupMemberWithEventParentKey> Members);
public record GroupMemberWithEventParentKey(
string UserId,
string Role);
public class GroupWithEventParentKeyProjection : IProjectionFor<GroupWithEventParentKey>
{
public void Define(IProjectionBuilderFor<GroupWithEventParentKey> builder) => builder
.From<GroupCreatedWithEventParentKey>()
.Children(m => m.Members, children => children
.IdentifiedBy(m => m.UserId)
.From<UserAddedWithEventParentKey>(b => b
.UsingParentKey(e => e.GroupId)
.UsingKey(e => e.UserId)));
}

When you append the event:

Append child event with parent key
using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
public class GroupMembershipWithEventParentKey(IEventStore eventStore)
{
public Task AddUserToGroup(EventSourceId userId, string groupId, string role) =>
eventStore.EventLog.Append(userId, new UserAddedWithEventParentKey(groupId, userId.Value, role));
}

The groupId property from the event content is used to find the parent Group.

Using EventSourceId explicitly with UsingParentKeyFromContext

Section titled “Using EventSourceId explicitly with UsingParentKeyFromContext”

In some advanced scenarios, you might want to explicitly indicate that the EventSourceId should be used as the parent key (e.g., for documentation clarity):

Explicit parent key from context
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record GroupCreatedWithContextParentKey(string Name);
[EventType]
public record UserAddedWithContextParentKey(string UserId, string Role);
public record GroupWithContextParentKey(
string Name,
IEnumerable<GroupMemberWithContextParentKey> Members);
public record GroupMemberWithContextParentKey(
string UserId,
string Role);
public class GroupWithContextParentKeyProjection : IProjectionFor<GroupWithContextParentKey>
{
public void Define(IProjectionBuilderFor<GroupWithContextParentKey> builder) => builder
.From<GroupCreatedWithContextParentKey>()
.Children(m => m.Members, children => children
.IdentifiedBy(m => m.UserId)
.From<UserAddedWithContextParentKey>(b => b
.UsingParentKeyFromContext(c => c.EventSourceId)
.UsingKey(e => e.UserId)));
}

This is functionally equivalent to not specifying the parent key at all, but can make the intent clearer in complex projections.

  • No parent key specified (default): Use when EventSourceId represents the parent identifier
  • UsingParentKey(e => e.Property): Use when parent identifier is in the event content
  • UsingParentKeyFromContext(ctx => ctx.EventSourceId): Use for explicit documentation of default behavior
  • Adding children: When a new event arrives with a previously unseen key, a new child is created
  • Updating children: When an event arrives with an existing key, that child is updated
  • Removing children: Use RemovedWith<>() to specify which events remove child items

The RemovedWith<>() method specifies how to remove child items from collections:

Remove children
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record GroupCreatedWithRemoval(string Name);
[EventType]
public record UserAddedWithRemoval(string UserId, string Role);
[EventType]
public record UserRemovedWithRemoval(string UserId);
public record GroupWithRemoval(
string Name,
IEnumerable<GroupMemberWithRemoval> Members);
public record GroupMemberWithRemoval(
string UserId,
string Role);
public class GroupWithRemovalProjection : IProjectionFor<GroupWithRemoval>
{
public void Define(IProjectionBuilderFor<GroupWithRemoval> builder) => builder
.From<GroupCreatedWithRemoval>()
.Children(m => m.Members, children => children
.IdentifiedBy(m => m.UserId)
.From<UserAddedWithRemoval>(b => b
.UsingKey(e => e.UserId))
.RemovedWith<UserRemovedWithRemoval>(b => b
.UsingKey(e => e.UserId)));
}

When a UserRemovedFromGroup event is processed:

  1. The projection looks up the child using the specified key (e.UserId)
  2. If found, the child is removed from the collection
  3. If not found, the event is ignored

You can also remove children conditionally or based on other criteria by using multiple RemovedWith<>() calls.

A single projection can have multiple child collections:

Multiple child collections
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record GroupCreatedWithMultipleCollections(string Name);
[EventType]
public record MemberAddedToGroup(string UserId, string Role);
[EventType]
public record TaskAssignedToGroup(string TaskId, string Title);
public record GroupWithMultipleCollections(
string Name,
IEnumerable<GroupMemberInMultipleCollections> Members,
IEnumerable<GroupTaskInMultipleCollections> Tasks);
public record GroupMemberInMultipleCollections(
string UserId,
string Role);
public record GroupTaskInMultipleCollections(
string TaskId,
string Title);
public class GroupWithMultipleCollectionsProjection : IProjectionFor<GroupWithMultipleCollections>
{
public void Define(IProjectionBuilderFor<GroupWithMultipleCollections> builder) => builder
.From<GroupCreatedWithMultipleCollections>()
.Children(m => m.Members, children => children
.IdentifiedBy(m => m.UserId)
.From<MemberAddedToGroup>(b => b
.UsingKey(e => e.UserId)))
.Children(m => m.Tasks, children => children
.IdentifiedBy(m => m.TaskId)
.From<TaskAssignedToGroup>(b => b
.UsingKey(e => e.TaskId)));
}

This pattern allows you to build rich, hierarchical read models from events.