Skip to content

Passive and inactive reducers

Use a passive read model when you want to reconstruct state from events on demand instead of maintaining a sink. Disabling a reducer’s activity is a different choice: it stops observation, but does not change how the model is read.

ConfigurationActive observationSinkBy-id read
DefaultEnabledConfigured sinkCurrent materialized state, eventually consistent
[Reducer(isActive: false)] onlyDisabledStill configuredMaterialized state; it may be absent or stale
[Passive] on the read modelDisabledNoneReconstruct from the matching events

For a self reducer, the read model is also the reducer declaration, so place [Passive] on that same type. For a classic reducer, place it on the target model, not the reducer service.

Mark the read model [Passive]:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.ReadModels;
using Cratis.Chronicle.Reducers;
[EventType]
public record PassiveReducersTransactionCompleted(decimal Amount);
[Passive]
public record PassiveReducersAdHocReport(
decimal TotalRevenue,
int TransactionCount,
DateTimeOffset GeneratedAt);
public class PassiveReducersAdHocReportReducer : IReducerFor<PassiveReducersAdHocReport>
{
public PassiveReducersAdHocReport Completed(PassiveReducersTransactionCompleted @event, PassiveReducersAdHocReport? current, EventContext context)
{
var revenue = current?.TotalRevenue ?? 0m;
var count = current?.TransactionCount ?? 0;
return new PassiveReducersAdHocReport(
revenue + @event.Amount,
count + 1,
context.Occurred);
}
}

View C# snippet source on GitHub

This disables active observation regardless of the reducer’s isActive setting and selects on-demand reads through IReadModels. A sink collection cannot reconstruct passive state.

using Cratis.Chronicle;
using Cratis.Chronicle.ReadModels;
public class PassiveReducersReportingService(IEventStore eventStore)
{
public async Task<PassiveReducersMonthlyRevenueReport> GenerateReport(Guid reportId) =>
// This triggers the passive reducer to compute state from events
await eventStore.ReadModels.GetInstanceById<PassiveReducersMonthlyRevenueReport>(reportId);
}

View C# snippet source on GitHub

No matching history, or a history whose events are all filtered out, returns absent state. A null reducer result deletes state; a later accepted event can recreate it. Source/stream filters and tag filters apply to both classic and self passive reads with the same OR-within-tags and AND-across-categories semantics as active delivery.

Use isActive: false when you want the observer registered but not running:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reducers;
[EventType]
public record PassiveReducersDataRecorded(decimal Value);
public record PassiveReducersAnalytics(int RecordCount, decimal TotalValue, DateTimeOffset LastUpdated);
[Reducer(isActive: false)]
public class PassiveReducersTemporaryAnalyticsReducer : IReducerFor<PassiveReducersAnalytics>
{
public PassiveReducersAnalytics Recorded(PassiveReducersDataRecorded @event, PassiveReducersAnalytics? current, EventContext context)
{
var count = current?.RecordCount ?? 0;
var sum = current?.TotalValue ?? 0m;
return new PassiveReducersAnalytics(count + 1, sum + @event.Value, context.Occurred);
}
}

View C# snippet source on GitHub

This does not make the model passive. Previously persisted state remains in its configured sink and is no longer maintained. By-id reads do not switch to event reconstruction unless the read model carries [Passive].

Bounded analysis, not arbitrary-date history

Section titled “Bounded analysis, not arbitrary-date history”

A bounded GetInstances(eventCount) reconstructs from a prefix of event-type-selected events. The prefix is taken before tag/source/stream metadata filtering; rejected events do not cause Chronicle to read farther to find N accepted events.

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.ReadModels;
[EventType]
public record PassivePrefixAmountRecorded(decimal Amount);
[Passive]
public record PassiveReducersAccountBalance(decimal Balance)
{
public static PassiveReducersAccountBalance Reduce(PassivePrefixAmountRecorded @event, PassiveReducersAccountBalance? current) =>
new((current?.Balance ?? 0m) + @event.Amount);
}
public class PassiveReducersPrefixAnalysis(IEventStore eventStore)
{
public Task<IEnumerable<PassiveReducersAccountBalance>> GetBalancesFromPrefix(EventCount eventCount) =>
eventStore.ReadModels.GetInstances<PassiveReducersAccountBalance>(eventCount);
}

View C# snippet source on GitHub

This is not a date-based snapshot query. GetSnapshotsById currently returns no reducer history; current materialized snapshots and historical projection snapshots are different read surfaces.

Passive models fit infrequent queries whose reconstruction cost is acceptable:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.ReadModels;
using Cratis.Chronicle.Reducers;
[EventType]
public record PassiveReducersPaymentReceived(string Category, decimal Amount);
[Passive]
public record PassiveReducersMonthlyRevenueReport(
decimal TotalRevenue,
Dictionary<string, decimal> RevenueByCategory,
int Month,
int Year);
public class PassiveReducersMonthlyRevenueReportReducer : IReducerFor<PassiveReducersMonthlyRevenueReport>
{
public PassiveReducersMonthlyRevenueReport Received(PassiveReducersPaymentReceived @event, PassiveReducersMonthlyRevenueReport? current, EventContext context)
{
var revenue = current?.TotalRevenue ?? 0m;
var byCategory = current?.RevenueByCategory ?? new Dictionary<string, decimal>();
if (!byCategory.ContainsKey(@event.Category))
byCategory[@event.Category] = 0;
byCategory[@event.Category] += @event.Amount;
return new PassiveReducersMonthlyRevenueReport(
revenue + @event.Amount,
byCategory,
context.Occurred.Month,
context.Occurred.Year);
}
}

View C# snippet source on GitHub

An exploratory model can declare a passive state shape such as this; supply its reducer before reading it:

using Cratis.Chronicle.ReadModels;
[Passive]
public record PassiveReducersCustomerBehaviorAnalysis(
int UniqueCustomers,
decimal AverageOrderValue,
Dictionary<int, int> OrdersByHour);

View C# snippet source on GitHub

A large event-source history is folded again on each on-demand read. Prefer a materialized reducer for frequent reads, consider application caching where appropriate, and measure reconstruction cost rather than assuming on-demand is cheaper.

For development, disable observation without promising on-demand reads:

using Cratis.Chronicle.Reducers;
public record PassiveReducersExperimentalMetrics(int SampleCount);
#if DEBUG
[Reducer(isActive: false)]
#endif
public class PassiveReducersExperimentalMetricsReducer : IReducerFor<PassiveReducersExperimentalMetrics>;

View C# snippet source on GitHub

The following declarations illustrate alternative activity settings; choose the one your deployment needs:

using Cratis.Chronicle.Reducers;
public record PassiveReducersSwitchableReadModel(int Value);
// Was active, now passive
[Reducer(isActive: false)]
public class PassiveReducersSwitchableReducer : IReducerFor<PassiveReducersSwitchableReadModel>;

View C# snippet source on GitHub

Changing isActive updates observer activity, not the model’s read strategy. Adding or removing [Passive] changes sink selection and by-id reconstruction semantics. Treat that as a read-model configuration change; do not assume toggling isActive has the same effect.

A passive read is fresh when you read it, but it does not note a position in the log and does not guard the append that follows. Another writer can append between your read and your write. To make a rule hold, use a constraint or a decision read. A decision read can fold a reducer that has no filters and no constructor dependencies other than ILogger<T>, or a flat projection. See Decision reads. See Choosing how a rule holds.

Neither passive nor inactive reducers enable stream-id subscription filtering or exactly-once delivery. A passive or inactive reducer can still be read as a decision read under the same admission rules as any reducer. See self-reducer limits for the shared boundaries.