---
title: Self-reducing read models
editUrl: https://github.com/Cratis/Chronicle/edit/main/Documentation/reducers/self-reducing-read-models.mdx
description: Declare static state transitions on a read model without a separate reducer class.
---

import { Tabs, TabItem } from '@astrojs/starlight/components';


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](/chronicle/projections/choosing-a-read-model-style/) 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.

:::caution[Check existing models when upgrading]
An existing concrete, non-generic model with a complete public static `Reduce(event, nullable current[, EventContext])`
shape becomes a reducer after upgrading, even if you add no marker. Review existing static helpers and
remove any competing builder before registering that model. Near misses warn and are skipped by
implicit discovery; explicit registration rejects them.
:::

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

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/basic.md)

</TabItem>
<TabItem label="Kotlin">

```text
Kotlin does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Kotlin snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/basic.md)

</TabItem>
<TabItem label="Java">

```text
Java does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Java snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets-java/reducers/self-reducing-read-models/basic.md)

</TabItem>
<TabItem label="Elixir">

```text
This Chronicle client does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before using self-reducing read models from Elixir.
```

[View Elixir snippet source on GitHub](https://github.com/Cratis/Chronicle.Elixir/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/basic.md)

</TabItem>
<TabItem label="TypeScript">

```text
TypeScript does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.
```

[View TypeScript snippet source on GitHub](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/basic.md)

</TabItem>
</Tabs>

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

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

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](/arc/backend/csharp/queries/model-bound/) for the query contract.

## 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:

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/explicit-registration.md)

</TabItem>
<TabItem label="Kotlin">

```text
Kotlin does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Kotlin snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/explicit-registration.md)

</TabItem>
<TabItem label="Java">

```text
Java does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Java snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets-java/reducers/self-reducing-read-models/explicit-registration.md)

</TabItem>
<TabItem label="Elixir">

```text
This 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.
```

[View Elixir snippet source on GitHub](https://github.com/Cratis/Chronicle.Elixir/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/explicit-registration.md)

</TabItem>
<TabItem label="TypeScript">

```text
TypeScript does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.
```

[View TypeScript snippet source on GitHub](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/explicit-registration.md)

</TabItem>
</Tabs>

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](/chronicle/clients/dotnet/explicit-artifacts/).

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

Use [ReadModelScenario](/chronicle/testing/read-models/scenario/) to fold the same events through the
registered self reducer. This example checks accumulation, removal, and creation after removal:

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/testing.md)

</TabItem>
<TabItem label="Kotlin">

```text
Kotlin does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Kotlin snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/testing.md)

</TabItem>
<TabItem label="Java">

```text
Java does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Java snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets-java/reducers/self-reducing-read-models/testing.md)

</TabItem>
<TabItem label="Elixir">

```text
This Chronicle client does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before testing self-reducing read models from Elixir.
```

[View Elixir snippet source on GitHub](https://github.com/Cratis/Chronicle.Elixir/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/testing.md)

</TabItem>
<TabItem label="TypeScript">

```text
TypeScript does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.
```

[View TypeScript snippet source on GitHub](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/testing.md)

</TabItem>
</Tabs>

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

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.

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/configuration.md)

</TabItem>
<TabItem label="Kotlin">

```text
Kotlin does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Kotlin snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/configuration.md)

</TabItem>
<TabItem label="Java">

```text
Java does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Java snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets-java/reducers/self-reducing-read-models/configuration.md)

</TabItem>
<TabItem label="Elixir">

```text
This Chronicle client does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before configuring self-reducing read models from Elixir.
```

[View Elixir snippet source on GitHub](https://github.com/Cratis/Chronicle.Elixir/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/configuration.md)

</TabItem>
<TabItem label="TypeScript">

```text
TypeScript does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.
```

[View TypeScript snippet source on GitHub](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/configuration.md)

</TabItem>
</Tabs>

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](/chronicle/reducers/passive-reducers/) before changing the read behavior.

## Handler selection

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.

## 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`.

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/replay-hooks.md)

</TabItem>
<TabItem label="Kotlin">

```text
Kotlin does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Kotlin snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/replay-hooks.md)

</TabItem>
<TabItem label="Java">

```text
Java does not support this workflow yet.
Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851
```

[View Java snippet source on GitHub](https://github.com/Cratis/Chronicle.Kotlin/blob/main/Documentation/client-snippets-java/reducers/self-reducing-read-models/replay-hooks.md)

</TabItem>
<TabItem label="Elixir">

```text
This 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.
```

[View Elixir snippet source on GitHub](https://github.com/Cratis/Chronicle.Elixir/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/replay-hooks.md)

</TabItem>
<TabItem label="TypeScript">

```text
TypeScript does not support this workflow yet.
Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.
```

[View TypeScript snippet source on GitHub](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/reducers/self-reducing-read-models/replay-hooks.md)

</TabItem>
</Tabs>

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

- `[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](/chronicle/reducers/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](/chronicle/read-models/decision-reads/). 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](/chronicle/testing/read-models/scenario/) 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

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