Reducers: full control of the read model, persistence for free
A reducer is a method per event. Chronicle hands it the event and the current state of the read model, you return the next state, and Chronicle stores whatever you return. You decide the shape and the content of the read model, and storage is not your problem. It sits between a projection and a reactor, and it’s what I reach for when a read model reads better as code than as a mapping.
Plenty of ways to get a read model
Section titled “Plenty of ways to get a read model”Let’s face it, Chronicle gives you more than one way to turn events into a read model, and that can be a bit much when you’re new to it. There are projections, written as attributes on the read model, as a fluent builder or as text. And there are reducers. The projections are the declarative side: you describe how events map onto the model, and the engine does the rest.
The reducer is the imperative side. I think of it as the odd one out, and, to be honest, as the one that makes the other two easier to understand, because it shows you what the declarative ones are doing on your behalf.
Between a projection and a reactor
Section titled “Between a projection and a reactor”The easiest way to place a reducer is to put it next to its two neighbors. A projection and a reducer both build state you can query. A reactor builds nothing. It reacts to events by doing something, like notifying someone or triggering a command. Chronicle has a page that puts all three side by side, and this is the short version:

Who writes what. The reducer takes the code from the reactor and the storage from the projection.
What I like about this is where the line falls. With a reactor you get all the freedom, and with that freedom comes the job of making it safe to run more than once, because Chronicle will run it more than once. With a projection you get the least code, but you’re limited to what the attributes and the builder can say. The reducer gives you code for the part where you want control, which is the shape and the content of the read model, and takes the persistence off your hands. It’s a nice cross between the two, really.
One method per event
Section titled “One method per event”The model is a fold, the thing functional programmers have been doing to lists forever. Chronicle calls your reducer once per event, in order, for one event source at a time, and passes in the state the previous event produced. What you return becomes the current state for the next event, and it’s also what gets stored.

