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));}import io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
class DecJoinsUserProjection : IProjectionFor<DecJoinsUser> { override fun define(builder: IProjectionBuilderFor<DecJoinsUser>) { builder .from(DecJoinsUserCreated::class) .from(DecJoinsUserAssignedToGroup::class) { it.usingKey("userId") it.set(DecJoinsUser::groupId).toEventSourceId() } .join(DecJoinsGroupCreated::class) { it.on(DecJoinsUser::groupId) } .join(DecJoinsGroupRenamed::class) { it.on(DecJoinsUser::groupId) } }}import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
class DecJoinsUserProjection implements IProjectionFor<DecJoinsUser> { @Override public void define(IProjectionBuilderFor<DecJoinsUser> builder) { builder .from(DecJoinsUserCreated.class) .from(DecJoinsUserAssignedToGroup.class, fb -> { fb.usingKey("userId"); fb.set("groupId").toEventSourceId(); }) .join(DecJoinsGroupCreated.class, jb -> { jb.on("groupId"); }) .join(DecJoinsGroupRenamed.class, jb -> { jb.on("groupId"); }); }}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 { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@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);data class DecJoinsUser( val name: String = "", val email: String = "", val groupId: String? = null, val groupName: String? = null, val groupDescription: String? = null)class DecJoinsUser { public String name = ""; public String email = ""; public String groupId = null; public String groupName = null; public String groupDescription = null;}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);import io.cratis.chronicle.events.EventType
// User stream events@EventTypedata class DecJoinsUserCreated(val name: String, val email: String)
@EventTypedata class DecJoinsUserAssignedToGroup(val userId: String, val groupId: String)
// Group stream events@EventTypedata class DecJoinsGroupCreated(val name: String, val description: String)
@EventTypedata class DecJoinsGroupRenamed(val newName: String)import io.cratis.chronicle.events.EventType;
// User stream events@EventTyperecord DecJoinsUserCreated(String name, String email) {}
@EventTyperecord DecJoinsUserAssignedToGroup(String userId, String groupId) {}
// Group stream events@EventTyperecord DecJoinsGroupCreated(String name, String description) {}
@EventTyperecord DecJoinsGroupRenamed(String newName) {}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]endimport { eventType } from '@cratis/chronicle';
// User stream events@eventType()class DecJoinsUserCreated { constructor(readonly name: string, readonly email: string) {}}
@eventType()class DecJoinsUserAssignedToGroup { constructor(readonly userId: string, readonly groupId: string) {}}
// Group stream events@eventType()class DecJoinsGroupCreated { constructor(readonly name: string, readonly description: string) {}}
@eventType()class DecJoinsGroupRenamed { constructor(readonly newName: string) {}}How 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
- Joined values take precedence — a joined property is applied over any value a
Fromblock wrote for the same property, in whichever order the events arrived
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.
Join precedence
Section titled “Join precedence”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.
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));}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
@EventTypedata class DecJoinsMultipleEmployeeAssigned(val groupId: String, val departmentId: String, val locationId: String)
@EventTypedata class DecJoinsMultipleGroupCreated(val name: String)
@EventTypedata class DecJoinsMultipleDepartmentCreated(val name: String)
@EventTypedata class DecJoinsMultipleLocationUpdated(val address: String)
data class DecJoinsMultipleEmployeeSummary( val groupId: String? = null, val groupName: String? = null, val departmentId: String? = null, val departmentName: String? = null, val locationId: String? = null, val locationAddress: String? = null)
class DecJoinsMultipleEmployeeSummaryProjection : IProjectionFor<DecJoinsMultipleEmployeeSummary> { override fun define(builder: IProjectionBuilderFor<DecJoinsMultipleEmployeeSummary>) { builder .from(DecJoinsMultipleEmployeeAssigned::class) .join(DecJoinsMultipleGroupCreated::class) { it.on(DecJoinsMultipleEmployeeSummary::groupId) } .join(DecJoinsMultipleDepartmentCreated::class) { it.on(DecJoinsMultipleEmployeeSummary::departmentId) } .join(DecJoinsMultipleLocationUpdated::class) { it.on(DecJoinsMultipleEmployeeSummary::locationId) } }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
@EventTyperecord DecJoinsMultipleEmployeeAssigned(String groupId, String departmentId, String locationId) {}
@EventTyperecord DecJoinsMultipleGroupCreated(String name) {}
@EventTyperecord DecJoinsMultipleDepartmentCreated(String name) {}
@EventTyperecord DecJoinsMultipleLocationUpdated(String address) {}
class DecJoinsMultipleEmployeeSummary { public String groupId = null; public String groupName = null; public String departmentId = null; public String departmentName = null; public String locationId = null; public String locationAddress = null;}
class DecJoinsMultipleEmployeeSummaryProjection implements IProjectionFor<DecJoinsMultipleEmployeeSummary> { @Override public void define(IProjectionBuilderFor<DecJoinsMultipleEmployeeSummary> builder) { builder .from(DecJoinsMultipleEmployeeAssigned.class) .join(DecJoinsMultipleGroupCreated.class, jb -> { jb.on("groupId"); }) .join(DecJoinsMultipleDepartmentCreated.class, jb -> { jb.on("departmentId"); }) .join(DecJoinsMultipleLocationUpdated.class, jb -> { jb.on("locationId"); }); }}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)));}Kotlin does not support this workflow yet.`IChildrenBuilderFor` has no `join()` method — joins are only available on the top-level`IProjectionBuilderFor<TReadModel>`, not inside a child collection scope.Java does not support this workflow yet.`IChildrenBuilderFor` has no `join()` method — joins are only available on the top-level`IProjectionBuilderFor<TReadModel>`, not inside a child collection scope.Elixir does not support this workflow yet.import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class DecJoinsChildTaskAssigned { constructor(readonly taskId: string, readonly projectId: string) {}}
@eventType()class DecJoinsChildProjectCreated { constructor(readonly name: string) {}}
class DecJoinsChildTask { taskId = ''; projectId = ''; projectName: string | null = null;}
class DecJoinsChildProjectBoard { tasks: DecJoinsChildTask[] = [];}
@projection()class DecJoinsChildProjectBoardProjection implements IProjectionFor<DecJoinsChildProjectBoard> { define(builder: IProjectionBuilderFor<DecJoinsChildProjectBoard>): void { builder .children<DecJoinsChildTask>(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.