Joins
Model-bound projections support joining data from different events using the Join attribute. This allows you to enrich your read models with data from related events.
Basic Join
Section titled “Basic Join”The Join attribute maps properties from events that are related through a common key:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[EventType]public record MbJoinsOrderPlaced(Guid CustomerId, decimal Amount);
[EventType]public record MbJoinsCustomerCreated(string Name);
public record MbJoinsOrderSummary( [Key] Guid OrderId,
[SetFrom<MbJoinsOrderPlaced>] decimal Amount,
[SetFrom<MbJoinsOrderPlaced>] Guid CustomerId,
[Join<MbJoinsCustomerCreated>( on: nameof(CustomerId), eventPropertyName: nameof(MbJoinsCustomerCreated.Name))] string CustomerName);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.JoinsOrderPlaced do use Chronicle.Events.EventType, id: "joins-order-placed-v1"
defstruct [:customer_id, :amount]end
defmodule MyApp.Events.JoinsCustomerCreated do use Chronicle.Events.EventType, id: "joins-customer-created-v1"
defstruct [:name]end
defmodule MyApp.ReadModels.JoinsOrderSummary do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.{JoinsOrderPlaced, JoinsCustomerCreated}
defstruct [:order_id, :amount, :customer_id, :customer_name]
from JoinsOrderPlaced, set: [order_id: :event_source_id, amount: :amount, customer_id: :customer_id]
join JoinsCustomerCreated, on: "customer_id", set: [customer_name: :name]endimport { eventType, Guid, join, readModel, setFrom } from '@cratis/chronicle';
@eventType()class MbJoinsOrderPlaced { customerId: Guid = Guid.empty; amount = 0;}
@eventType()class MbJoinsCustomerCreated { name = '';}
@readModel()class MbJoinsOrderSummary { id: Guid = Guid.empty;
@setFrom(MbJoinsOrderPlaced, 'amount') amount = 0;
@setFrom(MbJoinsOrderPlaced, 'customerId') customerId: Guid = Guid.empty;
@join(MbJoinsCustomerCreated, 'customerId', 'name') customerName = '';}Parameters
Section titled “Parameters”- on (optional): Property on the read model to join on. For root projections, this is typically required unless joining within children
- eventPropertyName (optional): Property name on the event. If not specified, uses the read model property name
Join in Children
Section titled “Join in Children”Joins work within child collections. When used in children, the on parameter is optional if the child has an identifiedBy property:
[EventType]public record MbJoinsLineItemAdded(Guid ProductId, int Quantity);
[EventType]public record MbJoinsProductUpdated(string ProductName, decimal CurrentPrice);
public record MbJoinsOrder( [Key] Guid OrderId,
[ChildrenFrom<MbJoinsLineItemAdded>(key: nameof(MbJoinsLineItemAdded.ProductId))] IEnumerable<MbJoinsOrderLine> Lines);
// The line's key is the product id, so the join to ProductUpdated (raised on that// same product's event source) resolves implicitly through the child's own key.public record MbJoinsOrderLine( [Key] Guid ProductId,
[SetFrom<MbJoinsLineItemAdded>] int Quantity,
[Join<MbJoinsProductUpdated>(eventPropertyName: nameof(MbJoinsProductUpdated.ProductName))] string ProductName,
[Join<MbJoinsProductUpdated>(eventPropertyName: nameof(MbJoinsProductUpdated.CurrentPrice))] decimal Price);Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { childrenFrom, eventType, Guid, join, readModel, setFrom } from '@cratis/chronicle';
@eventType()class MbJoinsLineItemAdded { productId: Guid = Guid.empty; quantity = 0;}
@eventType()class MbJoinsProductUpdated { productName = ''; currentPrice = 0;}
@readModel()class MbJoinsOrder { id: Guid = Guid.empty;
@childrenFrom(MbJoinsLineItemAdded, 'productId') lines: MbJoinsOrderLine[] = [];}
// The line's key is the product id, so the join to ProductUpdated (raised on that// same product's event source) resolves implicitly through the child's own key.class MbJoinsOrderLine { id: Guid = Guid.empty;
@setFrom(MbJoinsLineItemAdded, 'quantity') quantity = 0;
@join(MbJoinsProductUpdated, undefined, 'productName') productName = '';
@join(MbJoinsProductUpdated, undefined, 'currentPrice') price = 0;}Multiple Joins
Section titled “Multiple Joins”You can join with multiple different events:
[EventType]public record MbJoinsMultipleOrderPlaced(Guid CustomerId);
[EventType]public record MbJoinsMultipleCustomerCreated(string Name);
[EventType]public record MbJoinsCustomerUpdated(string Email);
[EventType]public record MbJoinsShippingAddressSet(string Address);
public record MbJoinsEnrichedOrder( [Key] Guid OrderId,
[SetFrom<MbJoinsMultipleOrderPlaced>] Guid CustomerId,
[Join<MbJoinsMultipleCustomerCreated>(on: nameof(CustomerId))] string CustomerName,
[Join<MbJoinsCustomerUpdated>(on: nameof(CustomerId))] string CustomerEmail,
// ShippingAddressSet is raised on the order's own event source, so it joins on the // read model's own key rather than a separate correlating property. [Join<MbJoinsShippingAddressSet>(on: nameof(OrderId))] string ShippingAddress);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.JoinsMultipleOrderPlaced do use Chronicle.Events.EventType, id: "joins-multiple-order-placed-v1"
defstruct [:customer_id]end
defmodule MyApp.Events.JoinsMultipleCustomerCreated do use Chronicle.Events.EventType, id: "joins-multiple-customer-created-v1"
defstruct [:name]end
defmodule MyApp.Events.JoinsCustomerUpdated do use Chronicle.Events.EventType, id: "joins-customer-updated-v1"
defstruct [:email]end
defmodule MyApp.Events.JoinsShippingAddressSet do use Chronicle.Events.EventType, id: "joins-shipping-address-set-v1"
defstruct [:address]end
defmodule MyApp.ReadModels.JoinsEnrichedOrder do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.{ JoinsMultipleOrderPlaced, JoinsMultipleCustomerCreated, JoinsCustomerUpdated, JoinsShippingAddressSet }
defstruct [:order_id, :customer_id, :customer_name, :customer_email, :shipping_address]
from JoinsMultipleOrderPlaced, set: [order_id: :event_source_id, customer_id: :customer_id]
join JoinsMultipleCustomerCreated, on: "customer_id", set: [customer_name: :name]
join JoinsCustomerUpdated, on: "customer_id", set: [customer_email: :email]
# Raised on the order's own event source, so it joins on the read model's # own key rather than a separate correlating property. join JoinsShippingAddressSet, on: "order_id", set: [shipping_address: :address]endimport { eventType, Guid, join, readModel, setFrom } from '@cratis/chronicle';
@eventType()class MbJoinsMultipleOrderPlaced { customerId: Guid = Guid.empty;}
@eventType()class MbJoinsMultipleCustomerCreated { name = '';}
@eventType()class MbJoinsCustomerUpdated { email = '';}
@eventType()class MbJoinsShippingAddressSet { address = '';}
@readModel()class MbJoinsEnrichedOrder { id: Guid = Guid.empty;
@setFrom(MbJoinsMultipleOrderPlaced, 'customerId') customerId: Guid = Guid.empty;
@join(MbJoinsMultipleCustomerCreated, 'customerId', 'name') customerName = '';
@join(MbJoinsCustomerUpdated, 'customerId', 'email') customerEmail = '';
// ShippingAddressSet is raised on the order's own event source, so it joins on the // read model's own key rather than a separate correlating property. @join(MbJoinsShippingAddressSet, 'id', 'address') shippingAddress = '';}Recursive Join Processing
Section titled “Recursive Join Processing”Join attributes on related types are processed recursively:
[EventType]public record MbJoinsSourcesLineItemAdded(Guid ProductId);
[EventType]public record MbJoinsSourcesProductCatalogUpdated(string Name, string Description);
[EventType]public record MbJoinsSourcesPricingUpdated(decimal CurrentPrice);
public record MbJoinsSourcesOrder( [Key] Guid OrderId,
[ChildrenFrom<MbJoinsSourcesLineItemAdded>(key: nameof(MbJoinsSourcesLineItemAdded.ProductId))] IEnumerable<MbJoinsSourcesOrderLine> Lines);
// Keyed by product id, so both joins below resolve implicitly through the child's own key.public record MbJoinsSourcesOrderLine( [Key] Guid ProductId,
[Join<MbJoinsSourcesProductCatalogUpdated>(eventPropertyName: nameof(MbJoinsSourcesProductCatalogUpdated.Name))] string ProductName,
[Join<MbJoinsSourcesProductCatalogUpdated>(eventPropertyName: nameof(MbJoinsSourcesProductCatalogUpdated.Description))] string Description,
[Join<MbJoinsSourcesPricingUpdated>(eventPropertyName: nameof(MbJoinsSourcesPricingUpdated.CurrentPrice))] decimal UnitPrice);Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { childrenFrom, eventType, Guid, join, readModel } from '@cratis/chronicle';
@eventType()class MbJoinsSourcesLineItemAdded { productId: Guid = Guid.empty;}
@eventType()class MbJoinsSourcesProductCatalogUpdated { name = ''; description = '';}
@eventType()class MbJoinsSourcesPricingUpdated { currentPrice = 0;}
@readModel()class MbJoinsSourcesOrder { id: Guid = Guid.empty;
@childrenFrom(MbJoinsSourcesLineItemAdded, 'productId') lines: MbJoinsSourcesOrderLine[] = [];}
// Keyed by product id, so both joins below resolve implicitly through the child's own key.class MbJoinsSourcesOrderLine { id: Guid = Guid.empty;
@join(MbJoinsSourcesProductCatalogUpdated, undefined, 'name') productName = '';
@join(MbJoinsSourcesProductCatalogUpdated, undefined, 'description') description = '';
@join(MbJoinsSourcesPricingUpdated, undefined, 'currentPrice') unitPrice = 0;}Complete Example
Section titled “Complete Example”Here’s a comprehensive example showing joins at multiple levels:
// Events[EventType]public record MbJoinsFullOrderPlaced(Guid CustomerId, DateTimeOffset PlacedAt);
[EventType]public record MbJoinsFullCustomerRegistered(string Name, string Email);
[EventType]public record MbJoinsFullCustomerProfileUpdated(string PhoneNumber);
[EventType]public record MbJoinsFullLineItemAdded(Guid ProductId, int Quantity);
[EventType]public record MbJoinsFullProductCreated(string Name, decimal Price);
[EventType]public record MbJoinsFullProductPriceChanged(decimal NewPrice);
// Read Modelspublic record MbJoinsFullOrderDetails( [Key] Guid OrderId,
[SetFrom<MbJoinsFullOrderPlaced>] DateTimeOffset PlacedAt,
[SetFrom<MbJoinsFullOrderPlaced>] Guid CustomerId,
// Join customer information [Join<MbJoinsFullCustomerRegistered>( on: nameof(CustomerId), eventPropertyName: nameof(MbJoinsFullCustomerRegistered.Name))] string CustomerName,
[Join<MbJoinsFullCustomerRegistered>( on: nameof(CustomerId), eventPropertyName: nameof(MbJoinsFullCustomerRegistered.Email))] string CustomerEmail,
[Join<MbJoinsFullCustomerProfileUpdated>( on: nameof(CustomerId), eventPropertyName: nameof(MbJoinsFullCustomerProfileUpdated.PhoneNumber))] string CustomerPhone,
[ChildrenFrom<MbJoinsFullLineItemAdded>(key: nameof(MbJoinsFullLineItemAdded.ProductId))] IEnumerable<MbJoinsFullLineItemDetails> Items);
// Keyed by product id, so the joins below resolve implicitly through the child's own key.public record MbJoinsFullLineItemDetails( [Key] Guid ProductId,
[SetFrom<MbJoinsFullLineItemAdded>] int Quantity,
// Join product information [Join<MbJoinsFullProductCreated>(eventPropertyName: nameof(MbJoinsFullProductCreated.Name))] string ProductName,
[Join<MbJoinsFullProductCreated>(eventPropertyName: nameof(MbJoinsFullProductCreated.Price))] [Join<MbJoinsFullProductPriceChanged>(eventPropertyName: nameof(MbJoinsFullProductPriceChanged.NewPrice))] decimal Price);Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { childrenFrom, eventType, Guid, join, readModel, setFrom } from '@cratis/chronicle';
// Events@eventType()class MbJoinsFullOrderPlaced { customerId: Guid = Guid.empty; placedAt = new Date();}
@eventType()class MbJoinsFullCustomerRegistered { name = ''; email = '';}
@eventType()class MbJoinsFullCustomerProfileUpdated { phoneNumber = '';}
@eventType()class MbJoinsFullLineItemAdded { productId: Guid = Guid.empty; quantity = 0;}
@eventType()class MbJoinsFullProductCreated { name = ''; price = 0;}
@eventType()class MbJoinsFullProductPriceChanged { newPrice = 0;}
// Read Models@readModel()class MbJoinsFullOrderDetails { id: Guid = Guid.empty;
@setFrom(MbJoinsFullOrderPlaced, 'placedAt') placedAt = new Date();
@setFrom(MbJoinsFullOrderPlaced, 'customerId') customerId: Guid = Guid.empty;
// Join customer information @join(MbJoinsFullCustomerRegistered, 'customerId', 'name') customerName = '';
@join(MbJoinsFullCustomerRegistered, 'customerId', 'email') customerEmail = '';
@join(MbJoinsFullCustomerProfileUpdated, 'customerId', 'phoneNumber') customerPhone = '';
@childrenFrom(MbJoinsFullLineItemAdded, 'productId') items: MbJoinsFullLineItemDetails[] = [];}
// Keyed by product id, so the joins below resolve implicitly through the child's own key.class MbJoinsFullLineItemDetails { id: Guid = Guid.empty;
@setFrom(MbJoinsFullLineItemAdded, 'quantity') quantity = 0;
// Join product information @join(MbJoinsFullProductCreated, undefined, 'name') productName = '';
@join(MbJoinsFullProductCreated, undefined, 'price') @join(MbJoinsFullProductPriceChanged, undefined, 'newPrice') price = 0;}Event Processing Flow
Section titled “Event Processing Flow”- CustomerRegistered - Customer data becomes available for joining
- ProductCreated - Product data becomes available for joining
- OrderPlaced - Order is created, joins pull in customer data
- LineItemAdded - Line item is added, joins pull in product data
- ProductPriceChanged - Updates price on all relevant line items through join
- CustomerProfileUpdated - Updates phone number on all relevant orders through join
Join vs SetFrom
Section titled “Join vs SetFrom”SetFrom:
- Maps properties from events directly related to the entity
- Event is “about” the entity (same event source ID)
- Direct parent-child relationship
Join:
- Maps properties from events about related entities
- Event is about a different entity but shares a common key
- Used for enrichment and denormalization
Best Practices
Section titled “Best Practices”- Use meaningful join keys - Ensure the
onparameter clearly identifies the relationship - Handle missing data - Joins may not find matching data; consider nullable properties
- Be mindful of updates - Joined data updates when the source event changes
- Avoid circular joins - Don’t create circular dependencies between projections
- Consider cardinality - Joins work best for one-to-one and many-to-one relationships