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 the transitions
Section titled “Declare the transitions”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;}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using self-reducing read models from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.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.
| Part | Supported shape |
|---|---|
| Declaration | Public static, non-generic method named Reduce |
| Parameters | (event, current) or (event, current, EventContext) |
| Event | Non-object class or interface dispatch target |
| Current | Exactly the containing model type, nullable or nullable-oblivious |
| Result | The 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.
Discovery and upgrades
Section titled “Discovery and upgrades”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.
Arc query endpoints
Section titled “Arc query endpoints”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.
Register the model
Section titled “Register the model”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");}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before explicitly registering self-reducing read models from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.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.
Test the transitions
Section titled “Test the transitions”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);}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before testing self-reducing read models from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.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.
Configure observation and reads
Section titled “Configure observation and reads”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);}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before configuring self-reducing read models from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.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.
Handler selection
Section titled “Handler selection”Dispatch considers only concrete events registered in the owning client:
- An exact event parameter wins.
- Otherwise the nearest base-class handler wins over interface handlers.
- 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.
Optional replay hooks
Section titled “Optional replay hooks”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>();}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before adding replay hooks to self-reducing read models from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.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.
Reads, filters and preserved limits
Section titled “Reads, filters and preserved limits”[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/Tagslabel 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.
GetSnapshotsByIdreturns no reducer history today; it is not an arbitrary-date reducer query. Watchsupports reducers, butGetWatcherForremains 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
ContainerNameoverride.
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.
Diagnostics
Section titled “Diagnostics”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.