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.
Defining a projection with joins
Section titled “Defining a projection with joins”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 .AutoMap() .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));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Projections.DecJoinsUserProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecJoinsUser
alias MyApp.Events.{ DecJoinsUserCreated, DecJoinsUserAssignedToGroup, DecJoinsGroupCreated, DecJoinsGroupRenamed }
from DecJoinsUserCreated
from DecJoinsUserAssignedToGroup, key: :user_id, set: [group_id: :event_source_id]
join DecJoinsGroupCreated, on: :group_id join DecJoinsGroupRenamed, on: :group_idendimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class DecJoinsUserCreated { name = ''; email = '';}
@eventType()class DecJoinsUserAssignedToGroup { userId = ''; groupId = '';}
@eventType()class DecJoinsGroupCreated { name = ''; description = '';}
@eventType()class DecJoinsGroupRenamed { newName = '';}
@projection()class DecJoinsUserProjection implements IProjectionFor<DecJoinsUser> { define(builder: IProjectionBuilderFor<DecJoinsUser>): void { builder .autoMap() .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)); }}Read model with joined data
Section titled “Read model with joined data”The read model includes properties populated from different event sources:
public record DecJoinsUser( string Name, string Email, string? GroupId, string? GroupName, string? GroupDescription);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.DecJoinsUser do use Chronicle.ReadModels.ReadModel
defstruct [:name, :email, :group_id, :group_name, :group_description]endclass DecJoinsUser { name = ''; email = ''; groupId: string | null = null; groupName: string | null = null; groupDescription: string | null = null;}Event definitions
Section titled “Event definitions”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);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.DecJoinsUserCreated do use Chronicle.Events.EventType, id: "dec-joins-user-created"
defstruct [:name, :email]end
defmodule MyApp.Events.DecJoinsUserAssignedToGroup do use Chronicle.Events.EventType, id: "dec-joins-user-assigned-to-group"
defstruct [:user_id, :group_id]end
defmodule MyApp.Events.DecJoinsGroupCreated do use Chronicle.Events.EventType, id: "dec-joins-group-created"
defstruct [:name, :description]end
defmodule MyApp.Events.DecJoinsGroupRenamed do use Chronicle.Events.EventType, id: "dec-joins-group-renamed"
defstruct [:new_name]endHow joins work
Section titled “How joins work”- Primary events establish the read model and may set join keys
- Join conditions specify which property links to other streams
- Joined events update properties when their event source ID matches the join key
- Join properties are updated whenever relevant events occur
Join scenarios
Section titled “Join scenarios”Setting join keys
Section titled “Setting join keys”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().
Joining on the key
Section titled “Joining on the key”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.
Multiple joins
Section titled “Multiple joins”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 .AutoMap() .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));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.DecJoinsMultipleEmployeeAssigned do use Chronicle.Events.EventType, id: "dec-joins-multiple-employee-assigned"
defstruct [:group_id, :department_id, :location_id]end
defmodule MyApp.Events.DecJoinsMultipleGroupCreated do use Chronicle.Events.EventType, id: "dec-joins-multiple-group-created"
defstruct [:name]end
defmodule MyApp.Events.DecJoinsMultipleDepartmentCreated do use Chronicle.Events.EventType, id: "dec-joins-multiple-department-created"
defstruct [:name]end
defmodule MyApp.Events.DecJoinsMultipleLocationUpdated do use Chronicle.Events.EventType, id: "dec-joins-multiple-location-updated"
defstruct [:address]end
defmodule MyApp.ReadModels.DecJoinsMultipleEmployeeSummary do use Chronicle.ReadModels.ReadModel
defstruct [:group_id, :group_name, :department_id, :department_name, :location_id, :location_address]end
defmodule MyApp.Projections.DecJoinsMultipleEmployeeSummaryProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecJoinsMultipleEmployeeSummary
alias MyApp.Events.{ DecJoinsMultipleEmployeeAssigned, DecJoinsMultipleGroupCreated, DecJoinsMultipleDepartmentCreated, DecJoinsMultipleLocationUpdated }
from DecJoinsMultipleEmployeeAssigned
join DecJoinsMultipleGroupCreated, on: :group_id join DecJoinsMultipleDepartmentCreated, on: :department_id join DecJoinsMultipleLocationUpdated, on: :location_idendimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class DecJoinsMultipleEmployeeAssigned { groupId = ''; departmentId = ''; locationId = '';}
@eventType()class DecJoinsMultipleGroupCreated { name = '';}
@eventType()class DecJoinsMultipleDepartmentCreated { name = '';}
@eventType()class DecJoinsMultipleLocationUpdated { address = '';}
class DecJoinsMultipleEmployeeSummary { groupId: string | null = null; groupName: string | null = null; departmentId: string | null = null; departmentName: string | null = null; locationId: string | null = null; locationAddress: string | null = null;}
@projection()class DecJoinsMultipleEmployeeSummaryProjection implements IProjectionFor<DecJoinsMultipleEmployeeSummary> { define(builder: IProjectionBuilderFor<DecJoinsMultipleEmployeeSummary>): void { builder .autoMap() .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)); }}Joining children
Section titled “Joining children”Joins can also be used within child collections:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;
[EventType]public record DecJoinsChildTaskAssigned(string TaskId, string ProjectId);
[EventType]public record DecJoinsChildProjectCreated(string Name);
public record DecJoinsChildTask(string TaskId, string ProjectId, string? ProjectName);
public record DecJoinsChildProjectBoard(IEnumerable<DecJoinsChildTask> Tasks);
public class DecJoinsChildProjectBoardProjection : IProjectionFor<DecJoinsChildProjectBoard>{ public void Define(IProjectionBuilderFor<DecJoinsChildProjectBoard> builder) => builder .Children(m => m.Tasks, children => children .IdentifiedBy(e => e.TaskId) .AutoMap() .From<DecJoinsChildTaskAssigned>(b => b .UsingKey(e => e.TaskId)) .Join<DecJoinsChildProjectCreated>(j => j .On(m => m.ProjectId)));}Performance considerations
Section titled “Performance considerations”- 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.