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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub