Projection with composite keys
When a single property isn’t sufficient to uniquely identify a projection instance, you can use composite keys made up of multiple values. This is useful for multi-tenant scenarios, hierarchical data, or when you need complex keys.
Defining a composite key
Section titled “Defining a composite key”Define a key made up of multiple properties when one event value is not enough to identify the read model instance:
using Cratis.Chronicle.Projections;
public class CompositeOrderProjection : IProjectionFor<CompositeOrder>{ public void Define(IProjectionBuilderFor<CompositeOrder> builder) => builder .From<CompositeOrderCreated>(created => created .UsingCompositeKey<CompositeOrderKey>(key => key .Set(k => k.CustomerId).To(e => e.CustomerId) .Set(k => k.OrderNumber).To(e => e.OrderNumber))) .From<CompositeOrderShipped>(shipped => shipped .UsingCompositeKey<CompositeOrderKey>(key => key .Set(k => k.CustomerId).To(e => e.CustomerId) .Set(k => k.OrderNumber).To(e => e.OrderNumber)));}import io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
class CompositeOrderProjection : IProjectionFor<CompositeOrder> { override fun define(builder: IProjectionBuilderFor<CompositeOrder>) { builder .from(CompositeOrderCreated::class) { it.usingCompositeKey { key -> key.property("customerId", "customerId") .property("orderNumber", "orderNumber") } } .from(CompositeOrderShipped::class) { it.usingCompositeKey { key -> key.property("customerId", "customerId") .property("orderNumber", "orderNumber") } } }}import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
class CompositeOrderProjection implements IProjectionFor<CompositeOrder> { @Override public void define(IProjectionBuilderFor<CompositeOrder> builder) { builder.from(CompositeOrderCreated.class, it -> { it.usingCompositeKey(key -> { key.property("customerId", "customerId").property("orderNumber", "orderNumber"); }); }); builder.from(CompositeOrderShipped.class, it -> { it.usingCompositeKey(key -> { key.property("customerId", "customerId").property("orderNumber", "orderNumber"); }); }); }}defmodule MyApp.Projections.CompositeOrderProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.CompositeOrder
alias MyApp.Events.{CompositeOrderCreated, CompositeOrderShipped}
from CompositeOrderCreated, key: {:composite, [customer_id: :customer_id, order_number: :order_number]}
from CompositeOrderShipped, key: {:composite, [customer_id: :customer_id, order_number: :order_number]}endimport { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@projection('')class CompositeOrderProjection implements IProjectionFor<CompositeOrder> { define(builder: IProjectionBuilderFor<CompositeOrder>): void { builder .from(CompositeOrderCreated, from => from .usingCompositeKey<CompositeOrderKey>(key => key .set(target => target.customerId, event => event.customerId) .set(target => target.orderNumber, event => event.orderNumber))) .from(CompositeOrderShipped, from => from .usingCompositeKey<CompositeOrderKey>(key => key .set(target => target.customerId, event => event.customerId) .set(target => target.orderNumber, event => event.orderNumber))); }}Composite key type
Section titled “Composite key type”Define a record or class to represent your composite key:
public record CompositeOrderKey(string CustomerId, string OrderNumber);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.CompositeOrderKey do defstruct [:customer_id, :order_number]endclass CompositeOrderKey { customerId = ''; orderNumber = '';}Read model with composite key
Section titled “Read model with composite key”The read model’s Id property should match your composite key type:
public record CompositeOrder( CompositeOrderKey Id, string CustomerName, DateTimeOffset OrderDate, DateTimeOffset? ShippedDate);data class CompositeOrder( val customerName: String = "", val orderDate: String = "", val shippedDate: String? = null)class CompositeOrder { public String customerName = ""; public String orderDate = ""; public String shippedDate = null;}defmodule MyApp.ReadModels.CompositeOrder do defstruct [:id, :customer_name, :order_date, :shipped_date]endexport class CompositeOrder { id = ''; customerName = ''; orderDate = new Date(); shippedDate?: Date;}Composite keys with event context
Section titled “Composite keys with event context”You can combine event properties with event context properties in composite keys:
using Cratis.Chronicle.Projections;
public class AuditEntryProjectionWithCompositeKey : IProjectionFor<AuditEntryWithCompositeKey>{ public void Define(IProjectionBuilderFor<AuditEntryWithCompositeKey> builder) => builder .From<CompositeUserAction>(action => action .UsingCompositeKey<AuditEntryKey>(key => key .Set(k => k.UserId).To(e => e.UserId) .Set(k => k.Timestamp).ToEventContextProperty(c => c.Occurred)));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Projections.AuditEntryProjectionWithCompositeKey do use Chronicle.Projections.Projection, model: MyApp.ReadModels.AuditEntryWithCompositeKey
alias MyApp.Events.CompositeUserAction
from CompositeUserAction, key: {:composite, [ user_id: :user_id, timestamp: {:event_context, "Occurred"} ]}endimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class CompositeUserAction { @field(String) userId = ''; @field(String) action = ''; @field(String) details = '';}
@projection('', AuditEntryWithCompositeKey)class AuditEntryProjectionWithCompositeKey implements IProjectionFor<AuditEntryWithCompositeKey> { define(builder: IProjectionBuilderFor<AuditEntryWithCompositeKey>): void { builder.from(CompositeUserAction, from => from .usingCompositeKey<AuditEntryKey>(key => key .set(target => target.userId).to(event => event.userId) .set(target => target.timestamp).toEventContextProperty('occurred'))); }}The corresponding key and read model:
public record AuditEntryKey(string UserId, DateTimeOffset Timestamp);
public record AuditEntryWithCompositeKey( AuditEntryKey Id, string Action, string Details);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.AuditEntryKey do defstruct [:user_id, :timestamp]end
defmodule MyApp.ReadModels.AuditEntryWithCompositeKey do defstruct [:id, :action, :details]endimport { field } from '@cratis/fundamentals';
class AuditEntryKey { @field(String) userId = ''; @field(Date) timestamp = new Date();}
class AuditEntryWithCompositeKey { @field(AuditEntryKey) id = new AuditEntryKey(); @field(String) action = ''; @field(String) details = '';}Composite keys in child collections
Section titled “Composite keys in child collections”Composite keys work with child collections too:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;
[EventType]public record CompositeOrderCreatedForItems(string CustomerId, string OrderNumber);
[EventType]public record CompositeItemAddedToOrder( string CustomerId, string OrderNumber, string ProductId, string Variant, int Quantity);
public record CompositeOrderWithItems( CompositeOrderKey Id, IEnumerable<CompositeOrderItem> OrderItems);
public record CompositeOrderItem( CompositeItemKey Id, string ProductId, string Variant, int Quantity);
public record CompositeItemKey(string ProductId, string Variant);
public class CompositeOrderItemsProjection : IProjectionFor<CompositeOrderWithItems>{ public void Define(IProjectionBuilderFor<CompositeOrderWithItems> builder) => builder .From<CompositeOrderCreatedForItems>(created => created .UsingCompositeKey<CompositeOrderKey>(key => key .Set(k => k.CustomerId).To(e => e.CustomerId) .Set(k => k.OrderNumber).To(e => e.OrderNumber))) .Children(m => m.OrderItems, items => items .IdentifiedBy(m => m.Id) .From<CompositeItemAddedToOrder>(added => added .UsingParentCompositeKey<CompositeOrderKey>(key => key .Set(k => k.CustomerId).To(e => e.CustomerId) .Set(k => k.OrderNumber).To(e => e.OrderNumber)) .UsingCompositeKey<CompositeItemKey>(key => key .Set(k => k.ProductId).To(e => e.ProductId) .Set(k => k.Variant).To(e => e.Variant))));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.CompositeOrderCreatedForItems do use Chronicle.Events.EventType, id: "composite-order-created-for-items"
defstruct [:customer_id, :order_number]end
defmodule MyApp.Events.CompositeItemAddedToOrder do use Chronicle.Events.EventType, id: "composite-item-added-to-order"
defstruct [:customer_id, :order_number, :product_id, :variant, quantity: 0]end
defmodule MyApp.ReadModels.CompositeItemKey do defstruct [:product_id, :variant]end
defmodule MyApp.ReadModels.CompositeOrderItem do defstruct [:id, :product_id, :variant, quantity: 0]end
defmodule MyApp.ReadModels.CompositeOrderWithItems do use Chronicle.ReadModels.ReadModel, types: [order_items: {:list, MyApp.ReadModels.CompositeOrderItem}]
defstruct id: nil, order_items: []end
defmodule MyApp.Projections.CompositeOrderItemsProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.CompositeOrderWithItems
alias MyApp.Events.{CompositeItemAddedToOrder, CompositeOrderCreatedForItems}
from CompositeOrderCreatedForItems, key: {:composite, [customer_id: :customer_id, order_number: :order_number]}
children :order_items, identified_by: :id do from CompositeItemAddedToOrder, parent_key: {:composite, [customer_id: :customer_id, order_number: :order_number]}, key: {:composite, [product_id: :product_id, variant: :variant]}, set: [product_id: :product_id, variant: :variant, quantity: :quantity] endendimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()export class CompositeItemAddedToOrder { @field(String) readonly customerId: string; @field(String) readonly orderNumber: string; @field(String) readonly productId: string; @field(String) readonly variant: string; @field(Number) readonly quantity: number;
constructor(customerId: string = '', orderNumber: string = '', productId: string = '', variant: string = '', quantity: number = 0) { this.customerId = customerId; this.orderNumber = orderNumber; this.productId = productId; this.variant = variant; this.quantity = quantity; }}
export class CompositeItemKey { productId = ''; variant = '';}
export class CompositeOrderItem { id = ''; productId = ''; variant = ''; quantity = 0;}
export class CompositeOrderWithItems { id = ''; orderItems: CompositeOrderItem[] = [];}
@projection('', CompositeOrderWithItems)export class CompositeOrderItemsProjection implements IProjectionFor<CompositeOrderWithItems> { define(builder: IProjectionBuilderFor<CompositeOrderWithItems>): void { builder .from(CompositeOrderCreated, from => from .usingCompositeKey<CompositeOrderKey>(key => key .set(target => target.customerId, event => event.customerId) .set(target => target.orderNumber, event => event.orderNumber))) .children<CompositeOrderItem>(model => model.orderItems, items => items .identifiedBy(model => model.id) .from(CompositeItemAddedToOrder, from => from .usingParentCompositeKey<CompositeOrderKey>(key => key .set(target => target.customerId, event => event.customerId) .set(target => target.orderNumber, event => event.orderNumber)) .usingCompositeKey<CompositeItemKey>(key => key .set(target => target.productId, event => event.productId) .set(target => target.variant, event => event.variant)))); }}Joins with composite keys
Section titled “Joins with composite keys”Composite keys can be used in join scenarios:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;
[EventType]public record CompositeProductUpdated( string ProductId, string Variant, string ProductName);
public record CompositeProductKey(string ProductId, string Variant);
public record CompositeOrderLine( CompositeProductKey ProductKey, string ProductName);
public class CompositeOrderLineProjection : IProjectionFor<CompositeOrderLine>{ public void Define(IProjectionBuilderFor<CompositeOrderLine> builder) => builder .Join<CompositeProductUpdated>(product => product .On(m => m.ProductKey) .UsingCompositeKey<CompositeProductKey>(key => key .Set(k => k.ProductId).To(e => e.ProductId) .Set(k => k.Variant).To(e => e.Variant)));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.CompositeProductUpdated do use Chronicle.Events.EventType, id: "composite-product-updated"
defstruct [:product_id, :variant, :product_name]end
defmodule MyApp.ReadModels.CompositeProductKey do defstruct [:product_id, :variant]end
defmodule MyApp.ReadModels.CompositeOrderLine do use Chronicle.ReadModels.ReadModel
defstruct [:product_key, :product_name]end
defmodule MyApp.Projections.CompositeOrderLineProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.CompositeOrderLine
alias MyApp.Events.CompositeProductUpdated
join CompositeProductUpdated, on: :product_key, key: {:composite, [product_id: :product_id, variant: :variant]}, set: [product_name: :product_name]endimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()export class CompositeProductUpdated { @field(String) readonly productId: string; @field(String) readonly variant: string; @field(String) readonly productName: string;
constructor(productId: string = '', variant: string = '', productName: string = '') { this.productId = productId; this.variant = variant; this.productName = productName; }}
export class CompositeProductKey { productId = ''; variant = '';}
export class CompositeOrderLine { id = ''; productName = '';}
@projection('', CompositeOrderLine)export class CompositeOrderLineProjection implements IProjectionFor<CompositeOrderLine> { define(builder: IProjectionBuilderFor<CompositeOrderLine>): void { builder .join(CompositeProductUpdated, join => join .on(model => model.id) .usingCompositeKey<CompositeProductKey>(key => key .set(target => target.productId, event => event.productId) .set(target => target.variant, event => event.variant))); }}Event definitions
Section titled “Event definitions”using Cratis.Chronicle.Events;
[EventType]public record CompositeOrderCreated( string CustomerId, string OrderNumber, string CustomerName, DateTimeOffset OrderDate);
[EventType]public record CompositeOrderShipped( string CustomerId, string OrderNumber, DateTimeOffset ShippedDate);
[EventType]public record CompositeUserAction( string UserId, string Action, string Details);import io.cratis.chronicle.events.EventType
@EventTypedata class CompositeOrderCreated( val customerId: String, val orderNumber: String, val customerName: String, val orderDate: String)
@EventTypedata class CompositeOrderShipped( val customerId: String, val orderNumber: String, val shippedDate: String)
@EventTypedata class CompositeUserAction( val userId: String, val action: String, val details: String)import io.cratis.chronicle.events.EventType;
@EventTyperecord CompositeOrderCreated( String customerId, String orderNumber, String customerName, String orderDate) {}
@EventTyperecord CompositeOrderShipped( String customerId, String orderNumber, String shippedDate) {}
@EventTyperecord CompositeUserAction( String userId, String action, String details) {}defmodule MyApp.Events.CompositeOrderCreated do use Chronicle.Events.EventType, id: "composite-order-created"
defstruct [:customer_id, :order_number, :customer_name, :order_date]end
defmodule MyApp.Events.CompositeOrderShipped do use Chronicle.Events.EventType, id: "composite-order-shipped"
defstruct [:customer_id, :order_number, :shipped_date]end
defmodule MyApp.Events.CompositeUserAction do use Chronicle.Events.EventType, id: "composite-user-action"
defstruct [:user_id, :action, :details]endimport { eventType } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class CompositeOrderCreated { @field(String) readonly customerId: string; @field(String) readonly orderNumber: string; @field(String) readonly customerName: string; @field(Date) readonly orderDate: Date;
constructor(customerId: string = '', orderNumber: string = '', customerName: string = '', orderDate: Date = new Date()) { this.customerId = customerId; this.orderNumber = orderNumber; this.customerName = customerName; this.orderDate = orderDate; }}
@eventType()class CompositeOrderShipped { @field(String) readonly customerId: string; @field(String) readonly orderNumber: string; @field(Date) readonly shippedDate: Date;
constructor(customerId: string = '', orderNumber: string = '', shippedDate: Date = new Date()) { this.customerId = customerId; this.orderNumber = orderNumber; this.shippedDate = shippedDate; }}Key composition rules
Section titled “Key composition rules”- Consistent structure: All events that target the same projection must use the same composite key structure
- Immutable parts: Key components should not change during the lifetime of a projection instance
- Uniqueness: The combination of all key parts must uniquely identify each projection instance
- Type safety: Key components are strongly typed and validated at compile time
Kernel compatibility
Section titled “Kernel compatibility”Projection declarations that specify a composite key type require Chronicle kernel v19.8.1 or later. Older kernels reject the type name when projecting events. Upgrade the kernel before deploying those declarations. Existing fluent .NET projections continue to emit untyped composite-key expressions so a newer client can still target an older kernel; the updated kernel also reads those stored expressions without migration. The connection-time compatibility check covers wire versions, not this projection-expression behavior.
Generating declarations from stored projections
Section titled “Generating declarations from stored projections”When generating a declaration from an untyped fluent composite-key expression, Chronicle infers the type from the read model’s Id property (including nullable object types) or the child item’s Id for a child collection. The .NET client now includes the CLR key type name in the generated schema, so declarations generated from these fluent projections can be compiled again. Fluent expressions remain untyped for compatibility with older kernels. For a manually supplied schema without a key type name, generation still uses CompositeKey as a best-effort placeholder; that declaration cannot recompile unless the schema defines a CompositeKey object with the mapped properties.
Performance considerations
Section titled “Performance considerations”- Index efficiency: Composite keys create complex indexes in the underlying storage
- Query patterns: Consider how you’ll query the data when designing key structure
- Key size: Larger composite keys use more storage and may impact performance
- Sort order: The order of properties in the composite key affects index efficiency
Composite keys provide powerful flexibility for complex identification scenarios while maintaining type safety and performance.