ReadModelScenario
A read model scenario lets you check projection or reducer output without a running Chronicle server or database. Use the client tabs for each workflow’s API shape.
For application code, start with model-bound attributes, then fluent mappings when the definition needs structure, then reducer code when transitions read better; a self-reducing model is the default for new reducers. Choose how to build a read model compares the same event history across those forms.
In .NET, ReadModelScenario<TReadModel> detects model-bound and fluent projections, self-reducing
models, classic IReducerFor<T> classes, and nominated plain instance reducers. It routes events
through the selected engine; there is no direct PDL-authoring overload.
In a .NET Cratis Specification, the event history is the given, Because() feeds that history through the scenario, and the read model properties are the then.
Why use ReadModelScenario
Section titled “Why use ReadModelScenario”Integration tests against a live database are useful for proving storage and network behavior, but cost more to run. An in-process scenario tests the supported projection or reducer logic without that infrastructure; it does not replace a kernel-backed integration test.
.NET installation
Section titled “.NET installation”ReadModelScenario<TReadModel> is in the Cratis.Chronicle.Testing NuGet package:
dotnet add package Cratis.Specifications.XUnitdotnet add package Cratis.Chronicle.TestingBasic usage
Section titled “Basic usage”using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;using Xunit;
[EventType]public record TestingReadModelScenarioSomeEvent(string Value);
[EventType]public record TestingReadModelScenarioSomeOtherEvent(int Value);
[FromEvent<TestingReadModelScenarioSomeEvent>]public record TestingReadModelScenarioMyReadModel([Key] Guid Id, string Value);
public class when_projecting_events : Specification{ readonly EventSourceId _eventSourceId = EventSourceId.New(); readonly ReadModelScenario<TestingReadModelScenarioMyReadModel> _scenario = new();
Task Because() => _scenario.Given .ForEventSource(_eventSourceId) .Events(new TestingReadModelScenarioSomeEvent("expected value"), new TestingReadModelScenarioSomeOtherEvent(42));
[Fact] void should_project_the_value() => _scenario.Instance!.Value.ShouldEqual("expected value");}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport io.cratis.chronicle.testing.ReadModelScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
@EventTypedata class SomeEvent(val value: String)
@ReadModeldata class MyReadModel(val value: String = "")
@Reducerclass MyReducer { fun someEvent(event: SomeEvent) = MyReadModel(event.value)}
class WhenProjectingEventsTests {
@Test fun `folding an event produces the read model`() = runBlocking { val scenario = ReadModelScenario<MyReadModel>(MyReducer())
val instance = scenario.fold("some-id", SomeEvent("expected value"))
assertEquals("expected value", instance!!.value) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TestingReadModelsScenarioBasic {
@EventType record SomeEvent(String value) { }
@ReadModel record MyReadModel(String value) { }
@Reducer static class MyReducer { MyReadModel someEvent(SomeEvent event) { return new MyReadModel(event.value()); } }
@Test void foldingAnEventProducesTheReadModel() { var reducer = new MyReducer();
var instance = reducer.someEvent(new SomeEvent("expected value"));
assertEquals("expected value", instance.value()); }}defmodule MyApp.Events.TestingReadModelScenarioSomeEvent do use Chronicle.Events.EventType, id: "testing-read-model-scenario-some-event"
defstruct [:value]end
defmodule MyApp.Events.TestingReadModelScenarioSomeOtherEvent do use Chronicle.Events.EventType, id: "testing-read-model-scenario-some-other-event"
defstruct [:value]end
defmodule MyApp.ReadModels.TestingReadModelScenarioMyReadModel do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.TestingReadModelScenarioSomeEvent
defstruct id: nil, value: nil
from TestingReadModelScenarioSomeEvent, set: [id: :event_source_id, value: :value]end
defmodule MyApp.TestingReadModelScenarioBasicTest do use ExUnit.Case, async: true
alias MyApp.Events.{ TestingReadModelScenarioSomeEvent, TestingReadModelScenarioSomeOtherEvent }
alias MyApp.ReadModels.TestingReadModelScenarioMyReadModel
test "only the matching event projects a value" do event_source_id = "item-#{System.unique_integer([:positive])}"
{:ok, instance} = Chronicle.Testing.Projections.apply_event( TestingReadModelScenarioMyReadModel, nil, %TestingReadModelScenarioSomeEvent{value: "expected value"}, event_source_id )
# An event with no matching `from/2` declaration leaves the instance unchanged. {:ok, ^instance} = Chronicle.Testing.Projections.apply_event( TestingReadModelScenarioMyReadModel, instance, %TestingReadModelScenarioSomeOtherEvent{value: 42}, event_source_id )
assert instance.value == "expected value" endendimport { field } from '@cratis/fundamentals';import { eventType, fromEvent } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class TestingScenarioValueRecorded { @field(String) value: string; constructor(value: string) { this.value = value; }}
@eventType()class TestingScenarioUnrelatedEvent { @field(Number) value: number; constructor(value: number) { this.value = value; }}
@fromEvent(TestingScenarioValueRecorded)class TestingScenarioValueModel { @field(String) id = ''; @field(String) value = '';}
const basicReadModelScenario = new ReadModelScenario(TestingScenarioValueModel);basicReadModelScenario.given.forEventSource('item-1').events( new TestingScenarioValueRecorded('expected value'), new TestingScenarioUnrelatedEvent(42));const basicReadModel = await basicReadModelScenario.instance;if (basicReadModel?.value !== 'expected value') throw new Error('Expected the recorded value');Pass events to the read-model logic, then assert on the result. In .NET, the scenario takes Given.ForEventSource(id).Events(...) and exposes Instance; in TypeScript, given.forEventSource(id).events(...) and an awaited instance. Kotlin folds events through scenario.fold(...). The Java example calls the reducer directly and asserts on what it returns.
Fold appended events with their original context (.NET)
Section titled “Fold appended events with their original context (.NET)”When rebuilding a read model from events that have already been appended, pass the client’s
IEnumerable<AppendedEvent> to await scenario.ProcessAppendedEvents(events). Unlike bare events passed
through ProcessEventsFor or Given, these events retain their original sequence numbers, occurrence
times (including offsets), source and stream metadata, correlation, and causation.
Input accumulates until you access Instance, Instances, or InstanceForEventSourceId. Every rebuild
sorts the collected events by sequence number. Use distinct sequence numbers; duplicate slots fail
when preparing the input. Bare events mixed into the same scenario receive synthetic contexts with
collection-index sequence numbers.
Projections retain their normal key resolution, including event-property keys, joins, and children.
Reducers fold each event source independently; use Instances or InstanceForEventSourceId if more
than one source materializes. The input is not appended to the scenario’s application event log.
Seed routed events
Section titled “Seed routed events”In .NET, use Given.ForEventSource(id).OnStream(eventSourceType, eventStreamType, eventStreamId).Events(...)
when your read model needs the event’s source or stream route. The route reaches the projection or reducer
through EventContext.EventSourceType, EventContext.EventStreamType, and EventContext.EventStreamId.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;using Xunit;
[EventType]public record TestingReadModelScenarioPaymentReceived(decimal Amount);
[FromEvent<TestingReadModelScenarioPaymentReceived>]public record TestingReadModelScenarioPaymentRoute( [Key] Guid Id, decimal Amount, [SetFromContext<TestingReadModelScenarioPaymentReceived>(nameof(EventContext.EventSourceType))] EventSourceType SourceType, [SetFromContext<TestingReadModelScenarioPaymentReceived>(nameof(EventContext.EventStreamType))] EventStreamType StreamType, [SetFromContext<TestingReadModelScenarioPaymentReceived>(nameof(EventContext.EventStreamId))] EventStreamId StreamId);
public class when_seeding_a_routed_payment : Specification{ readonly EventSourceId _orderId = EventSourceId.New(); readonly ReadModelScenario<TestingReadModelScenarioPaymentRoute> _scenario = new();
Task Because() => _scenario.Given .ForEventSource(_orderId) .OnStream(eventSourceType: "Order", eventStreamType: "Payments", eventStreamId: "2026-10") .Events(new TestingReadModelScenarioPaymentReceived(42m));
[Fact] void should_see_the_source_type() => _scenario.Instance!.SourceType.ShouldEqual(new EventSourceType("Order")); [Fact] void should_see_the_stream_type() => _scenario.Instance!.StreamType.ShouldEqual(new EventStreamType("Payments")); [Fact] void should_see_the_stream_id() => _scenario.Instance!.StreamId.Value.ShouldEqual("2026-10");}defmodule MyApp.Events.TestingReadModelScenarioPaymentReceived do use Chronicle.Events.EventType, id: "testing-read-model-scenario-payment-received"
defstruct [:amount]end
defmodule MyApp.ReadModels.TestingReadModelScenarioPaymentRoute do defstruct amount: 0, source_type: nil, stream_type: nil, stream_id: nilend
defmodule MyApp.Reducers.TestingReadModelScenarioPaymentRouteReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.TestingReadModelScenarioPaymentRoute
alias MyApp.Events.TestingReadModelScenarioPaymentReceived alias MyApp.ReadModels.TestingReadModelScenarioPaymentRoute
@handles TestingReadModelScenarioPaymentReceived
@impl true def reduce(%TestingReadModelScenarioPaymentReceived{amount: amount}, _model, context) do %TestingReadModelScenarioPaymentRoute{ amount: amount, source_type: context.event_source_type, stream_type: context.event_stream_type, stream_id: context.event_stream_id } endend
defmodule MyApp.TestingReadModelScenarioRoutedEventsTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingReadModelScenarioPaymentReceived], reducers: [MyApp.Reducers.TestingReadModelScenarioPaymentRouteReducer]
alias MyApp.Events.TestingReadModelScenarioPaymentReceived alias MyApp.Reducers.TestingReadModelScenarioPaymentRouteReducer
setup %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "order-1", %TestingReadModelScenarioPaymentReceived{amount: 42}, client: client, event_source_type: "Order", event_stream_type: "Payments", event_stream_id: "2026-10" )
:ok = Chronicle.Testing.wait_for_observers(client)
{:ok, route} = Chronicle.Testing.read_model(client, TestingReadModelScenarioPaymentRouteReducer, "order-1")
%{route: route} end
test "sees the source type", %{route: route}, do: assert(route.source_type == "Order") test "sees the stream type", %{route: route}, do: assert(route.stream_type == "Payments") test "sees the stream id", %{route: route}, do: assert(route.stream_id == "2026-10")endAll three OnStream arguments are optional. Omitting an argument, or passing null, uses the same default
as appending to the event log:
| Argument | Default |
|---|---|
eventSourceType | EventSourceType.Default |
eventStreamType | EventStreamType.All |
eventStreamId | EventStreamId.Default |
For example, OnStream(eventStreamType: "Payments") seeds a payment stream with the default source type and
stream id. Leaving out OnStream keeps these defaults too. The method returns a new builder, so reusing the
original builder does not inherit the route. Calling ReadModel(instance) still registers the instance by
its event source id, regardless of the builder’s route.
Reducer source and stream filters apply to seeded events just as they do under the kernel. A reducer carrying
[EventSourceType("Order")] ignores events from other source types; [EventStreamType("Payments")] narrows
by stream type. [FromEventSource<TSource>("Payments")] resolves both filters from the registered event
source definition. Both filters must match, and neither filters by stream id. An unspecified source filter
(EventSourceType.Unspecified) and EventStreamType.All do not narrow delivery. An explicit source filter
of EventSourceType.Default matches only that source type.
Projections have no source-type or stream-type filters: they consume the event types their definitions
subscribe to, across every route. Use context mappings such as [SetFromContext<TEvent>] to inspect the
route in a projected property.
Seed tagged self-reducer events
Section titled “Seed tagged self-reducer events”WithTags(params string[] tags) adds append tags to seeded events. It returns a new builder, and preserves OnStream metadata in either call order. The effective tags combine the event’s static/inherited Tag/Tags with the builder’s append tags; named tags remain separate and do not satisfy FilterEventsByTag.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Testing;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType][Tag("origin")]public record ScenarioBalanceAdded(decimal Amount);
[FilterEventsByTag("accepted")][EventSourceType("Account")][EventStreamType("Payments")]public record ScenarioSelfBalance(decimal Balance){ public static ScenarioSelfBalance Reduce(ScenarioBalanceAdded @event, ScenarioSelfBalance? current) => new((current?.Balance ?? 0m) + @event.Amount);}
public static class SelfReducerTagScenario{ public static async Task Run() { var artifacts = new InMemoryClientArtifactsProvider { EventTypes = [typeof(ScenarioBalanceAdded)], Reducers = [typeof(ScenarioSelfBalance)] }; var scenario = new ReadModelScenario<ScenarioSelfBalance>(null, new Defaults(artifacts)); var source = scenario.Given.ForEventSource("account-1"); await source.WithTags("accepted").OnStream("Account", "Payments") .Events(new ScenarioBalanceAdded(3m), new ScenarioBalanceAdded(5m)); await source.OnStream("Account", "Payments").WithTags("rejected") .Events(new ScenarioBalanceAdded(100m)); scenario.Instance!.Balance.ShouldEqual(8m); }}Elixir does not support this workflow yet.Classic and self reducers use the same registry-selected invoker and metadata matcher. Filter tags are OR alternatives, while tag/source/stream categories are AND requirements. Descriptive tags on the reducer do not become filters. This adds reducer tag fidelity without changing projection or reactor scenario selection; compliance, replay and real-store behavior still need kernel-backed specifications.
Event input serialization
Section titled “Event input serialization”In the .NET scenario, seeded events for projection-backed read models pass through the IEventSerializer supplied by
Defaults, then through Chronicle’s schema-aware JSON conversion. Projections therefore consume
the event’s serialized representation, including serializer-owned property names, rather than a reflection
walk of the CLR object. This also avoids reflecting over the parent links inside a nonempty JsonObject
event property. Serialized content is re-read as case-sensitive JSON data before conversion, so a serializer’s
case-insensitive CLR member matching cannot collapse distinct dictionary keys. This preserves JSON data
spelling; it is not a verification of the network marshaller or wire compatibility.
This fidelity applies to event input for fluent and model-bound projections. Reducers still receive typed
events directly. The optional initial read model state goes through the same schema-aware conversion: it is
serialized, re-read as case-sensitive JSON data, and converted against the read model’s schema, so an initial
state may contain nonempty JsonObject values. Reducers receive the initial state as the typed instance.
Optional initial state
Section titled “Optional initial state”In .NET, pass an initial state to the constructor when the read model starts from a non-default baseline:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioItemAdded;
public record TestingReadModelScenarioCountingReadModel(int Count);
public class TestingReadModelScenarioCountingReducer : IReducerFor<TestingReadModelScenarioCountingReadModel>{ public TestingReadModelScenarioCountingReadModel ItemAdded(TestingReadModelScenarioItemAdded @event, TestingReadModelScenarioCountingReadModel? current, EventContext context) => current is null ? new(1) : current with { Count = current.Count + 1 };}
public static class TestingReadModelScenarioInitialState{ public static async Task Run() { var myId = EventSourceId.New(); var initial = new TestingReadModelScenarioCountingReadModel(10); var scenario = new ReadModelScenario<TestingReadModelScenarioCountingReadModel>(initial); await scenario.Given .ForEventSource(myId) .Events(new TestingReadModelScenarioItemAdded());
scenario.Instance!.Count.ShouldEqual(11); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
@EventTypeclass InitialStateItemAdded
@ReadModeldata class CountingReadModel(val count: Int)
@Reducerclass CountingReducer { fun itemAdded(event: InitialStateItemAdded, current: CountingReadModel) = current.copy(count = current.count + 1)}
/** * [io.cratis.chronicle.testing.ReadModelScenario] has no constructor parameter for a starting * baseline, so a non-default initial state is passed directly to the reducer's current-state * parameter. */class InitialStateTests {
@Test fun `folding from a non-default baseline continues from it`() { val reducer = CountingReducer() val initial = CountingReadModel(10)
val result = reducer.itemAdded(InitialStateItemAdded(), initial)
assertEquals(11, result.count) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** * ReadModelScenario has no constructor parameter for a starting baseline, so a non-default initial * state is passed directly to the reducer's current-state parameter. */class TestingReadModelsScenarioInitialState {
@EventType record ItemAdded() { }
@ReadModel record CountingReadModel(int count) { }
@Reducer static class CountingReducer { CountingReadModel itemAdded(ItemAdded event, CountingReadModel current) { return new CountingReadModel(current.count() + 1); } }
@Test void foldingFromANonDefaultBaselineContinuesFromIt() { var reducer = new CountingReducer(); var initial = new CountingReadModel(10);
var result = reducer.itemAdded(new ItemAdded(), initial);
assertEquals(11, result.count()); }}defmodule MyApp.Events.TestingReadModelScenarioItemAdded do use Chronicle.Events.EventType, id: "testing-read-model-scenario-item-added"
defstruct []end
defmodule MyApp.ReadModels.TestingReadModelScenarioCountingReadModel do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.TestingReadModelScenarioItemAdded
defstruct count: 0
from TestingReadModelScenarioItemAdded, count: :countend
defmodule MyApp.TestingReadModelScenarioInitialStateTest do use ExUnit.Case, async: true
alias MyApp.Events.TestingReadModelScenarioItemAdded alias MyApp.ReadModels.TestingReadModelScenarioCountingReadModel
test "events are applied on top of an initial state" do initial = %TestingReadModelScenarioCountingReadModel{count: 10}
{:ok, counting} = Chronicle.Testing.Projections.apply_event( TestingReadModelScenarioCountingReadModel, initial, %TestingReadModelScenarioItemAdded{}, "counter-#{System.unique_integer([:positive])}" )
assert counting.count == 11 endendTypeScript does not support this workflow yet.Injecting dependencies
Section titled “Injecting dependencies”In .NET, pass an IServiceProvider to the constructor to supply mocks and stubs for an instance
reducer’s fold or a fluent projection’s definition construction. Static self-reduction methods do not
accept service arguments:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;using Microsoft.Extensions.DependencyInjection;using NSubstitute;
[EventType]public record TestingReadModelScenarioOrderCreated(string OrderId);
public record TestingReadModelScenarioOrderSummary(string OrderId, decimal Total);
public interface ITestingReadModelScenarioPricingService{ decimal GetBasePrice();}
public class TestingReadModelScenarioOrderSummaryReducer(ITestingReadModelScenarioPricingService pricingService) : IReducerFor<TestingReadModelScenarioOrderSummary>{ public TestingReadModelScenarioOrderSummary OrderCreated(TestingReadModelScenarioOrderCreated @event, TestingReadModelScenarioOrderSummary? current, EventContext context) => new(@event.OrderId, pricingService.GetBasePrice());}
public static class TestingReadModelScenarioInjectingDependencies{ public static async Task Run() { var pricingService = Substitute.For<ITestingReadModelScenarioPricingService>(); var services = new ServiceCollection() .AddSingleton(pricingService) .BuildServiceProvider();
var scenario = new ReadModelScenario<TestingReadModelScenarioOrderSummary>(initialState: null, serviceProvider: services); await scenario.Given .ForEventSource("order-1") .Events(new TestingReadModelScenarioOrderCreated("order-1"));
scenario.Instance!.Total.ShouldEqual(0m); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport io.cratis.chronicle.testing.ReadModelScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
@EventTypedata class OrderCreated(val orderId: String)
@ReadModeldata class OrderSummary(val orderId: String = "", val total: Double = 0.0)
interface PricingService { fun basePrice(): Double}
@Reducerclass OrderSummaryReducer(private val pricingService: PricingService) { fun orderCreated(event: OrderCreated) = OrderSummary(event.orderId, pricingService.basePrice())}
class InjectingDependenciesTests {
@Test fun `a fake passed into the reducer's constructor is used while folding`() = runBlocking { val pricingService = object : PricingService { override fun basePrice() = 42.0 }
val scenario = ReadModelScenario<OrderSummary>(OrderSummaryReducer(pricingService)) val instance = scenario.fold("order-1", OrderCreated("order-1"))
assertEquals(42.0, instance!!.total) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TestingReadModelsScenarioInjectingDependencies {
@EventType record OrderCreated(String orderId) { }
@ReadModel record OrderSummary(String orderId, double total) { }
interface PricingService { double basePrice(); }
@Reducer static class OrderSummaryReducer { private final PricingService pricingService;
OrderSummaryReducer(PricingService pricingService) { this.pricingService = pricingService; }
OrderSummary orderCreated(OrderCreated event) { return new OrderSummary(event.orderId(), pricingService.basePrice()); } }
@Test void aFakePassedIntoTheReducersConstructorIsUsedWhileFolding() { PricingService pricingService = () -> 42.0; var reducer = new OrderSummaryReducer(pricingService);
var instance = reducer.orderCreated(new OrderCreated("order-1"));
assertEquals(42.0, instance.total()); }}defmodule MyApp.Events.TestingReadModelScenarioOrderCreated do use Chronicle.Events.EventType, id: "testing-read-model-scenario-order-created"
defstruct [:order_id]end
defmodule MyApp.ReadModels.TestingReadModelScenarioOrderSummary do use Chronicle.ReadModels.ReadModel
defstruct order_id: nil, total_in_cents: 0end
defmodule MyApp.TestingReadModelScenarioPricingService do @callback get_base_price() :: non_neg_integer()end
defmodule MyApp.Reducers.TestingReadModelScenarioOrderSummaryReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.TestingReadModelScenarioOrderSummary
alias MyApp.Events.TestingReadModelScenarioOrderCreated alias MyApp.ReadModels.TestingReadModelScenarioOrderSummary
@handles TestingReadModelScenarioOrderCreated
@impl true def reduce(%TestingReadModelScenarioOrderCreated{order_id: order_id}, _model, _context) do pricing = Application.fetch_env!(:my_app, :testing_read_model_scenario_pricing)
%TestingReadModelScenarioOrderSummary{ order_id: order_id, total_in_cents: pricing.get_base_price() } endend
defmodule MyApp.TestingReadModelScenarioFixedPricing do @behaviour MyApp.TestingReadModelScenarioPricingService
@impl true def get_base_price, do: 2500end
defmodule MyApp.TestingReadModelScenarioInjectingDependenciesTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingReadModelScenarioOrderCreated], reducers: [MyApp.Reducers.TestingReadModelScenarioOrderSummaryReducer]
alias MyApp.Events.TestingReadModelScenarioOrderCreated alias MyApp.Reducers.TestingReadModelScenarioOrderSummaryReducer alias MyApp.TestingReadModelScenarioFixedPricing
setup do Application.put_env(:my_app, :testing_read_model_scenario_pricing, TestingReadModelScenarioFixedPricing) on_exit(fn -> Application.delete_env(:my_app, :testing_read_model_scenario_pricing) end) end
test "the reducer uses the injected pricing service", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "order-1", %TestingReadModelScenarioOrderCreated{order_id: "order-1"}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
{:ok, summary} = Chronicle.Testing.read_model(client, TestingReadModelScenarioOrderSummaryReducer, "order-1")
assert summary.total_in_cents == 2500 endendTypeScript does not support this workflow yet.Pre-seeding read model instances
Section titled “Pre-seeding read model instances”In .NET, use Given.ForEventSourceId(id).ReadModel(instance) when one key already has state before your
events. The projection starts from that instance exactly as supplied, including its identifier: setters,
arithmetic and children apply on top of the seeded values. A reducer receives the seed as its current state
for that key’s first event. Other keys start from the constructor’s optional initial state or the normal
empty baseline.
A seeded key appears in Instances, InstanceForEventSourceId, and (when it is the only instance) Instance,
even without events. The supplied id addresses the read-model key, which can differ from an event source
when you use UsingKey or joins. Seeding the same key twice throws ReadModelAlreadySeeded.
You can also pass scenario.ReadModels to production code that calls IReadModels.GetInstanceById:
using Cratis.Chronicle.ReadModels;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
public class TestingReadModelScenarioOrderService(IReadModels readModels){ public async Task<decimal> GetOrderTotal(string orderId) { var order = await readModels.GetInstanceById<TestingReadModelScenarioOrderSummary>(orderId); return order.Total; }}
public static class TestingReadModelScenarioPreseedingInstances{ public static async Task Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioOrderSummary>(); await scenario.Given .ForEventSourceId("order-1") .ReadModel(new TestingReadModelScenarioOrderSummary("order-1", 99.99m));
// This key exists even without events. Any later events fold on top of this seed. scenario.InstanceForEventSourceId("order-1")!.Total.ShouldEqual(99.99m);
// Pass scenario.ReadModels to production code under test var sut = new TestingReadModelScenarioOrderService(scenario.ReadModels); var result = await sut.GetOrderTotal("order-1");
result.ShouldEqual(99.99m); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.scenario.ReadModels returns an IReadModels instance that intercepts GetInstanceById for registered
instances, with collected events applied before each lookup. Models without a projection or reducer remain
lookup-only seeds and do not appear in Instances. The rest of the surface delegates to the production
implementation and needs a running Chronicle: enumerate scenario output through Instances, not GetInstances.
Limitation
Section titled “Limitation”A seeded parent without its originating events cannot resolve children whose parent key comes from those events. Include the originating events for those shapes. Children whose parent key resolves directly, without a parent-event lookup, can append onto the seeded parent.
Observing read models
Section titled “Observing read models”The testing event store supports collection observations through ReadModels.Observe<T>(). It rebuilds the complete collection from its scenario event log on subscription and after successful appends, including batch appends and unit-of-work commits. Projections process events in log order; reducers fold each event source separately. Explicitly seeded instances are included, and the optional filter is applied to the rebuilt set. Unchanged sets do not emit again.
Own and dispose the subscription just as you would in production. After the last subscriber leaves, or the subject completes or is disposed, further appends produce no emissions for that observation. The harness uses the projection and reducer processors described below; it does not verify kernel transport, sink notifications, or read-model compliance protection.
ReadModelsForTesting(inner, eventLog) also provides this behavior when you have an existing scenario log. When inner comes from the testing event store, its artifact catalog, explicit projections, and injected services are retained; otherwise default artifact discovery is used. The constructor without an event log refuses observation with ReadModelCannotBeObserved. Passive read models and event-target types are refused just as they are by the client.
ReadModelScenario<T>.ReadModels.Observe<T>() remains unsupported: that scenario collects its own input rather than appending it to the event log. Use Instance, Instances, or InstanceForEventSourceId to assert on that scenario’s output.
Per-run projections and artifacts
Section titled “Per-run projections and artifacts”When a test generates artifacts for one run, give that scenario its own artifact catalog. The next scenario can then select a different projection without inheriting the first run’s artifacts:
var scenario = new ReadModelScenario<MyReadModel>(null, new Defaults(artifacts)) .WithProjection(builder => builder.From<MyEvent>(from => from.Set(model => model.Name).To(e => e.Name)));await scenario.Given.ForEventSource(id).Events(new MyEvent("example"));var result = scenario.Instance;Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { field } from '@cratis/fundamentals';import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class PerRunScenarioEvent { @field(String) name: string; constructor(name: string) { this.name = name; }}
class PerRunScenarioModel { @field(String) id = ''; @field(String) name = '';}
@projection('per-run-scenario-projection', PerRunScenarioModel)class PerRunScenarioProjection implements IProjectionFor<PerRunScenarioModel> { define(builder: IProjectionBuilderFor<PerRunScenarioModel>): void { builder.from(PerRunScenarioEvent, from => from.set(model => model.name).to(event => event.name)); }}
// The catalog passed to this scenario selects its projection independently of other runs.const perRunScenario = new ReadModelScenario(PerRunScenarioModel, { eventTypes: [PerRunScenarioEvent], reducers: [], projections: [PerRunScenarioProjection]});perRunScenario.given.forEventSource('author-1').events(new PerRunScenarioEvent('Jane Austen'));const perRunModel = await perRunScenario.instance;if (perRunModel?.name !== 'Jane Austen') throw new Error('Expected the per-run projection to set the name');In .NET, supply Defaults built from an IClientArtifactsProvider to control event types,
serializers, and discovery during seeding. WithProjection can define a projection for one
scenario and takes precedence over discovered projections and reducers; it does not register
the projection process-wide. TypeScript selects the projection from the catalog passed to the
scenario instead.
Explicit registrations (.NET)
Section titled “Explicit registrations (.NET)”Use InMemoryClientArtifactsProvider when you want to supply artifact lists without assembly discovery.
Its settable collections default to empty; include every event type your scenario uses in EventTypes.
You can also supply projection, model-bound projection, reducer, reactor, middleware, constraint,
migration, seeding, event-source, compliance, and security provider types through their corresponding collections.
Pass ChronicleOptions as the third argument to new Defaults(artifacts, serviceProvider, options);
use null for the service provider when you do not need dependency injection. Configure
options.ExplicitArtifacts.RegisterProjection<TReadModel>(...), RegisterReadModel<TReadModel>(), or
RegisterReducer<TReadModel>() for a self model before constructing the defaults. Explicit projections use the same discovery recipes as the client
and do not need a projection class or an entry in the provider’s projection collections.
ReadModelScenario<TReadModel>(null, defaults) honors the explicitly registered builder. WithProjection still takes precedence over it. Pass the same defaults to
EventScenario or ReactorScenario<TReactor> to retain the run’s artifact catalog and settings.
The test event store applies registrations in-process; appending through an event scenario does not
start observer delivery or automatically run those projections and reactors.
Auto-detection
Section titled “Auto-detection”In .NET, ReadModelScenario<TReadModel> looks up a handler in the client artifacts registry. By default, Chronicle’s
reflection-based discovery resolves that registry once per process from project-referenced and package-referenced
assemblies. Supplying Defaults uses its IClientArtifactsProvider instead, so each run can discover a different
set of handlers. Without an inline projection, handlers are selected in this order:
- Reducer — a classic
IReducerFor<TReadModel>, explicitly nominated plain instance reducer, or structurally recognized self-reducing model. - Fluent projection — a class implementing
IProjectionFor<TReadModel>. - Model-bound projection —
TReadModelcarries genuine projection mappings such as[FromEvent<T>]; a key or shared observer configuration alone does not establish mapping ownership.
WithProjection takes precedence over all three, including a discovered reducer. If none are found and no
inline projection was supplied, NoReadModelHandlerFound is thrown.
Define a projection for one run (.NET)
Section titled “Define a projection for one run (.NET)”When a spec generates artifacts at runtime or needs a different mapping for the same read model, pass
new Defaults(artifactsProvider) to new ReadModelScenario<MyReadModel>(null, defaults) and call
WithProjection(builder => builder.From<MyEvent>(from => from.Set(model => model.Name).To(e => e.Name))).
The callback uses IProjectionBuilderFor<MyReadModel> and replaces discovered projection selection for that
scenario only. Each scenario can have its own Defaults, artifact registry, and fluent definition; no wire
contract or process-wide projection registration is needed. The scenario’s event store also uses that registry.
Asking what Chronicle registered
Section titled “Asking what Chronicle registered”In .NET, that registry is not private to the scenario. scenario.ClientArtifactsProvider hands out the very
IClientArtifactsProvider the scenario resolved its handler from, so a spec can ask what Chronicle registered
instead of re-deriving it by reflecting over its own assemblies:
using Cratis.Chronicle.Testing;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
public static class TestingReadModelScenarioRegisteredArtifacts{ public static void Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioModelBoundDeliveryStatus>();
// The registry Chronicle discovered its artifacts from - no reflection of your own needed var artifacts = scenario.ClientArtifactsProvider; artifacts.ModelBoundProjections.ShouldContain(typeof(TestingReadModelScenarioModelBoundDeliveryStatus)); artifacts.EventTypes.ShouldContain(typeof(TestingReadModelScenarioModelBoundShipmentDispatched));
// The same registry, reachable without a scenario Defaults.Instance.ClientArtifactsProvider.Reactors.ShouldNotBeNull(); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { field } from '@cratis/fundamentals';import { DefaultClientArtifactsProvider, eventType, fromEvent } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class TestingScenarioRegisteredShipment { @field(String) carrier: string; constructor(carrier: string) { this.carrier = carrier; }}
@fromEvent(TestingScenarioRegisteredShipment)class TestingScenarioRegisteredDelivery { @field(String) id = ''; @field(String) carrier = '';}
// The same discovered artifacts that the client uses for registration.const registeredArtifacts = DefaultClientArtifactsProvider.default;if (!registeredArtifacts.eventTypes.includes(TestingScenarioRegisteredShipment)) { throw new Error('Expected the registered event type');}const registeredScenario = new ReadModelScenario(TestingScenarioRegisteredDelivery, registeredArtifacts);registeredScenario.given.forEventSource('shipment-1').events(new TestingScenarioRegisteredShipment('FedEx'));const registeredDelivery = await registeredScenario.instance;if (registeredDelivery?.carrier !== 'FedEx') throw new Error('Expected the registered projection');It exposes every artifact kind Chronicle discovers — EventTypes, Projections, ModelBoundProjections,
Reactors, ReadModelReactors, Reducers, ReactorMiddlewares, ConstraintTypes, UniqueConstraints,
UniqueEventTypeConstraints, RemoveConstraintEventTypes, EventTypeMigrators, EventSeeders, and the
compliance and additional-event-information providers — including the classifications the registry draws
itself. An event type with a property-level [Unique] lands in UniqueConstraints, while one with a
class-level [Unique] lands in UniqueEventTypeConstraints; asking the registry gets that distinction right
without reimplementing it.
The property is read-only: reading it neither triggers nor alters registration, and every read hands out the same instance.
Strict event subscription
Section titled “Strict event subscription”In .NET, the scenario mirrors the production projection engine by default, which filters an event source’s stream down to the types the projection subscribes to — so seeding an unrelated audit or marker event is silently ignored. When you would rather be told, WithStrictEventSubscription() turns that silent skip into an UnsubscribedEventSeeded naming the offending event type:
var scenario = new ReadModelScenario<OrderSummary>().WithStrictEventSubscription();Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.This applies to projection-backed read models. A reducer only invokes the handlers it declares, so an unsubscribed seeded event is inherently a no-op there, exactly as at runtime.
Example: reducer
Section titled “Example: reducer”using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioReducerOrderCreated(string OrderId);
[EventType]public record TestingReadModelScenarioReducerItemAdded(decimal Price);
public record TestingReadModelScenarioReducerOrderSummary(string OrderId, decimal Total);
public class TestingReadModelScenarioReducerOrderSummaryReducer : IReducerFor<TestingReadModelScenarioReducerOrderSummary>{ public TestingReadModelScenarioReducerOrderSummary OnOrderCreated(TestingReadModelScenarioReducerOrderCreated @event, TestingReadModelScenarioReducerOrderSummary? current, EventContext context) => new(@event.OrderId, 0m);
public TestingReadModelScenarioReducerOrderSummary OnItemAdded(TestingReadModelScenarioReducerItemAdded @event, TestingReadModelScenarioReducerOrderSummary? current, EventContext context) => current! with { Total = current.Total + @event.Price };}
public static class TestingReadModelScenarioReducerExample{ public static async Task Run() { var orderId = "order-1"; var scenario = new ReadModelScenario<TestingReadModelScenarioReducerOrderSummary>(); await scenario.Given .ForEventSource(orderId) .Events( new TestingReadModelScenarioReducerOrderCreated("order-1"), new TestingReadModelScenarioReducerItemAdded(9.99m), new TestingReadModelScenarioReducerItemAdded(4.50m));
scenario.Instance!.Total.ShouldEqual(14.49m); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport io.cratis.chronicle.testing.ReadModelScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
@EventTypedata class ReducerExampleOrderCreated(val orderId: String)
@EventTypedata class ReducerExampleItemAdded(val price: Double)
@ReadModeldata class ReducerExampleOrderSummary(val orderId: String = "", val total: Double = 0.0)
@Reducerclass ReducerExampleOrderSummaryReducer { fun orderCreated(event: ReducerExampleOrderCreated) = ReducerExampleOrderSummary(event.orderId, 0.0)
fun itemAdded(event: ReducerExampleItemAdded, current: ReducerExampleOrderSummary?): ReducerExampleOrderSummary { val existing = current ?: ReducerExampleOrderSummary() return existing.copy(total = existing.total + event.price) }}
class ReducerExampleTests {
@Test fun `each event folds into the running total`() = runBlocking { val scenario = ReadModelScenario<ReducerExampleOrderSummary>(ReducerExampleOrderSummaryReducer())
val instance = scenario.fold( "order-1", ReducerExampleOrderCreated("order-1"), ReducerExampleItemAdded(9.99), ReducerExampleItemAdded(4.50) )
assertEquals(14.49, instance!!.total, 0.001) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TestingReadModelsScenarioReducerExample {
@EventType record OrderCreated(String orderId) { }
@EventType record ItemAdded(double price) { }
@ReadModel record OrderSummary(String orderId, double total) { }
@Reducer static class OrderSummaryReducer { OrderSummary orderCreated(OrderCreated event) { return new OrderSummary(event.orderId(), 0.0); }
OrderSummary itemAdded(ItemAdded event, OrderSummary current) { return new OrderSummary(current.orderId(), current.total() + event.price()); } }
@Test void eachEventFoldsIntoTheRunningTotal() { var reducer = new OrderSummaryReducer();
var summary = reducer.orderCreated(new OrderCreated("order-1")); summary = reducer.itemAdded(new ItemAdded(9.99), summary); summary = reducer.itemAdded(new ItemAdded(4.50), summary);
assertEquals(14.49, summary.total(), 0.001); }}defmodule MyApp.Events.TestingReadModelScenarioReducerOrderCreated do use Chronicle.Events.EventType, id: "testing-read-model-scenario-reducer-order-created"
defstruct [:order_id]end
defmodule MyApp.Events.TestingReadModelScenarioReducerItemAdded do use Chronicle.Events.EventType, id: "testing-read-model-scenario-reducer-item-added"
defstruct [:price_in_cents]end
defmodule MyApp.ReadModels.TestingReadModelScenarioReducerOrderSummary do use Chronicle.ReadModels.ReadModel
defstruct order_id: nil, total_in_cents: 0end
defmodule MyApp.Reducers.TestingReadModelScenarioReducerOrderSummaryReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.TestingReadModelScenarioReducerOrderSummary
alias MyApp.Events.{ TestingReadModelScenarioReducerItemAdded, TestingReadModelScenarioReducerOrderCreated }
alias MyApp.ReadModels.TestingReadModelScenarioReducerOrderSummary
@handles TestingReadModelScenarioReducerOrderCreated @handles TestingReadModelScenarioReducerItemAdded
@impl true def reduce(%TestingReadModelScenarioReducerOrderCreated{order_id: order_id}, _model, _context), do: %TestingReadModelScenarioReducerOrderSummary{order_id: order_id, total_in_cents: 0}
def reduce(%TestingReadModelScenarioReducerItemAdded{price_in_cents: price}, model, _context), do: %{model | total_in_cents: model.total_in_cents + price}end
defmodule MyApp.TestingReadModelScenarioReducerExampleTest do use Chronicle.Testing.Case, event_types: [ MyApp.Events.TestingReadModelScenarioReducerOrderCreated, MyApp.Events.TestingReadModelScenarioReducerItemAdded ], reducers: [MyApp.Reducers.TestingReadModelScenarioReducerOrderSummaryReducer]
alias MyApp.Events.{ TestingReadModelScenarioReducerItemAdded, TestingReadModelScenarioReducerOrderCreated }
alias MyApp.Reducers.TestingReadModelScenarioReducerOrderSummaryReducer
test "the reducer totals the item prices", %{chronicle: client} do order_id = "order-1"
:ok = Chronicle.EventSequences.EventLog.append_many( order_id, [ %TestingReadModelScenarioReducerOrderCreated{order_id: order_id}, %TestingReadModelScenarioReducerItemAdded{price_in_cents: 999}, %TestingReadModelScenarioReducerItemAdded{price_in_cents: 450} ], client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
{:ok, summary} = Chronicle.Testing.read_model( client, TestingReadModelScenarioReducerOrderSummaryReducer, order_id )
assert summary.total_in_cents == 1449 endendimport { field } from '@cratis/fundamentals';import { eventType, reducer } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class TestingScenarioOrderCreated { @field(String) orderId: string; constructor(orderId: string) { this.orderId = orderId; }}
@eventType()class TestingScenarioItemAdded { @field(Number) price: number; constructor(price: number) { this.price = price; }}
class TestingScenarioOrderSummary { orderId = ''; total = 0;}
@reducer('testing-scenario-order-summary', undefined, TestingScenarioOrderSummary)class TestingScenarioOrderReducer { testingScenarioOrderCreated(event: TestingScenarioOrderCreated): TestingScenarioOrderSummary { return { orderId: event.orderId, total: 0 }; } testingScenarioItemAdded(event: TestingScenarioItemAdded, current: TestingScenarioOrderSummary): TestingScenarioOrderSummary { return { ...current, total: current.total + event.price }; }}
const reducerScenario = new ReadModelScenario(TestingScenarioOrderSummary, { eventTypes: [TestingScenarioOrderCreated, TestingScenarioItemAdded], reducers: [TestingScenarioOrderReducer], projections: []});reducerScenario.given.forEventSource('order-1').events( new TestingScenarioOrderCreated('order-1'), new TestingScenarioItemAdded(9.99), new TestingScenarioItemAdded(4.50));const reducerSummary = await reducerScenario.instance;if (reducerSummary?.total !== 14.49) throw new Error('Expected total 14.49');Example: fluent projection
Section titled “Example: fluent projection”using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioFluentProductCreated(string Name);
[EventType]public record TestingReadModelScenarioFluentStockAdjusted(int NewStock);
public record TestingReadModelScenarioFluentProductView(string Name, int Stock);
public class TestingReadModelScenarioFluentProductViewProjection : IProjectionFor<TestingReadModelScenarioFluentProductView>{ public void Define(IProjectionBuilderFor<TestingReadModelScenarioFluentProductView> builder) => builder .From<TestingReadModelScenarioFluentProductCreated>() .From<TestingReadModelScenarioFluentStockAdjusted>(_ => _ .Set(m => m.Stock).To(e => e.NewStock));}
public static class TestingReadModelScenarioFluentProjectionExample{ public static async Task Run() { var productId = "product-1"; var scenario = new ReadModelScenario<TestingReadModelScenarioFluentProductView>(); await scenario.Given .ForEventSource(productId) .Events( new TestingReadModelScenarioFluentProductCreated("Widget"), new TestingReadModelScenarioFluentStockAdjusted(100));
scenario.Instance!.Name.ShouldEqual("Widget"); scenario.Instance!.Stock.ShouldEqual(100); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { field } from '@cratis/fundamentals';import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class TestingScenarioProductCreated { @field(String) name: string; constructor(name: string) { this.name = name; }}
@eventType()class TestingScenarioStockAdjusted { @field(Number) newStock: number; constructor(newStock: number) { this.newStock = newStock; }}
class TestingScenarioProductView { @field(String) id = ''; @field(String) name = ''; @field(Number) stock = 0;}
@projection('testing-scenario-product-view', TestingScenarioProductView)class TestingScenarioProductProjection implements IProjectionFor<TestingScenarioProductView> { define(builder: IProjectionBuilderFor<TestingScenarioProductView>): void { builder.from(TestingScenarioProductCreated, from => from.set(model => model.name).to(event => event.name)); builder.from(TestingScenarioStockAdjusted, from => from.set(model => model.stock).to(event => event.newStock)); }}
const productScenario = new ReadModelScenario(TestingScenarioProductView, { eventTypes: [TestingScenarioProductCreated, TestingScenarioStockAdjusted], reducers: [], projections: [TestingScenarioProductProjection]});productScenario.given.forEventSource('product-1').events( new TestingScenarioProductCreated('Widget'), new TestingScenarioStockAdjusted(100));const productView = await productScenario.instance;if (productView?.name !== 'Widget' || productView.stock !== 100) throw new Error('Expected Widget with stock 100');Example: model-bound projection
Section titled “Example: model-bound projection”using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioModelBoundShipmentDispatched(string Carrier);
[EventType]public record TestingReadModelScenarioModelBoundShipmentDelivered(DateTimeOffset DeliveredAt);
[FromEvent<TestingReadModelScenarioModelBoundShipmentDispatched>][FromEvent<TestingReadModelScenarioModelBoundShipmentDelivered>]public record TestingReadModelScenarioModelBoundDeliveryStatus( [Key] string ShipmentId, string Carrier, DateTimeOffset? DeliveredAt);
public static class TestingReadModelScenarioModelBoundExample{ public static async Task Run() { var shipmentId = "shipment-1"; var scenario = new ReadModelScenario<TestingReadModelScenarioModelBoundDeliveryStatus>(); await scenario.Given .ForEventSource(shipmentId) .Events( new TestingReadModelScenarioModelBoundShipmentDispatched("FedEx"), new TestingReadModelScenarioModelBoundShipmentDelivered(DateTimeOffset.UtcNow));
scenario.Instance!.Carrier.ShouldEqual("FedEx"); scenario.Instance!.DeliveredAt.HasValue.ShouldBeTrue(); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReadModelScenarioModelBoundShipmentDispatched do use Chronicle.Events.EventType, id: "testing-read-model-scenario-model-bound-shipment-dispatched"
defstruct [:carrier]end
defmodule MyApp.Events.TestingReadModelScenarioModelBoundShipmentDelivered do use Chronicle.Events.EventType, id: "testing-read-model-scenario-model-bound-shipment-delivered"
defstruct [:delivered_at]end
defmodule MyApp.ReadModels.TestingReadModelScenarioModelBoundDeliveryStatus do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.{ TestingReadModelScenarioModelBoundShipmentDelivered, TestingReadModelScenarioModelBoundShipmentDispatched }
defstruct shipment_id: nil, carrier: nil, delivered_at: nil
from TestingReadModelScenarioModelBoundShipmentDispatched, set: [shipment_id: :event_source_id, carrier: :carrier]
from TestingReadModelScenarioModelBoundShipmentDelivered, set: [delivered_at: :delivered_at]end
defmodule MyApp.TestingReadModelScenarioModelBoundExampleTest do use ExUnit.Case, async: true
alias MyApp.Events.{ TestingReadModelScenarioModelBoundShipmentDelivered, TestingReadModelScenarioModelBoundShipmentDispatched }
alias MyApp.ReadModels.TestingReadModelScenarioModelBoundDeliveryStatus
test "a delivered shipment keeps its carrier and gets a delivery time" do shipment_id = "shipment-1"
{:ok, status} = Chronicle.Testing.Projections.apply_event( TestingReadModelScenarioModelBoundDeliveryStatus, nil, %TestingReadModelScenarioModelBoundShipmentDispatched{carrier: "FedEx"}, shipment_id )
{:ok, status} = Chronicle.Testing.Projections.apply_event( TestingReadModelScenarioModelBoundDeliveryStatus, status, %TestingReadModelScenarioModelBoundShipmentDelivered{delivered_at: DateTime.utc_now()}, shipment_id )
assert status.carrier == "FedEx" assert %DateTime{} = status.delivered_at endendimport { field } from '@cratis/fundamentals';import { eventType, fromEvent } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class TestingScenarioShipmentDispatched { @field(String) carrier: string; constructor(carrier: string) { this.carrier = carrier; }}
@eventType()class TestingScenarioShipmentDelivered { @field(String) deliveredAt: string; constructor(deliveredAt: string) { this.deliveredAt = deliveredAt; }}
@fromEvent(TestingScenarioShipmentDispatched)@fromEvent(TestingScenarioShipmentDelivered)class TestingScenarioDeliveryStatus { @field(String) id = ''; @field(String) carrier = ''; @field(String) deliveredAt = '';}
const deliveryScenario = new ReadModelScenario(TestingScenarioDeliveryStatus);deliveryScenario.given.forEventSource('shipment-1').events( new TestingScenarioShipmentDispatched('FedEx'), new TestingScenarioShipmentDelivered('2026-06-01'));const deliveryStatus = await deliveryScenario.instance;if (deliveryStatus?.carrier !== 'FedEx' || deliveryStatus.deliveredAt !== '2026-06-01') { throw new Error('Expected dispatched and delivered status');}Child collections
Section titled “Child collections”In .NET, [ChildrenFrom<TEvent>] child collections are materialized the same way they are at runtime, so a spec can assert the child rows directly on Instance. The child key may be any value the runtime accepts:
string,int/long,bool,enum,Guid- the temporal primitives
DateOnly,TimeOnly,DateTime,DateTimeOffset - a
ConceptAs<T>over any of the above
Both same-event-source children (parent and child events share one event source) and cross-stream children (child events on their own event source, linked with parentKey) are supported, and two events carrying the same child key merge into a single child — matching the real sink.
using System.Linq;using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioChildrenTimesheetStarted(int Year);
[EventType]public record TestingReadModelScenarioChildrenDayRecorded(DateOnly Day, decimal Hours);
public record TestingReadModelScenarioChildrenTimesheetDay(DateOnly Day, decimal Hours);
[FromEvent<TestingReadModelScenarioChildrenTimesheetStarted>]public record TestingReadModelScenarioChildrenTimesheet( [Key] Guid Id, int Year,
[ChildrenFrom<TestingReadModelScenarioChildrenDayRecorded>(key: nameof(TestingReadModelScenarioChildrenDayRecorded.Day))] IEnumerable<TestingReadModelScenarioChildrenTimesheetDay> Days);
public static class TestingReadModelScenarioChildren{ public static async Task Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioChildrenTimesheet>(); await scenario.Given .ForEventSource(Guid.NewGuid()) .Events( new TestingReadModelScenarioChildrenTimesheetStarted(2026), new TestingReadModelScenarioChildrenDayRecorded(new DateOnly(2026, 6, 1), 7.5m), new TestingReadModelScenarioChildrenDayRecorded(new DateOnly(2026, 6, 2), 8m));
scenario.Instance!.Days.Count().ShouldEqual(2); scenario.Instance!.Days.First().Day.ShouldEqual(new DateOnly(2026, 6, 1)); scenario.Instance!.Days.First().Hours.ShouldEqual(7.5m); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Variants
Section titled “Variants”ReadModelScenario projects variants the way a running kernel does, for both model-bound ([VariantOf<T>]) and
fluent (.VariantOf<T>(...)) variants. It discovers the other variants of the group from the registered
artifacts, so entering one variant removes the entity from the others, and a non-entering event that was seeded
before the entering event is on the variant when it is created. Use one scenario per variant, seeded with the
same events, to specify a move from one variant to the next.
Multiple instances (joins and cross-stream)
Section titled “Multiple instances (joins and cross-stream)”In .NET, Instance returns a single threaded result, which is ambiguous when events span more than one event source — for example a [Join], whose join-source event materializes a second instance. For those specs, assert against a specific instance with InstanceForEventSourceId, which resolves the intended instance from the sink even when another source’s event was seeded first:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioJoinCustomerRegistered(string Name);
[EventType]public record TestingReadModelScenarioJoinOrderPlaced(string CustomerId, decimal Amount);
[FromEvent<TestingReadModelScenarioJoinOrderPlaced>]public record TestingReadModelScenarioJoinOrder( [Key] string Id, string CustomerId, [Join<TestingReadModelScenarioJoinCustomerRegistered>(on: nameof(CustomerId), eventPropertyName: nameof(TestingReadModelScenarioJoinCustomerRegistered.Name))] string CustomerName, decimal Amount);
public static class TestingReadModelScenarioMultipleInstances{ public static async Task Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioJoinOrder>(); var customerId = EventSourceId.New(); var orderId = EventSourceId.New();
await scenario.Given.ForEventSource(customerId).Events(new TestingReadModelScenarioJoinCustomerRegistered("Ada")); await scenario.Given.ForEventSource(orderId).Events(new TestingReadModelScenarioJoinOrderPlaced(customerId.Value, 100m));
var order = scenario.InstanceForEventSourceId(orderId); order!.CustomerName.ShouldEqual("Ada"); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Instances exposes every materialized instance keyed by its event source id. Both are populated for projections; reducers are single-instance and expose their result through Instance.
If more than one instance materializes and you read Instance, it throws MultipleInstancesMaterialized rather than silently returning a blended object — a signal to reach for InstanceForEventSourceId or Instances instead.
What the harness stands in for
Section titled “What the harness stands in for”The .NET scenario runs in-process, making this tier fast enough to use on every build; the price is that some of what a deployed Chronicle does is stood in for. None of it is a defect — but a spec whose subject lives in a substituted layer passes just as confidently as one whose subject does not, so it is worth knowing which is which before you rely on a green.
These are substituted for every scenario, whatever your read model looks like:
| Layer | What runs instead |
|---|---|
| The read model sink | An in-memory sink. Documents are held as objects and round-tripped through JSON, never through the encoding a real store uses. |
| Storage and the observer lifecycle | Chronicle’s production in-memory storage holds seeded events, including their original and migrated generations and content hashes. No observers run: there is no partition scheduling, replay or rewind. |
| The event context | The sequence number is the event’s position in what you seeded. Projection inputs get an Occurred value from a UTC base plus one tick per event. Reducers get a UTC Occurred stamped when the scenario folds the events. |
| The document key mapping | The _id ↔ identifier-property mapping a store performs is applied in C# instead. |
| The read model definition | Identifier, container and display name come straight off the CLR type and there are no indexes, where a deployed Chronicle uses your naming policy and the indexes you declared. |
| The client read surface | scenario.ReadModels serves explicitly seeded instances and delegates other reads to in-process services backed by the same store. Those reads do not automatically return the processor’s materialized result. Read it through Instance, Instances or InstanceForEventSourceId. |
Old-generation events are migrated through the kernel’s event-type migrations before projection or reduction. Re-reading Instance after collecting more events folds the accumulated input again without duplicating its stored rows.
Four more are substituted only for certain shapes — and because the harness can see which of those shapes your read model has, it tells you.
Asking what a scenario substituted
Section titled “Asking what a scenario substituted”In .NET, Substitutions reports the substituted layers your read model’s own shape reaches. It is derived from the read model and its projection alone, so you can read it before seeding anything:
var scenario = new ReadModelScenario<OrderSummary>();
foreach (var substitution in scenario.Substitutions){ Console.WriteLine($"{substitution.Layer}: {substitution.Shape} — {substitution.Consequence}");}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Layer | Reported when |
|---|---|
Sink | the document key is not a Guid, or the read model carries a class-level [RemovedWith] |
Compliance | the read model’s schema carries compliance metadata — a [PII] member, or a member typed as a [PII] concept |
JoinKeyResolution | the read model or one of its child collections carries a [Join] |
DeferredKeyHandling | a child collection takes its parent key from an event property rather than from the event source id |
Compliance is worth singling out, because it is the one whose absence looks most like success. A deployed Chronicle encrypts a read model’s [PII] members on the way into the sink and releases them on the way out, keyed by the subject the event resolved to. ReadModelScenario projects and reads plaintext, so a value that a running system would store as ciphertext — or hand back empty because the subject was erased — reads back here exactly as you wrote it. Assertions about the value still hold; assertions about it being protected, about which subject it is keyed by, or about what erasure does to it, do not. EventScenario exercises event encryption and release, but does not verify read-model sink protection or support erasure. Cover read-model encryption, subject-key ownership at the sink, and erasure against a real kernel and store. This substitution is reported for both projection-backed and reducer-backed read models, and strict fidelity rejects both.
An empty list means nothing shape-dependent is being stood in for. Anything in the list is a claim your in-process spec cannot settle on its own — cover it against a real kernel and a real store as well.
To make that binding rather than advisory, opt in to strict fidelity. A read model that reaches a substituted layer then raises ReadModelDependsOnSubstitutedLayer instead of returning a result, so a suite can turn it on everywhere and have only the shapes that genuinely need a second tier change color:
var scenario = new ReadModelScenario<OrderSummary>().WithStrictFidelity();Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet..NET notes
Section titled “.NET notes”InstanceisnulluntilGivenis called, or if no events were processed.- Each
Given.ForEventSource(id).Events(...)call applies events to the specified event source. Call it multiple times to simulate events from different event sources. Given.ForEventSourceId(id)is an alias forForEventSource(id)that also exposes.ReadModel(instance)for pre-seeding.- Cratis Specifications provides
ShouldEqual,ShouldBeTrue,ShouldBeNull, and related assertions. If you use Shouldly or FluentAssertions, translate the assertions to your project’s style.