Appended event metadata and projections
Projections build read models by mapping events to fields. They observe all events of the types declared in their definition — they do not use [FilterEventsByTag], [EventSourceType], or [EventStreamType] to filter incoming events.
How projections select their input
Section titled “How projections select their input”A projection declares the event types it observes through its event mappings:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[EventType]public record FilteringOrderPlaced(string CustomerId, decimal TotalAmount);
[EventType]public record FilteringOrderShipped(DateTimeOffset ShippedAt);
[FromEvent<FilteringOrderPlaced>][FromEvent<FilteringOrderShipped>]public record FilteringOrderSummary( [Key] string CustomerId, decimal TotalAmount, DateTimeOffset? ShippedAt);import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.readModels.ReadModelimport java.math.BigDecimalimport java.time.OffsetDateTime
@EventTypedata class FilteringOrderPlaced(val customerId: String, val totalAmount: BigDecimal)
@EventTypedata class FilteringOrderShipped(val shippedAt: OffsetDateTime)
@ReadModel@FromEvent(FilteringOrderPlaced::class)@FromEvent(FilteringOrderShipped::class)data class FilteringOrderSummary( val customerId: String = "", val totalAmount: BigDecimal = BigDecimal.ZERO, val shippedAt: OffsetDateTime? = null)import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.readModels.ReadModel;import java.math.BigDecimal;import java.time.OffsetDateTime;
@EventTyperecord FilteringOrderPlaced(String customerId, BigDecimal totalAmount) {}
@EventTyperecord FilteringOrderShipped(OffsetDateTime shippedAt) {}
@ReadModel@FromEvent(eventType = FilteringOrderPlaced.class)@FromEvent(eventType = FilteringOrderShipped.class)class FilteringOrderSummary { public String customerId = ""; public BigDecimal totalAmount = BigDecimal.ZERO; public OffsetDateTime shippedAt = null;}defmodule MyApp.Events.FilteringOrderPlaced do use Chronicle.Events.EventType, id: "filtering-order-placed"
defstruct [:customer_id, :total_amount]end
defmodule MyApp.Events.FilteringOrderShipped do use Chronicle.Events.EventType, id: "filtering-order-shipped"
defstruct [:shipped_at]end
defmodule MyApp.ReadModels.FilteringOrderSummary do use Chronicle.ReadModels.ReadModel
defstruct customer_id: nil, total_amount: nil, shipped_at: nil
from MyApp.Events.FilteringOrderPlaced, set: [customer_id: :customer_id, total_amount: :total_amount]
from MyApp.Events.FilteringOrderShipped, set: [shipped_at: :shipped_at]endimport { eventType, fromEvent } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()export class FilteringOrderPlaced { customerId = ''; totalAmount = 0;}
@eventType()export class FilteringOrderShipped { @field(Date) shippedAt: Date | null = null;}
@fromEvent(FilteringOrderPlaced)@fromEvent(FilteringOrderShipped)export class FilteringOrderSummary { @field(String) customerId = ''; @field(Number) totalAmount = 0; @field(Date) shippedAt: Date | null = null;}This projection receives every OrderPlaced and OrderShipped event regardless of any metadata attached during append. Metadata such as tags or stream type does not affect which events flow into a projection.
Tagging projections
Section titled “Tagging projections”[Tag] and [Tags] on a projection label the projection definition for organizational purposes. They do not filter incoming events:
using Cratis.Chronicle;using Cratis.Chronicle.Projections;
public record FilteringOrderReport(string CustomerId);
// Labels the projection for discoverability — does not affect which events are received[Tag("reporting")]public class FilteringOrderReportingProjection : IProjectionFor<FilteringOrderReport>{ public void Define(IProjectionBuilderFor<FilteringOrderReport> builder) => builder.From<FilteringOrderPlaced>(b => b.UsingKey(e => e.CustomerId));}Kotlin does not support this workflow yet.`@Tag` exists, but only labels a reactor or reducer — neither `ProjectionsService.buildDeclarativeDefinition`nor `buildModelBoundDefinition` ever reads a `Tag` annotation off a projection class, so tagging aprojection for discoverability has no effect yet.Java does not support this workflow yet.`@Tag` exists, but only labels a reactor or reducer — neither `ProjectionsService.buildDeclarativeDefinition`nor `buildModelBoundDefinition` ever reads a `Tag` annotation off a projection class, so tagging aprojection for discoverability has no effect yet.Elixir does not support this workflow yet.import { eventType, IProjectionBuilderFor, IProjectionFor, projection, tag } from '@cratis/chronicle';
@eventType()class FilteringTaggedOrderPlaced { customerId = '';}
class FilteringOrderReport { customerId = '';}
@projection('', FilteringOrderReport)// Sends a tag with the projection definition; does not filter received events.// Chronicle does not store projection tags yet (Cratis/Chronicle#4512).@tag('reporting')class FilteringOrderReportingProjection implements IProjectionFor<FilteringOrderReport> { define(builder: IProjectionBuilderFor<FilteringOrderReport>): void { builder.from(FilteringTaggedOrderPlaced, from => from.usingKey(event => event.customerId)); }}Combining a projection with metadata-based filtering
Section titled “Combining a projection with metadata-based filtering”When you need a side effect or secondary read model that reacts only to a subset of events based on appended metadata, pair the projection with a reactor or reducer that carries the appropriate filter attributes.
The following example shows an OrderSummaryProjection that builds the full read model for all orders, alongside a PremiumOrderNotifier reactor that fires only when an order is appended with the premium tag:
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Reactors;
[EventType]public record FilteringWithReactorOrderPlaced(string CustomerId, decimal TotalAmount);
// --- Append call ---// Carries the "premium" tag for orders that qualify// eventLog.Append(orderId, new FilteringWithReactorOrderPlaced(customerId, total), tags: ["premium"]);
// --- Projection: receives every OrderPlaced ---[FromEvent<FilteringWithReactorOrderPlaced>]public record FilteringWithReactorOrderSummary( [Key] string CustomerId, decimal TotalAmount);
// --- Reactor: receives only premium-tagged OrderPlaced ---[FilterEventsByTag("premium")]public class FilteringWithReactorPremiumOrderNotifier : IReactor{ public Task Placed(FilteringWithReactorOrderPlaced @event, EventContext context) => Task.CompletedTask;}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.FilterEventsByTagimport io.cratis.chronicle.observation.Reactorimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.readModels.ReadModel
@EventTypedata class FilteringWithReactorOrderPlaced(val customerId: String, val totalAmount: Double)
// --- Append call ---// Carries the "premium" tag for orders that qualify// eventStore.eventLog.append(orderId, FilteringWithReactorOrderPlaced(customerId, total), AppendOptions(tags = listOf("premium")))
// --- Projection: receives every OrderPlaced ---@ReadModel@FromEvent(FilteringWithReactorOrderPlaced::class)data class FilteringWithReactorOrderSummary( val customerId: String = "", val totalAmount: Double = 0.0)
// --- Reactor: receives only premium-tagged OrderPlaced ---@Reactor@FilterEventsByTag("premium")class FilteringWithReactorPremiumOrderNotifier { fun placed(event: FilteringWithReactorOrderPlaced, context: EventContext) { }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.FilterEventsByTag;import io.cratis.chronicle.observation.Reactor;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord FilteringWithReactorOrderPlaced(String customerId, double totalAmount) {}
// --- Append call ---// Carries the "premium" tag for orders that qualify// EventLogJavaBridge.append(eventStore.getEventLog(), orderId,// new FilteringWithReactorOrderPlaced(customerId, total),// new AppendOptionsBuilder().tag("premium").build());
// --- Projection: receives every OrderPlaced ---@ReadModel@FromEvent(eventType = FilteringWithReactorOrderPlaced.class)record FilteringWithReactorOrderSummary(String customerId, double totalAmount) {}
// --- Reactor: receives only premium-tagged OrderPlaced ---@Reactor@FilterEventsByTag("premium")class FilteringWithReactorPremiumOrderNotifier { void placed(FilteringWithReactorOrderPlaced event, EventContext context) { }}Elixir does not support this workflow yet.import { EventContext, eventType, filterEventsByTag, IEventStore, reactor, fromEvent } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class FilteringWithReactorOrderPlaced { @field(String) readonly customerId: string; @field(Number) readonly totalAmount: number;
constructor(customerId: string, totalAmount: number) { this.customerId = customerId; this.totalAmount = totalAmount; }}
class FilteringWithReactorOrderService { constructor(private readonly store: IEventStore) {}
async placePremiumOrder(orderId: string, customerId: string, totalAmount: number): Promise<void> { await this.store.eventLog.append(orderId, new FilteringWithReactorOrderPlaced(customerId, totalAmount), { tags: ['premium'] }); }}
@fromEvent(FilteringWithReactorOrderPlaced, { key: 'customerId' })class FilteringWithReactorOrderSummary { customerId = ''; totalAmount = 0;}
@reactor()@filterEventsByTag('premium')class FilteringWithReactorPremiumOrderNotifier { async filteringWithReactorOrderPlaced(_event: FilteringWithReactorOrderPlaced, _context: EventContext): Promise<void> {}}The same pattern works with a reducer instead of a reactor:
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
public record FilteringPremiumOrderTotals(int Count, decimal Total);
[FilterEventsByTag("premium")]public class FilteringPremiumOrderTotalsReducer : IReducerFor<FilteringPremiumOrderTotals>{ public FilteringPremiumOrderTotals Placed(FilteringWithReactorOrderPlaced @event, FilteringPremiumOrderTotals? current, EventContext context) => new((current?.Count ?? 0) + 1, (current?.Total ?? 0m) + @event.TotalAmount);}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.FilterEventsByTagimport io.cratis.chronicle.observation.Reducer
data class FilteringPremiumOrderTotals(val count: Int = 0, val total: Double = 0.0)
@Reducer@FilterEventsByTag("premium")class FilteringPremiumOrderTotalsReducer { fun placed( event: FilteringWithReactorOrderPlaced, current: FilteringPremiumOrderTotals?, context: EventContext ): FilteringPremiumOrderTotals = FilteringPremiumOrderTotals( count = (current?.count ?: 0) + 1, total = (current?.total ?: 0.0) + event.totalAmount )}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.observation.FilterEventsByTag;import io.cratis.chronicle.observation.Reducer;
record FilteringPremiumOrderTotals(int count, double total) {}
@Reducer@FilterEventsByTag("premium")class FilteringPremiumOrderTotalsReducer { FilteringPremiumOrderTotals placed( FilteringWithReactorOrderPlaced event, FilteringPremiumOrderTotals current, EventContext context ) { int count = current == null ? 0 : current.count(); double total = current == null ? 0.0 : current.total(); return new FilteringPremiumOrderTotals(count + 1, total + event.totalAmount()); }}Elixir does not support this workflow yet.import { eventType, filterEventsByTag, reducer } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class FilteringWithReducerOrderPlaced { @field(Number) readonly totalAmount: number;
constructor(totalAmount: number) { this.totalAmount = totalAmount; }}
class FilteringPremiumOrderTotals { count = 0; total = 0;}
@reducer('', undefined, FilteringPremiumOrderTotals)@filterEventsByTag('premium')class FilteringPremiumOrderTotalsReducer { filteringWithReducerOrderPlaced(event: FilteringWithReducerOrderPlaced, current: FilteringPremiumOrderTotals | undefined): FilteringPremiumOrderTotals { return { count: (current?.count ?? 0) + 1, total: (current?.total ?? 0) + event.totalAmount }; }}Use this pattern whenever you need both a projection-based read model (covering all events) and a metadata-filtered view or side effect (covering a subset).
Appending with metadata
Section titled “Appending with metadata”The metadata you provide at append time drives the filters on any accompanying reducers or reactors:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;
public class FilteringAppendService(IEventLog eventLog){ public async Task AppendOrders(string customerId) { // Appends to all observers — no extra metadata await eventLog.Append(EventSourceId.New(), new FilteringWithReactorOrderPlaced(customerId, 42m));
// Appends to all observers; additionally dispatched to observers filtering on "premium" await eventLog.Append(EventSourceId.New(), new FilteringWithReactorOrderPlaced(customerId, 299m), tags: ["premium"]);
// Appends with stream type; dispatched to observers filtering on "wholesale" stream type await eventLog.Append(EventSourceId.New(), new FilteringWithReactorOrderPlaced(customerId, 1500m), eventStreamType: "wholesale"); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.eventSequences.AppendOptionsimport java.util.UUID
class FilteringAppendService(private val eventStore: IEventStore) { suspend fun appendOrders(customerId: String) { // Appends to all observers — no extra metadata eventStore.eventLog.append( UUID.randomUUID().toString(), FilteringWithReactorOrderPlaced(customerId, 42.0) )
// Appends to all observers; additionally dispatched to observers filtering on "premium" eventStore.eventLog.append( UUID.randomUUID().toString(), FilteringWithReactorOrderPlaced(customerId, 299.0), AppendOptions(tags = listOf("premium")) )
// Appends with stream type; dispatched to observers filtering on "wholesale" stream type eventStore.eventLog.append( UUID.randomUUID().toString(), FilteringWithReactorOrderPlaced(customerId, 1500.0), AppendOptions(eventStreamType = "wholesale") ) }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.AppendOptionsBuilder;import io.cratis.chronicle.java.BlockingEventStore;
import java.util.UUID;
class FilteringAppendService { private final BlockingEventStore eventStore;
FilteringAppendService(IEventStore eventStore) { this.eventStore = new BlockingEventStore(eventStore); }
void appendOrders(String customerId) { // Appends to all observers — no extra metadata eventStore.getEventLog().append( UUID.randomUUID().toString(), new FilteringWithReactorOrderPlaced(customerId, 42.0));
// Appends to all observers; additionally dispatched to observers filtering on "premium" var premiumOptions = new AppendOptionsBuilder().tag("premium").build(); eventStore.getEventLog().append( UUID.randomUUID().toString(), new FilteringWithReactorOrderPlaced(customerId, 299.0), premiumOptions);
// Appends with stream type; dispatched to observers filtering on "wholesale" stream type var wholesaleOptions = new AppendOptionsBuilder().eventStreamType("wholesale").build(); eventStore.getEventLog().append( UUID.randomUUID().toString(), new FilteringWithReactorOrderPlaced(customerId, 1500.0), wholesaleOptions); }}defmodule MyApp.Events.FilteringMetadataOrderPlaced do use Chronicle.Events.EventType, id: "filtering-metadata-order-placed"
defstruct [:customer_id, :total_amount]end
defmodule MyApp.FilteringAppendService do alias MyApp.Events.FilteringMetadataOrderPlaced
def append_orders(order_id, customer_id) do # Appends to all observers — no extra metadata Chronicle.append(order_id, %FilteringMetadataOrderPlaced{customer_id: customer_id, total_amount: 42})
# Appends to all observers; additionally dispatched to observers filtering on "premium" Chronicle.append( order_id, %FilteringMetadataOrderPlaced{customer_id: customer_id, total_amount: 299}, tags: ["premium"] )
# Appends with stream type; dispatched to observers filtering on "wholesale" stream type Chronicle.append( order_id, %FilteringMetadataOrderPlaced{customer_id: customer_id, total_amount: 1500}, event_stream_type: "wholesale" ) endendimport { eventType, IEventLog } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class FilteringAppendOrderPlaced { @field(String) readonly customerId: string; @field(Number) readonly totalAmount: number;
constructor(customerId: string, totalAmount: number) { this.customerId = customerId; this.totalAmount = totalAmount; }}
class FilteringAppendService { constructor(private readonly eventLog: IEventLog) {}
async appendOrders(customerId: string): Promise<void> { // Appends to observers without a metadata filter. await this.eventLog.append('order-standard', new FilteringAppendOrderPlaced(customerId, 42));
// Also dispatched to observers filtering on the "premium" tag. await this.eventLog.append('order-premium', new FilteringAppendOrderPlaced(customerId, 299), { tags: ['premium'] });
// Dispatched to observers filtering on the "wholesale" stream type. await this.eventLog.append('order-wholesale', new FilteringAppendOrderPlaced(customerId, 1500), { streamType: 'wholesale' }); }}