Skip to content

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.

Define a key made up of multiple properties when one event value is not enough to identify the read model instance:

Composite key projection
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)));
}

View C# snippet source on GitHub

Define a record or class to represent your composite key:

Composite key type
public record CompositeOrderKey(string CustomerId, string OrderNumber);

View C# snippet source on GitHub

The read model’s Id property should match your composite key type:

Read model with composite key
public record CompositeOrder(
CompositeOrderKey Id,
string CustomerName,
DateTimeOffset OrderDate,
DateTimeOffset? ShippedDate);

View C# snippet source on GitHub

You can combine event properties with event context properties in composite keys:

Composite key from event content and context
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)));
}

View C# snippet source on GitHub

The corresponding key and read model:

Composite audit key and read model
public record AuditEntryKey(string UserId, DateTimeOffset Timestamp);
public record AuditEntryWithCompositeKey(
AuditEntryKey Id,
string Action,
string Details);

View C# snippet source on GitHub

Composite keys work with child collections too:

Composite key in a child collection
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))));
}

View C# snippet source on GitHub

Composite keys can be used in join scenarios:

Join with a composite key
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)));
}

View C# snippet source on GitHub

Events used by composite key projections
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);

View C# snippet source on GitHub

  1. Consistent structure: All events that target the same projection must use the same composite key structure
  2. Immutable parts: Key components should not change during the lifetime of a projection instance
  3. Uniqueness: The combination of all key parts must uniquely identify each projection instance
  4. Type safety: Key components are strongly typed and validated at compile time

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.

  • 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.