Skip to content

Projection with joins

Joins allow projections to incorporate data from events that don’t share the same event source ID. This enables building read models that combine data from different streams.

Use the Join() method to include data from events with different keys:

using Cratis.Chronicle.Projections;
public class DecJoinsUserProjection : IProjectionFor<DecJoinsUser>
{
public void Define(IProjectionBuilderFor<DecJoinsUser> builder) => builder
.From<DecJoinsUserCreated>()
.From<DecJoinsUserAssignedToGroup>(b => b
.UsingKey(e => e.UserId)
.Set(m => m.GroupId).ToEventSourceId())
.Join<DecJoinsGroupCreated>(j => j
.On(m => m.GroupId))
.Join<DecJoinsGroupRenamed>(j => j
.On(m => m.GroupId));
}

View C# snippet source on GitHub

The read model includes properties populated from different event sources:

public record DecJoinsUser(
string Name,
string Email,
string? GroupId,
string? GroupName,
string? GroupDescription);

View C# snippet source on GitHub

Events come from different streams but are joined based on common identifiers:

using Cratis.Chronicle.Events;
// User stream events
[EventType]
public record DecJoinsUserCreated(string Name, string Email);
[EventType]
public record DecJoinsUserAssignedToGroup(string UserId, string GroupId);
// Group stream events
[EventType]
public record DecJoinsGroupCreated(string Name, string Description);
[EventType]
public record DecJoinsGroupRenamed(string NewName);

View C# snippet source on GitHub

  1. Primary events establish the read model and may set join keys
  2. Join conditions specify which property links to other streams
  3. Joined events update properties when their event source ID matches the join key
  4. Join properties are updated whenever relevant events occur
  5. Joined values take precedence — a joined property is applied over any value a From block wrote for the same property, in whichever order the events arrived

Join keys are typically set from events that establish relationships — in the example above, UserAssignedToGroup sets GroupId to the group’s own event source ID via .ToEventSourceId().

Joins match events based on their event source ID and the join property — .Join<GroupCreated>(j => j.On(m => m.GroupId)) links in group data whenever a GroupCreated/GroupRenamed event’s event source ID matches the read model’s GroupId.

When a From block and a join write the same property, the joined value wins — regardless of arrival order. Whenever the projection handles a From event, Chronicle re-resolves the join against the joined stream’s last stored event and applies the joined properties after the From mappings. A From block therefore cannot reset or clear a property a join has written; there is no “clear this joined value” operation. If a property must reflect which fact happened most recently, give each fact its own property (for example, two timestamps) and compare them, rather than latching a single flag written from both sides. The CHR0042 analyzer warns when the same property is written from both sides.

A projection can join with multiple streams:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record DecJoinsMultipleEmployeeAssigned(string GroupId, string DepartmentId, string LocationId);
[EventType]
public record DecJoinsMultipleGroupCreated(string Name);
[EventType]
public record DecJoinsMultipleDepartmentCreated(string Name);
[EventType]
public record DecJoinsMultipleLocationUpdated(string Address);
public record DecJoinsMultipleEmployeeSummary(
string? GroupId,
string? GroupName,
string? DepartmentId,
string? DepartmentName,
string? LocationId,
string? LocationAddress);
public class DecJoinsMultipleEmployeeSummaryProjection : IProjectionFor<DecJoinsMultipleEmployeeSummary>
{
public void Define(IProjectionBuilderFor<DecJoinsMultipleEmployeeSummary> builder) => builder
.From<DecJoinsMultipleEmployeeAssigned>()
.Join<DecJoinsMultipleGroupCreated>(j => j.On(m => m.GroupId))
.Join<DecJoinsMultipleDepartmentCreated>(j => j.On(m => m.DepartmentId))
.Join<DecJoinsMultipleLocationUpdated>(j => j.On(m => m.LocationId));
}

View C# snippet source on GitHub

Joins can also be used within child collections. Inside Children a join always matches the joined event’s event source against the child’s identity (IdentifiedBy), so .On() is not used there. Identify the children by the id of the stream you join; here each member on a project board gets its name from the member’s own MemberRegistered event:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections;
[EventType]
public record DecJoinsChildMemberAssignedToProject(string ProjectId, string MemberId);
[EventType]
public record DecJoinsChildMemberRegistered(string Name);
public record DecJoinsChildProjectMember(string MemberId, string? Name);
public record DecJoinsChildProjectBoard(IEnumerable<DecJoinsChildProjectMember> Members);
public class DecJoinsChildProjectBoardProjection : IProjectionFor<DecJoinsChildProjectBoard>
{
public void Define(IProjectionBuilderFor<DecJoinsChildProjectBoard> builder) => builder
.Children(m => m.Members, children => children
.IdentifiedBy(e => e.MemberId)
.From<DecJoinsChildMemberAssignedToProject>(b => b
.UsingParentKey(e => e.ProjectId)
.UsingKey(e => e.MemberId))
.Join<DecJoinsChildMemberRegistered>());
}

View C# snippet source on GitHub

Automatic mapping in declaration-language joins

Section titled “Automatic mapping in declaration-language joins”

A with <Event> block inherits the projection’s automap setting by default. Use no automap inside that with block to map only properties you declare explicitly, even when the joined event and read model have same-named properties. Use automap there to enable matching by name even if the enclosing projection disables it. This setting also applies when Chronicle backfills joined values from earlier events. Per-join automap and no automap are declaration-language only: the .NET JoinBuilder and the C#, Kotlin, and TypeScript code generators do not express them. Code generated from a declaration with a per-join override will not preserve that override; keep the declaration as the source of truth.

  • Joins require Chronicle to track relationships between streams
  • The system automatically manages join indexes and updates
  • Consider the frequency of joined events when designing projections
  • Large numbers of joins may impact projection performance

Joins enable powerful cross-stream read models while maintaining the benefits of event sourcing and proper stream boundaries.