Skip to content

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.

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);
  • 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

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);

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);

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);

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 Models
public 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);
  1. CustomerRegistered - Customer data becomes available for joining
  2. ProductCreated - Product data becomes available for joining
  3. OrderPlaced - Order is created, joins pull in customer data
  4. LineItemAdded - Line item is added, joins pull in product data
  5. ProductPriceChanged - Updates price on all relevant line items through join
  6. CustomerProfileUpdated - Updates phone number on all relevant orders through join

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
  1. Use meaningful join keys - Ensure the on parameter clearly identifies the relationship
  2. Handle missing data - Joins may not find matching data; consider nullable properties
  3. Be mindful of updates - Joined data updates when the source event changes
  4. Avoid circular joins - Don’t create circular dependencies between projections
  5. Consider cardinality - Joins work best for one-to-one and many-to-one relationships