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

Define a record or class to represent your composite key:

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

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

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

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

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

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