cratis-chronicle-read-model-specifications · git:20261014.bf442b8 · 2026-10-14 · sha256 997679dd06eaa4bb
cratis-chronicle-read-model-specifications git:20261014.bf442b8A
Immutable. This exact content never changes and is served at /api/v1/blob/997679dd06eaa4bb.
---
name: cratis-chronicle-read-model-specifications
description: Specify declarative projections and reducer wiring with ReadModelScenario from Cratis.Chronicle.Testing — seeding events through Given and asserting on the materialized instance; start with Specification plus a direct call for pure reducer decisions. Use when the behavior under specification is how events become read-model state. Do not use to create a read model and do not use for raw event append behavior.
license: MIT
---
# Chronicle read-model specifications
`ReadModelScenario<TReadModel>` runs a projection or a reducer in-process
against a sequence of events. No Chronicle server, no database, no network — the
events go in, the materialized instance comes out.
Pure reducer decisions use `Specification` plus a direct method call by
default, with the event and current state supplied explicitly (for a self model,
`Model.Reduce(@event, current)`). Add
`ReadModelScenario<TReadModel>` only where declarative projection behavior,
event dispatch, filtering, metadata or read-model wiring contribute proof.
For a read-model scenario specification the **`Given` *is* the act**. There
is no separate `When`: seeding the events is what makes the projection run.
## Verified product sources
| Package | Version | Purpose |
| --- | --- | --- |
| `Cratis.Chronicle.Testing` | `19.59.4` | `Source/Clients/Testing/ReadModels/ReadModelScenario.cs` at tag `v19.59.4`: scenario selection, self and nominated reducers |
| `Cratis.Chronicle` | `19.59.4` | `Source/Clients/DotNET/Reducers/{SelfReducerMethods,RegisteredReducerReadModel}.cs`, `ReadModels/ReadModelBuilderOwnership.cs` at tag `v19.59.4`: supported forms and ownership |
| `Cratis.Specifications.XUnit` | `4.x` | The `Specification` base and the `ShouldXxx` assertions |
Source: `Source/Clients/Testing/ReadModels/` at **Cratis/Chronicle v19.59.4**.
```bash
dotnet add package Cratis.Chronicle.Testing
```
Reverify against the Chronicle repository before claiming support for another
version.
## Route near misses
- Event appending, constraints, or the event log's own behavior: use
`cratis-chronicle-event-specifications`.
- A command running through validators, `Provide()` and `Handle()`: use
`cratis-application-slice-specifications`.
- Deciding what the read model or projection should *be*: use
`cratis-chronicle-read-model`, `cratis-chronicle-projection`, or
`cratis-chronicle-reducer`.
- The scope is model-first (its read-model `specification` blocks and `then
readmodel` rows are the contract). Decide this with the decision rule in
`cratis-screenplay-modeling-lifecycle`: model-first only when an accepted
model under the model root covers this scope, or the repository is opted in
(the root holds a committed `.play` file (`git ls-tree -r --name-only HEAD` lists a `.play` file there, narrowed to `-- <root>` when a root is configured), or the project set `mcpServers.screenplay.root`
in `.cratis/ai.json`); an empty directory, install output, a `.play` file
outside the root or an uncommitted draft does not count, and framework, infrastructure, client, adapter and not-opted-in
brownfield work stays code-first. Opted in but no model for this scope: model
it first (`cratis-screenplay-discovery`, then `cratis-screenplay-slice-design`)
before writing specifications. If those Screenplay skills are not installed,
say so and stop; never author `.play` from memory. Never edit Stage-managed
output, and never change a model specification to match existing code. Author model
specifications with `cratis-screenplay-specifications` and
`cratis-screenplay-scenario-coverage`; check finished code against them with
`cratis-application-slice-conformance`.
## When you need this
- A reducer's event dispatch, filtering or read-model wiring needs proof.
- A fluent `IProjectionFor<T>` maps event properties onto read-model properties.
- A model-bound projection — `[FromEvent<T>]`, `[SetFrom<T>]`, `[Key]` — maps
correctly.
- A boundary matters: the first event, several events, several event sources.
## Step 1 — Create the scenario
A new scenario per specification, never shared state.
```csharp
// Default, empty initial state
var scenario = new ReadModelScenario<MyReadModel>();
// With a baseline
var scenario = new ReadModelScenario<CartSummary>(new CartSummary { ItemCount = 10 });
```
Further constructors take a service provider, a `Defaults`, or both, alongside
the initial state. Use the named-parameter form when you need only one of them:
`new ReadModelScenario<T>(initialState: null, serviceProvider: services)`.
Scenarios implement `IDisposable`; call `_scenario.Dispose()` in `Destroy()`.
`WithProjection(Action<IProjectionBuilderFor<T>>)` supplies an inline projection
and wins over discovery, **including a discovered reducer**. Per-run `Defaults`
can take a client artifacts provider, service provider and options;
`InMemoryClientArtifactsProvider` and `ChronicleOptions.ExplicitArtifacts` let a
specification isolate registrations without global discovery. Nominate a plain
instance reducer with `options.ExplicitArtifacts.RegisterReducer<PlainReducer>()`
before constructing per-run `Defaults(artifacts, serviceProvider, options)`.
The same API accepts a self model; default discovery needs no marker. Do not
mutate `Defaults.Instance` across independent runs.
## Step 2 — Seed the events
```csharp
await scenario.Given
.ForEventSource(myId)
.Events(new SomeEvent("value"), new SomeOtherEvent(42));
```
Chain a second `ForEventSource` for another event source — a cross-stream
projection is seeded by giving each contributing stream its own call:
```csharp
await scenario.Given.ForEventSource(orderId).Events(new OrderCreated("order-1"), new ItemAdded(9.99m));
await scenario.Given.ForEventSource(anotherOrderId).Events(new OrderCreated("order-2"));
```
`ForEventSourceId(...)` is an alias for `ForEventSource(...)`. The builder also
offers `.ReadModel(instance)` to pin a materialized instance for code that calls
`IReadModels.GetInstanceById` rather than deriving it from events.
For a filtered reducer, seed the routing metadata as well:
`Given.ForEventSource(id).OnStream(eventSourceType, eventStreamType, eventStreamId).Events(...)`.
`[EventSourceType]`, `[EventStreamType]` and `[FromEventSource<T>]` filters are honored;
unrouted seeds no longer reach a handler for another stream. Child-before-parent
writes are retried when the parent later arrives; decimal seeds retain exact values.
Events are processed in the order supplied. Seed before asserting, and do not
try to separate a "setup" `Given` from an "act" `Given` — for a read model they
are the same phase.
## Step 3 — Assert on the instance
```csharp
_scenario.Instance!.Total.ShouldEqual(14.49m);
_scenario.Instance!.Name.ShouldEqual("Widget");
```
- `Instance` materializes lazily on first access and is `null` when nothing was
produced. It throws `MultipleInstancesMaterialized` when the seeded events
produced more than one — a single answer would be ambiguous.
- `Instances` is the whole dictionary keyed by event-source id, and
`InstanceForEventSourceId(id)` picks one. Use those when the specification
deliberately seeds several sources.
- **There are no `Should*` assertions on `ReadModelScenario`**, as members or as
extensions. Assert on `Instance` with the ordinary `ShouldXxx` assertions —
and note the shipped name is `ShouldEqual`, not `ShouldBe`.
Do not pre-emptively skip an assertion. Assume scalar concept, enum, and
identifier properties populate; when one does not, investigate the projection
rather than the harness. Skip only on a reproduced harness gap, and put the
specific reason in the skip message.
## Step 4 — Know what the scenario picked
`WithProjection` overrides discovery, including a reducer. Otherwise:
1. An **explicit projection recipe** for `TReadModel` suppresses reducer selection.
2. A **registered reducer handler** targeting it: a self-reducing model, a
separate `IReducerFor<TReadModel>`, or an explicitly nominated plain instance
class. The scenario uses the store's handler catalog, not its own interface
scan. Self signatures and registered event membership still matter.
3. A **fluent projection** implementing `IProjectionFor<TReadModel>`.
4. **Model-bound attributes** directly on `TReadModel`, then the matching
model-bound entry in the artifacts provider.
If no handler exists, processing throws `NoReadModelHandlerFound`. Classic
reducer precedence does not authorize competing self-model builders: the store
rejects those with `AmbiguousReadModelBuilder`. An inline scenario override can
exercise a different path than production; do not mistake it for registration
proof. See [reducers](../cratis-chronicle-reducer/SKILL.md) for signatures and
static direct-call/scenario examples.
For every reducer form, cover empty history, first-event null current, guarded
branches, deletion and recreation. Scenarios substitute storage, observer
lifecycle and compliance; they do not prove real-kernel registration, replay,
transport, encryption, erasure or migration.
For stricter runs, `WithStrictEventSubscription()` and `WithStrictFidelity()`
turn silent mismatches into failures.
## Step 5 — Write the specification
```csharp
#if DEBUG
namespace MyApp.Ordering.Orders.when_items_are_added;
public class and_two_items_are_priced : Specification
{
ReadModelScenario<OrderSummary> _scenario;
static readonly OrderId TheOrder = OrderId.New();
async Task Establish()
{
_scenario = new ReadModelScenario<OrderSummary>();
await _scenario.Given
.ForEventSource(TheOrder)
.Events(
new OrderCreated(),
new ItemAdded(9.99m),
new ItemAdded(4.50m));
}
[Fact] void should_sum_the_item_prices() => _scenario.Instance!.Total.ShouldEqual(14.49m);
}
#endif
```
Wrap files in `#if DEBUG … #endif` only when they compile into the application
assembly, so specification code ships only in Debug. Dedicated spec projects
need no wrapper. Keep one outcome per `should_` fact.
## Step 6 — Cover the read model, not just one event
For `.play` setup, assertions and case tables, read `cratis-application-slice-specifications`, "Reading a `.play` specification".
One summation example proves one event. Take the inventory from the contract
(the `.play` read model and its specifications, or the agreed outline); without
one, derive it from the projection and say so. Then check each line:
- **Every property is asserted** in at least one specification.
- **Every subscribed event has a specification.** Reconcile three lists in both
directions: the contract's event types, the projection's actual subscriptions,
and the events your specifications seed. Report any event in one list missing
from another; route that reconciliation to
`cratis-application-slice-conformance`. `WithStrictEventSubscription()` is
narrower: it fails a specification that *seeds* an event the projection does
not subscribe to. It does not reveal an extra subscription that no
specification seeds, nor a subscribed event lacking a specification.
- **Every removal or clearing event has a specification asserting absence**:
`Instance` is `null`, or the child is gone from the collection. Absence is a
result, not a skipped case.
- **A later event that updates only some properties** has a specification showing
the other properties are unchanged.
- **Use the contract's example values** so the code and the contract describe the
same example; substitute fresh identifiers only where isolation needs it,
keeping the example's relationships. A read-model rule found in the projection with no contract
specification is a proposal for the contract (the `.play` model when one covers the slice, otherwise the agreed outline), not silent coverage.
## Lineage
Step 6 is adapted in our own words from ideas recorded in `references/provenance.md`.
Read `references/view-lifecycle-checks.md` when delivering a read model from a contract: population, update and removal checks per property and event.
## What breaks
- **`NoReadModelHandlerFound`.** Nothing in the loaded assemblies handles
`TReadModel` — the reducer or projection type is not where the scenario looks.
- **`MultipleInstancesMaterialized` on `Instance`.** The seeded events produced
more than one instance. Use `InstanceForEventSourceId(id)`, or seed one source.
- **`Instance` is `null`.** No event in the seeded set matched the projection, so
nothing was created. Check the key resolution before the property mapping.
- **A property is silently the type default.** AutoMap wired a different event's
identically named property over the explicit setter, or the explicit setter
never fired. This looks like a specification problem and is a projection
problem.
- **A removal is never exercised.** The suite is green because every
specification only adds. Seed the event that removes or clears, then assert the
instance is `null` or the child is absent.
- **The specification passes but production does not.** The specification pinned
a read-model instance instead of seeding events, so the projection under
question never ran.
## How it is proven
`dotnet build` in Debug and `dotnet test`, both clean. A projection
specification is only meaningful once it has been seen to fail: drop one seeded
event and confirm the assertion goes red before trusting it green.