Three events folded into one state. The first event sees no state at all.
An account balance shows the shape, one method per event:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
[EventType]public record DepositMade(decimal Amount);
[EventType]public record WithdrawalMade(decimal Amount);
public record AccountBalance(decimal Balance, DateTimeOffset LastUpdated);
public class AccountBalanceReducer : IReducerFor<AccountBalance>{ public AccountBalance Deposited(DepositMade @event, AccountBalance? current, EventContext context) { var currentBalance = current?.Balance ?? 0m; return new AccountBalance(currentBalance + @event.Amount, context.Occurred); }
public AccountBalance WithdrawalMade(WithdrawalMade @event, AccountBalance? current, EventContext context) { var currentBalance = current?.Balance ?? 0m; return new AccountBalance(currentBalance - @event.Amount, context.Occurred); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport java.time.Instant
@EventTypedata class DepositMade(val amount: Double)
@EventTypedata class WithdrawalMade(val amount: Double)
@ReadModeldata class AccountBalance(val balance: Double = 0.0, val lastUpdated: Instant = Instant.EPOCH)
@Reducerclass AccountBalanceReducer { fun deposited(event: DepositMade, current: AccountBalance?): AccountBalance { val currentBalance = current?.balance ?: 0.0 return AccountBalance(currentBalance + event.amount, Instant.now()) }
fun withdrawalMade(event: WithdrawalMade, current: AccountBalance?): AccountBalance { val currentBalance = current?.balance ?: 0.0 return AccountBalance(currentBalance - event.amount, Instant.now()) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;import java.time.Instant;
@EventTyperecord DepositMade(double amount) {}
@EventTyperecord WithdrawalMade(double amount) {}
@ReadModelrecord AccountBalance(double balance, Instant lastUpdated) { AccountBalance() { this(0.0, Instant.EPOCH); }}
@Reducerclass AccountBalanceReducer { AccountBalance deposited(DepositMade event, AccountBalance current) { double currentBalance = current != null ? current.balance() : 0.0; return new AccountBalance(currentBalance + event.amount(), Instant.now()); }
AccountBalance withdrawalMade(WithdrawalMade event, AccountBalance current) { double currentBalance = current != null ? current.balance() : 0.0; return new AccountBalance(currentBalance - event.amount(), Instant.now()); }}import { eventType, reducer } from '@cratis/chronicle';
@eventType()class DepositMade { amount = 0;}
@eventType()class WithdrawalMade { amount = 0;}
class AccountBalance { balance = 0; lastUpdated: Date = new Date(0);}
// Handler method names must be the exact camelCase of the event's class name -// Chronicle discovers handlers by name, not by parameter type.@reducer('', undefined, AccountBalance)class AccountBalanceReducer { depositMade( event: DepositMade, current: AccountBalance | undefined ): AccountBalance { const currentBalance = current?.balance ?? 0; return { balance: currentBalance + event.amount, lastUpdated: new Date() }; }
withdrawalMade( event: WithdrawalMade, current: AccountBalance | undefined ): AccountBalance { const currentBalance = current?.balance ?? 0; return { balance: currentBalance - event.amount, lastUpdated: new Date() }; }}defmodule MyApp.Events.DepositMade do use Chronicle.Events.EventType, id: "deposit-made"
defstruct [:amount]end
defmodule MyApp.Events.WithdrawalMade do use Chronicle.Events.EventType, id: "withdrawal-made"
defstruct [:amount]end
defmodule MyApp.ReadModels.AccountBalance do defstruct balance: 0, last_updated: nilend
defmodule MyApp.Reducers.AccountBalanceReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.AccountBalance
alias MyApp.Events.{DepositMade, WithdrawalMade} alias MyApp.ReadModels.AccountBalance
@handles DepositMade @handles WithdrawalMade
@impl true def reduce(%DepositMade{} = event, current, context) do current_balance = if current, do: current.balance, else: 0
%AccountBalance{ balance: current_balance + event.amount, last_updated: Map.get(context, :occurred) } end
def reduce(%WithdrawalMade{} = event, current, context) do current_balance = if current, do: current.balance, else: 0
%AccountBalance{ balance: current_balance - event.amount, last_updated: Map.get(context, :occurred) } endendA few things in that signature are worth knowing. The method gets the event, the current state, and optionally the EventContext with things like when the event occurred. The name of the method is yours to choose, because Chronicle finds the handler by the type of the first parameter. And current is nullable, because for the first event of an event source there is no state yet. Handle that, and you’ve handled the hardest part.
Two return values are special. Return current unchanged and the event is ignored. Return null and the read model is cleared, so don’t use it to mean “nothing to see here”. That’s a mistake I’d make at least once.
I should be honest about this example too. A running balance is something the projection attributes can add and subtract on their own, so it’s the docs’ hello world and not a reason to pick a reducer. For that, start with a projection.
Where the attributes run out
Section titled “Where the attributes run out”The reducer earns its place when the next state depends on the previous one in a way you’d rather write as an if. Say customers earn a loyalty tier based on what they’ve spent, and a refund lowers the total but never takes a tier away:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
[EventType]public record OrderPlaced(decimal Amount);
[EventType]public record OrderRefunded(decimal Amount);
public enum Tier { Standard, Silver, Gold }
public record CustomerSpend(decimal Total, Tier Tier);
public interface ILoyaltyPolicy{ Task<Tier> TierFor(decimal totalSpent);}
public class CustomerSpendReducer(ILoyaltyPolicy policy) : IReducerFor<CustomerSpend>{ public Task<CustomerSpend> Placed(OrderPlaced @event, CustomerSpend? current) => Next(current, @event.Amount);
public Task<CustomerSpend> Refunded(OrderRefunded @event, CustomerSpend? current) => Next(current, -@event.Amount);
async Task<CustomerSpend> Next(CustomerSpend? current, decimal change) { var total = (current?.Total ?? 0m) + change; var earned = await policy.TierFor(total);
// A refund lowers the total, but never takes a tier away. var tier = current is null || earned > current.Tier ? earned : current.Tier;
return new CustomerSpend(total, tier); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModel
@EventTypedata class OrderPlaced(val amount: Double)
@EventTypedata class OrderRefunded(val amount: Double)
enum class Tier { STANDARD, SILVER, GOLD }
@ReadModeldata class CustomerSpend(val total: Double = 0.0, val tier: Tier = Tier.STANDARD)
interface LoyaltyPolicy { suspend fun tierFor(totalSpent: Double): Tier}
@Reducerclass CustomerSpendReducer(private val policy: LoyaltyPolicy) { suspend fun placed(event: OrderPlaced, current: CustomerSpend?): CustomerSpend = next(current, event.amount)
suspend fun refunded(event: OrderRefunded, current: CustomerSpend?): CustomerSpend = next(current, -event.amount)
private suspend fun next(current: CustomerSpend?, change: Double): CustomerSpend { val total = (current?.total ?: 0.0) + change val earned = policy.tierFor(total)
// A refund lowers the total, but never takes a tier away. val tier = if (current == null || earned > current.tier) earned else current.tier
return CustomerSpend(total, tier) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord OrderPlaced(double amount) {}
@EventTyperecord OrderRefunded(double amount) {}
enum Tier { STANDARD, SILVER, GOLD }
@ReadModelrecord CustomerSpend(double total, Tier tier) { CustomerSpend() { this(0.0, Tier.STANDARD); }}
interface LoyaltyPolicy { Tier tierFor(double totalSpent);}
@Reducerclass CustomerSpendReducer { private final LoyaltyPolicy policy;
CustomerSpendReducer(LoyaltyPolicy policy) { this.policy = policy; }
CustomerSpend placed(OrderPlaced event, CustomerSpend current) { return next(current, event.amount()); }
CustomerSpend refunded(OrderRefunded event, CustomerSpend current) { return next(current, -event.amount()); }
private CustomerSpend next(CustomerSpend current, double change) { var total = (current != null ? current.total() : 0.0) + change; var earned = policy.tierFor(total);
// A refund lowers the total, but never takes a tier away. var tier = current == null || earned.compareTo(current.tier()) > 0 ? earned : current.tier();
return new CustomerSpend(total, tier); }}defmodule MyApp.Events.OrderPlaced do use Chronicle.Events.EventType, id: "order-placed"
defstruct [:amount]end
defmodule MyApp.Events.OrderRefunded do use Chronicle.Events.EventType, id: "order-refunded"
defstruct [:amount]end
defmodule MyApp.ReadModels.CustomerSpend do defstruct total: 0, tier: :standardend
defmodule MyApp.Reducers.CustomerSpendReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.CustomerSpend
alias MyApp.Events.{OrderPlaced, OrderRefunded} alias MyApp.ReadModels.CustomerSpend
@handles OrderPlaced @handles OrderRefunded
@tiers [:standard, :silver, :gold]
@impl true def reduce(%OrderPlaced{} = event, current, _context), do: next(current, event.amount) def reduce(%OrderRefunded{} = event, current, _context), do: next(current, -event.amount)
defp next(current, change) do total = (if current, do: current.total, else: 0) + change earned = tier_for(total)
# A refund lowers the total, but never takes a tier away. tier = if current == nil or rank(earned) > rank(current.tier), do: earned, else: current.tier
%CustomerSpend{total: total, tier: tier} end
defp tier_for(total) when total >= 500, do: :gold defp tier_for(total) when total >= 100, do: :silver defp tier_for(_total), do: :standard
defp rank(tier), do: Enum.find_index(@tiers, &(&1 == tier))endimport { eventType, reducer } from '@cratis/chronicle';
@eventType()export class OrderPlaced { amount = 0;}
@eventType()export class OrderRefunded { amount = 0;}
export type Tier = 'standard' | 'silver' | 'gold';const tiers: Tier[] = ['standard', 'silver', 'gold'];
export class CustomerSpend { total = 0; tier: Tier = 'standard';}
export const policy = { silverAt: 100, goldAt: 500 };
const tierFor = (totalSpent: number): Tier => totalSpent >= policy.goldAt ? 'gold' : totalSpent >= policy.silverAt ? 'silver' : 'standard';
@reducer('', undefined, CustomerSpend)export class CustomerSpendReducer { orderPlaced(event: OrderPlaced, current: CustomerSpend | undefined): CustomerSpend { return this.next(current, event.amount); }
orderRefunded(event: OrderRefunded, current: CustomerSpend | undefined): CustomerSpend { return this.next(current, -event.amount); }
private next(current: CustomerSpend | undefined, change: number): CustomerSpend { const total = (current?.total ?? 0) + change; const earned = tierFor(total);
// A refund lowers the total, but never takes a tier away. const tier = !current || tiers.indexOf(earned) > tiers.indexOf(current.tier) ? earned : current.tier;
return { total, tier }; }}Nothing here is exotic. The decision about the tier is ordinary C#, it looks at the state we had before, and the shape of CustomerSpend is whatever we say it is. There’s no attribute for “keep the highest tier so far”, and I wouldn’t want one. In C#, Kotlin and Java the reducer is an ordinary class, so the policy arrives through the constructor like any other dependency, and the testing docs show how to supply one when you test it. The docs don’t show that for TypeScript and Elixir yet, so in those two the policy is a plain function. The clients also differ in how they do asynchronous work: C# returns a Task, Kotlin uses suspend, and Java blocks. The TypeScript handlers are named after the event class, and don’t receive the event context, which is why that tab has no timestamp. The event processing docs cover each client’s shape.
What Chronicle does for you here is everything around it. It calls the right method for each event, in order, per event source. It stores the state you return. And for a read model keyed by its event source, it handles redelivery, so an event delivered a second time after a crash doesn’t change the stored state. The docs spell out what “handled” means. You focus on what the read model should look like.
Reading is fine, doing is not
Section titled “Reading is fine, doing is not”Here’s the part where I have to temper my own enthusiasm. A reducer can ask a collaborator a question, like the policy above. What it must not do is something to the outside world. The docs are plain about it: projections and reducers build state, with no emails and no API calls. That’s the reactor’s job, and a reactor has to be written to be safe to run more than once.
My rule of thumb is to keep what a reducer depends on in-process and boring. A policy, a lookup, a calculator. And there’s a reason beyond tidiness, which is replay. A read model can always be rebuilt from the events, and a rebuild starts from a clean read model and runs your reducer again for every event. So whatever you ask a dependency gets asked again.

Same events, a different policy, a different answer.
Sometimes that is exactly what you want. Change the tier policy, replay, and every customer is recomputed under the new rules from their first order, for free. Notice that the “never takes a tier away” rule only holds within one run of the fold, so a replay under a stricter policy can demote someone. If it isn’t what you want, if an answer has to be the one that was true back then, such as an exchange rate on the day of the purchase, then it belongs in the event and not in something you look up later. The event is the fact. The reducer shouldn’t have to go and find it again.
Try it without a server
Section titled “Try it without a server”You don’t need Chronicle running to see this behave. In C# and Kotlin, ReadModelScenario from the testing package runs a reducer in-process: you give it events, you read the instance. In the other clients you call the reducer yourself. Set up a test project:
dotnet new xunit -n Reducers -f net10.0cd Reducersdotnet add package Cratis.Chronicledotnet add package Cratis.Chronicle.TestingAdd io.cratis:chronicle from Maven Central, and io.cratis:chronicle-testing and JUnit 5 for the tests. Kotlin specs that call suspend code also use kotlinx-coroutines-core. The Kotlin client docs show the Gradle and Maven setup.
Add io.cratis:chronicle from Maven Central, and JUnit 5 for the tests. The Kotlin client docs cover the setup for both, because one artifact serves both.
ExUnit comes with Elixir, and the reducer is an ordinary module, so a spec can call reduce/3 directly.
npm install @cratis/chroniclenpm install --save-dev vitest typescriptThe decorators need experimentalDecorators switched on in tsconfig.json.
Put the reducer above in the project, and add a spec that supplies the policy and folds some events:
using Cratis.Chronicle.Testing.ReadModels;using Microsoft.Extensions.DependencyInjection;
public class FixedPolicy : ILoyaltyPolicy{ public decimal SilverAt = 100m, GoldAt = 500m;
public Task<Tier> TierFor(decimal totalSpent) => Task.FromResult(totalSpent >= GoldAt ? Tier.Gold : totalSpent >= SilverAt ? Tier.Silver : Tier.Standard);}
public class Specs{ static (ReadModelScenario<CustomerSpend> Scenario, FixedPolicy Policy) Create() { var policy = new FixedPolicy(); var services = new ServiceCollection().AddSingleton<ILoyaltyPolicy>(policy).BuildServiceProvider(); return (new ReadModelScenario<CustomerSpend>(initialState: null, serviceProvider: services), policy); }
[Fact] public async Task a_refund_lowers_the_total_but_keeps_the_tier() { var (scenario, _) = Create();
await scenario.Given.ForEventSource("customer-1").Events(new OrderPlaced(520m), new OrderRefunded(450m));
Assert.Equal(70m, scenario.Instance!.Total); Assert.Equal(Tier.Gold, scenario.Instance!.Tier); }
[Fact] public async Task a_changed_policy_changes_the_tier_when_the_events_are_folded_again() { var (scenario, policy) = Create(); await scenario.Given.ForEventSource("customer-1").Events(new OrderPlaced(120m), new OrderPlaced(400m)); Assert.Equal(Tier.Gold, scenario.Instance!.Tier);
policy.GoldAt = 5000m; policy.SilverAt = 5000m; await scenario.Given.ForEventSource("customer-1").Events(new OrderPlaced(1m));
Assert.Equal(Tier.Standard, scenario.Instance!.Tier); }}import io.cratis.chronicle.testing.ReadModelScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
class FixedPolicy : LoyaltyPolicy { var silverAt = 100.0 var goldAt = 500.0
override suspend fun tierFor(totalSpent: Double) = when { totalSpent >= goldAt -> Tier.GOLD totalSpent >= silverAt -> Tier.SILVER else -> Tier.STANDARD }}
class CustomerSpendReducerTests { @Test fun `a refund lowers the total but keeps the tier`() = runBlocking { val scenario = ReadModelScenario<CustomerSpend>(CustomerSpendReducer(FixedPolicy()))
val spend = scenario.fold("customer-1", OrderPlaced(520.0), OrderRefunded(450.0))
assertEquals(70.0, spend!!.total) assertEquals(Tier.GOLD, spend.tier) }
@Test fun `a changed policy changes the tier when the events are folded again`() = runBlocking { val policy = FixedPolicy() val first = ReadModelScenario<CustomerSpend>(CustomerSpendReducer(policy)) assertEquals(Tier.GOLD, first.fold("customer-1", OrderPlaced(520.0))!!.tier)
policy.goldAt = 5000.0 policy.silverAt = 5000.0
// A replay starts from a clean read model, so fold the same events in a fresh scenario. val replay = ReadModelScenario<CustomerSpend>(CustomerSpendReducer(policy)) assertEquals(Tier.STANDARD, replay.fold("customer-1", OrderPlaced(520.0))!!.tier) }}import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.assertEquals;
class FixedPolicy implements LoyaltyPolicy { double silverAt = 100.0; double goldAt = 500.0;
public Tier tierFor(double totalSpent) { return totalSpent >= goldAt ? Tier.GOLD : totalSpent >= silverAt ? Tier.SILVER : Tier.STANDARD; }}
class CustomerSpendReducerTests { @Test void aRefundLowersTheTotalButKeepsTheTier() { var reducer = new CustomerSpendReducer(new FixedPolicy());
var spend = reducer.placed(new OrderPlaced(520.0), null); spend = reducer.refunded(new OrderRefunded(450.0), spend);
assertEquals(70.0, spend.total()); assertEquals(Tier.GOLD, spend.tier()); }
@Test void aChangedPolicyChangesTheTierWhenTheEventsAreFoldedAgain() { var policy = new FixedPolicy(); var reducer = new CustomerSpendReducer(policy); assertEquals(Tier.GOLD, reducer.placed(new OrderPlaced(520.0), null).tier());
policy.goldAt = 5000.0; policy.silverAt = 5000.0; assertEquals(Tier.STANDARD, reducer.placed(new OrderPlaced(520.0), null).tier()); }}defmodule MyApp.Reducers.CustomerSpendReducerTest do use ExUnit.Case, async: true
alias MyApp.Events.{OrderPlaced, OrderRefunded} alias MyApp.Reducers.CustomerSpendReducer
test "a refund lowers the total but keeps the tier" do spend = CustomerSpendReducer.reduce(%OrderPlaced{amount: 520}, nil, %{}) spend = CustomerSpendReducer.reduce(%OrderRefunded{amount: 450}, spend, %{})
assert spend.total == 70 assert spend.tier == :gold endendimport { describe, expect, it } from 'vitest';import { CustomerSpendReducer, OrderPlaced, OrderRefunded, policy } from './customer';
const placed = (amount: number) => Object.assign(new OrderPlaced(), { amount });const refunded = (amount: number) => Object.assign(new OrderRefunded(), { amount });
describe('CustomerSpendReducer', () => { it('lowers the total on a refund but keeps the tier', () => { const reducer = new CustomerSpendReducer();
const spend = reducer.orderRefunded(refunded(450), reducer.orderPlaced(placed(520), undefined));
expect(spend.total).toBe(70); expect(spend.tier).toBe('gold'); });
it('changes the tier when the events are folded again under a changed policy', () => { const reducer = new CustomerSpendReducer(); expect(reducer.orderPlaced(placed(520), undefined).tier).toBe('gold');
policy.goldAt = 5000; policy.silverAt = 5000;
expect(reducer.orderPlaced(placed(520), undefined).tier).toBe('standard'); });});Run the specs the way your language does. The first spec is the reducer doing its job. The second is the replay point from above, shown as a test: nothing about the events changed, only the answer from the dependency. The Kotlin client’s scenario keeps state between folds, so there a replay is a fresh scenario, which is also how Chronicle starts a rebuild. Java and TypeScript call the reducer method directly. The Elixir spec shows the first case only, because the policy there is a function with fixed thresholds.
Which one, then
Section titled “Which one, then”If I had to put it in one line, I’d say: use the simplest style that keeps the intent obvious. Start with a projection when the read model is shaped like the data in the events, because it’s the least code and the mapping sits right next to the model. Reach for a reducer when the logic is easier to read as a state transition, with branching, derived values or a rule that depends on what came before. And when you need to do something about the world rather than describe it, that’s a reactor.
The thing I like about the reducer is how little it asks of you, and how much it leaves to you. Chronicle keeps the persistence consistent, and you get to spend your time on the shape and the content of the read model, which is the part that’s actually yours.
If you have a read model today that’s a pile of awkward attributes, try rewriting it as a reducer and see how it reads. The getting started guide is the place to begin, and choosing a read model style compares the options with the same example in each.