Skip to content

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.

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

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.

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

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

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

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

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