Skip to content

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.

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.

ReadModelScenario<TReadModel> is in the Cratis.Chronicle.Testing NuGet package:

Terminal window
dotnet add package Cratis.Specifications.XUnit
dotnet add package Cratis.Chronicle.Testing
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");
}

View C# snippet source on GitHub

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.

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");
}

View C# snippet source on GitHub

All three OnStream arguments are optional. Omitting an argument, or passing null, uses the same default as appending to the event log:

ArgumentDefault
eventSourceTypeEventSourceType.Default
eventStreamTypeEventStreamType.All
eventStreamIdEventStreamId.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.

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);
}
}

View C# snippet source on GitHub

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.

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.

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);
}
}

View C# snippet source on GitHub

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);
}
}

View C# snippet source on GitHub

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);
}
}

View C# snippet source on GitHub

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.

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.

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.

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;

View C# snippet source on GitHub

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.

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.

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:

  1. Reducer — a classic IReducerFor<TReadModel>, explicitly nominated plain instance reducer, or structurally recognized self-reducing model.
  2. Fluent projection — a class implementing IProjectionFor<TReadModel>.
  3. Model-bound projection — TReadModel carries 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.

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.

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();
}
}

View C# snippet source on GitHub

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.

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();

View C# snippet source on GitHub

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.

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);
}
}

View C# snippet source on GitHub

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);
}
}

View C# snippet source on GitHub

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();
}
}

View C# snippet source on GitHub

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);
}
}

View C# snippet source on GitHub

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");
}
}

View C# snippet source on GitHub

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.

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:

LayerWhat runs instead
The read model sinkAn 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 lifecycleChronicle’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 contextThe 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 mappingThe _id ↔ identifier-property mapping a store performs is applied in C# instead.
The read model definitionIdentifier, 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 surfacescenario.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.

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}");
}

View C# snippet source on GitHub

LayerReported when
Sinkthe document key is not a Guid, or the read model carries a class-level [RemovedWith]
Compliancethe read model’s schema carries compliance metadata — a [PII] member, or a member typed as a [PII] concept
JoinKeyResolutionthe read model or one of its child collections carries a [Join]
DeferredKeyHandlinga 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();

View C# snippet source on GitHub

  • Instance is null until Given is 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 for ForEventSource(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.