Skip to content

Self-reducing read models

Keep a read model’s transitions beside its state when a separate reducer adds no useful separation. Start with model-bound projection attributes, use fluent mappings when the definition needs structure, and choose reducer code when state transitions read better; a self-reducing model is the default for new reducers. Choose how to build a read model shows when fold-time dependencies or a shared contract call for a separate IReducerFor<T> instead.

A self reducer uses the same reducer pipeline, storage, filtering, and replay machinery as a classic reducer. Only discovery and invocation differ.

Declare public static Reduce methods on the concrete, non-generic read model. You need neither a marker attribute nor a parameterless constructor. Each method receives an event and the nullable current model, and returns the next state.

using Cratis.Chronicle.Events;
[EventType]
public record SelfBalanceAdded(decimal Amount);
[EventType]
public record SelfBalanceCleared;
public record SelfAccountBalance(decimal Balance)
{
public static SelfAccountBalance Reduce(SelfBalanceAdded @event, SelfAccountBalance? current) =>
new((current?.Balance ?? 0m) + @event.Amount);
public static SelfAccountBalance? Reduce(SelfBalanceCleared @event, SelfAccountBalance? current) => null;
}

View C# snippet source on GitHub

The first matching event for an event source receives null current state. Returning a new model creates or updates it; returning current ignores the event; returning null leaves it absent or deletes an existing instance. A later event can recreate it.

PartSupported shape
DeclarationPublic static, non-generic method named Reduce
Parameters(event, current) or (event, current, EventContext)
EventNon-object class or interface dispatch target
CurrentExactly the containing model type, nullable or nullable-oblivious
ResultThe model or Task of the model, including nullable results

No by-ref, optional, params, extra service or reordered arguments are accepted. Plain Task, ValueTask, and Task<object> are not state results. Async results are awaited before the next event. An explicitly non-nullable current parameter is a near miss: Chronicle can pass null even if the compiler annotation promises otherwise.

The complete static shape is the discovery signal, not the method name alone. An existing non-IReducer class with that shape becomes a self reducer after upgrading. Methods with other shapes remain helpers. A static near miss whose current or result refers to its model logs a discovery warning and is skipped; explicit registration rejects it with InvalidSelfReducerSignature.

Once a type has a valid self-reduction method, invalid sibling Reduce declarations and malformed replay hooks fail validation. A valid handler matching no event in the owning client’s registry creates no observer subscription. Unmarked events can participate when explicitly registered; assembly membership alone is not event membership.

Types implementing IReducer or IReducerFor<T> always remain classic, even with a self-shaped static helper. Their instance activation, dispatch and plain-Task behavior are unchanged.

In an Arc application, keep [ReadModel], the static Reduce methods, and query methods on the same record. Before Arc 22.90.0, use [ReadModel(RequireQueryAttribute = true)] and mark intended queries [Query] to prevent Reduce becoming an endpoint. From Arc 22.90.0, Arc’s automatic query discovery excludes methods taking the read model itself. Chronicle’s discovery does not change older Arc versions.

See Arc’s model-bound read models and queries for the query contract.

Default discovery recognizes the complete static shape in your application’s artifact catalog. Register the events through [EventType] or explicit nominations; assembly membership alone does not make a type an event. With manual or explicit artifact registration, nominate the model before creating the store, using the existing generic or Type RegisterReducer APIs:

using Cratis.Chronicle;
// These excerpts reuse SelfAccountBalance from the basic example.
public static class SelfBalanceRegistrations
{
public static void ConfigureGeneric(ChronicleOptions options) =>
options.ExplicitArtifacts.RegisterReducer<SelfAccountBalance>("account-balances");
public static void ConfigureRuntimeType(ChronicleOptions options) =>
options.ExplicitArtifacts.RegisterReducer(typeof(SelfAccountBalance), "account-balances");
}

View C# snippet source on GitHub

There is no separate self-reducer registration API. Equal explicit and discovered declarations coalesce; incompatible identities fail. Store snapshots and reconnect lifetime follow explicit artifact registration.

In the same client catalog, a self model cannot also be maintained by another reducer, a fluent projection, or genuine model-bound projection mappings: AmbiguousReadModelBuilder rejects the conflict. Shared configuration such as [Passive], [Reducer], sequence selection, and filters does not by itself establish projection ownership. Remove the previous builder before switching.

Use ReadModelScenario to fold the same events through the registered self reducer. This example checks accumulation, removal, and creation after removal:

using Cratis.Chronicle.Testing.ReadModels;
using Cratis.Specifications;
using Xunit;
// Uses SelfAccountBalance, SelfBalanceAdded, and SelfBalanceCleared declared above.
public class when_recreating_a_self_reduced_balance : Specification
{
readonly ReadModelScenario<SelfAccountBalance> _scenario = new();
SelfAccountBalance? _beforeRemoval;
SelfAccountBalance? _afterRemoval;
async Task Because()
{
await _scenario.Given.ForEventSource("account-42").Events(new SelfBalanceAdded(10m), new SelfBalanceAdded(5m));
_beforeRemoval = _scenario.Instance;
await _scenario.Given.ForEventSource("account-42").Events(new SelfBalanceCleared());
_afterRemoval = _scenario.Instance;
await _scenario.Given.ForEventSource("account-42").Events(new SelfBalanceAdded(2m));
}
[Fact] void should_accumulate_the_additions() => _beforeRemoval!.Balance.ShouldEqual(15m);
[Fact] void should_remove_the_instance() => _afterRemoval!.ShouldBeNull();
[Fact] void should_start_a_new_balance_after_removal() =>
_scenario.Instance!.Balance.ShouldEqual(2m);
}

View C# snippet source on GitHub

The model is absent after SelfBalanceCleared; the next addition receives null current and starts a new balance. Also cover an empty history and each event that can arrive without prior state. Scenarios substitute compliance and storage, so they do not prove encryption, erasure, replay, or transport.

Place reducer configuration on the model itself: [Reducer] controls identity, sequence, and activity. Sequence, event-store, descriptive tag, and metadata-filter attributes keep their existing purposes.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reducers;
[EventType]
public record ConfiguredBalanceAdded(decimal Amount);
[Reducer("verified-balances", eventSequence: "account-events")]
[FilterEventsByTag("verified")]
[EventSourceType("Account")]
[EventStreamType("Payments")]
[Tag("finance")]
public record ConfiguredSelfBalance(decimal Balance)
{
public static ConfiguredSelfBalance Reduce(ConfiguredBalanceAdded @event, ConfiguredSelfBalance? current) =>
new((current?.Balance ?? 0m) + @event.Amount);
}

View C# snippet source on GitHub

Read an instance with eventStore.ReadModels.GetInstanceById<SelfAccountBalance>(eventSourceId). Materialized reads use the stored state. Put [Passive] on the model for on-demand by-ID folding; [Reducer(isActive: false)] only stops active maintenance and does not make reads passive. Compare passive reducers before changing the read behavior.

Dispatch considers only concrete events registered in the owning client:

  1. An exact event parameter wins.
  2. Otherwise the nearest base-class handler wins over interface handlers.
  3. Otherwise the uniquely most-derived matching interface wins.

Equally specific handlers throw AmbiguousSelfReducerMethod. This includes two handlers for the same parameter with and without context. Declaration order and method names do not break ties. Separate clients can select different event sets without sharing registry-dependent dispatch or fingerprints.

The implementation fingerprint includes selected handlers and replay hooks, including inherited bodies and async state machines, together with the registry’s selected identities and generations. Existing kernel replay policy determines how changed implementations rebuild state.

Each hook is independently optional. Global hooks have service arguments only; partition hooks require Partition first. All must be public static non-generic methods returning exactly Task.

using Cratis.Chronicle.Observation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public record SelfReplayBalance(decimal Balance)
{
public static SelfReplayBalance Reduce(SelfBalanceAdded @event, SelfReplayBalance? current) =>
new((current?.Balance ?? 0m) + @event.Amount);
public static Task BeginReplay(BalanceReplayAudit audit) => audit.Write("begin");
public static Task EndReplay(BalanceReplayAudit audit) => audit.Write("end");
public static Task BeginReplayPartition(Partition partition, BalanceReplayAudit audit) =>
audit.Write($"begin partition {partition.Value}");
public static Task EndReplayPartition(Partition partition, BalanceReplayAudit audit) =>
audit.Write($"end partition {partition.Value}");
}
public sealed class BalanceReplayAudit(ILogger<BalanceReplayAudit> logger) : IAsyncDisposable
{
public Task Write(string phase)
{
logger.LogInformation("Replay phase: {Phase}", phase);
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
logger.LogInformation("Replay notification scope disposed");
return ValueTask.CompletedTask;
}
}
public static class BalanceReplayServices
{
public static void Configure(IServiceCollection services) => services.AddScoped<BalanceReplayAudit>();
}

View C# snippet source on GitHub

Trailing class/interface services are resolved from the delivered notification’s async DI scope. Events, EventContext, the model and extra partition arguments are not hook services. Missing registrations fail loudly. Chronicle awaits the hook and scope disposal before later notifications; an absent hook creates no scope and no model is activated.

Hooks follow the existing notification stream. A full replay sends global begin/end notifications; do not assume it also sends partition notifications. Failure does not guarantee a later end notification. Replay and delivery are not exactly-once operations.

  • [Passive] on the model selects on-demand by-id reduction. [Reducer(isActive: false)] only disables active observation; it does not turn materialized reads into passive reads. See passive reducers.
  • Passive reads and reducer scenarios apply the same tag/source/stream-category matching as active delivery: OR within tags, AND across categories. Tag/Tags label observers; they do not filter events. There is no stream-id subscription filter.
  • A bounded GetInstances(eventCount) takes a prefix of event-type-selected events before metadata filtering, not the first N accepted events. All-rejected or empty histories produce absent state.
  • Materialized snapshots are current stored state. GetSnapshotsById returns no reducer history today; it is not an arbitrary-date reducer query.
  • Watch supports reducers, but GetWatcherFor remains projection-only. Failed folds do not notify watchers, even if part of the batch reduced successfully. Successful folds still notify, including removal when the resulting state is null; a notification is not proof of successful persistence.
  • Inbox forwarding preserves append tags and source/stream routes for both classic and self reducers. Events forwarded by earlier releases that dropped tags remain untagged; upgrading does not backfill them.
  • Generation delivery, revisions and PII use the shared reducer engine. Passive/history queries retain classic query-generation behavior; they do not acquire pinned observer-delivery guarantees.
  • Reducer decision reads remain unsupported: a self-reducing model is refused like any reducer, so a rule that must hold when writers race needs a flat projection and a decision read. A passive or materialized self-reducing read does not guard an append. Container naming follows the existing naming policy; there is no reducer ContainerName override.

Use ReadModelScenario for transitions and metadata-filter examples. It substitutes compliance and does not prove encryption, erasure, storage, replay or transport behavior; those require real-kernel specifications.

CHR0058 warns about near-miss signatures, CHR0059 errors on malformed hooks, and CHR0060 errors on definite declaration ties. CHR0061 errors on definite ownership conflicts within one type and warns on possible peer ownership. Registry/plugin/separate-client membership remains runtime authority rather than a compilation error. Model state is not classic mutable reducer-service state.