Skip to content

Read models

A Chronicle read model is current state folded out of events. Arc makes that state available to a command as an ordinary constructor or method parameter, resolved for the key the command already carries — no query, no repository, no manual lookup.

This section explains the mechanism. To use it, start with Use current state in a command for the recipe, or Read models in commands for the full reference on each position.

A read model can be injected into command-scoped code because it is resolvable by key — the event source id Arc resolved from the command — through Chronicle’s read model store. That resolvability comes from a Chronicle backing artifact, so a read model is registered when it has one of:

You declare the dependency identically in every case. The backing still matters for freshness, serialization, and compliance release; it is not always a materialized document.

Children and nested objects are not standalone read models

Section titled “Children and nested objects are not standalone read models”

A type used only as a child or complex subobject of a model-bound projection is part of its parent’s document. Its mapping attributes do not give it an independent Chronicle lookup by key. Arc excludes those types from Chronicle’s model-bound command registrations, matching Chronicle’s projection discovery.

Inject the parent and select the child from its state, or define a separate standalone read model for the command. A type that also has an explicit fluent projection or reducer remains eligible through that backing artifact. A self-referencing root is still eligible unless another candidate uses it as a child or subobject.

This restriction concerns Chronicle ownership only. Arc leaves registrations from MongoDB, Entity Framework Core, or your application intact; those providers may independently own the same CLR type. Without such a registration, a required dependency fails resolution and a nullable dependency can receive null without a lookup. Making a child parameter nullable does not make it independently resolvable.

The [ReadModel] attribute does not make a type injectable into commands. [ReadModel] is an Arc concept used for queries, and it can be backed by stores other than Chronicle — Entity Framework Core, for example. Injection into command scope only makes sense for a type that is resolvable by key, and key resolution is owned by the backing provider. Chronicle registers the read models it can resolve; a read model backed by another provider is registered by that provider, not by Chronicle.

The practical consequence: adding [ReadModel] to a record with no projection or reducer behind it will not make it appear in a validator. Add the backing artifact.

Validator · Provide() · Handle()Chronicle read modelsCommand contextCommandValidator · Provide() · Handle()Chronicle read modelsCommand contextCommandalt[instance exists][never projected orremoved][Key] · EventSourceId · ICanProvideEventSourceIdGetInstanceById(type, eventSourceId)release with compliance subjectread model instancenull

Step by step:

  1. Identity strategy — Arc inspects the command to determine its key, using one of the strategies in Resolving EventSourceId.
  2. Command context lookup — the resolved identity is read from the current CommandContext.
  3. Guard — if no usable identity is available, resolution fails with UnableToResolveReadModelFromCommandContext.
  4. Store query — Chronicle’s read model store is queried by the resolved identity.
  5. Subject release — if the input command context carries a compliance Subject and the instance exists, Arc additionally calls Release(instance). It does not pass the command subject. Chronicle resolves the release subject from the instance; materialized state may already have been released server-side. See Subject.
  6. Result — the instance is returned, or null when the projection instance does not exist.

Resolution happens exactly once per command. The same instance is handed to the validator, Provide(), and Handle().

A [Key] or event source id tells Arc which instance to resolve; it does not prove that instance exists. A projection may never have been created, may have been removed, or may be mid-rebuild. Whether that is a normal business condition or a fault is yours to declare — see nullable versus required.

Read models are registered as command-scoped services:

  • The state is fetched once per command and shared across that command’s validator, Provide(), and Handle().
  • The instance is tied to the identity resolved from the command context.
  • It is disposed when the command completes.

And they are read-only snapshots:

  • Immutable in practice — changes made to an injected instance are not persisted anywhere.
  • Backing-dependent freshness — asynchronously materialized models reflect events processed so far, not necessarily every append. Passive models are built on demand.
  • Fetched on first resolution — the command-scoped snapshot is not refreshed after a later append or returned identity change.

To change state, return events from Handle() or use an aggregate root. A read model is an input to a decision, never the place a decision is recorded.

Passive reducer models are folded in-process. Passive projection models register with no materialized sink and are resolved on demand by Chronicle. Ordinary materialized reads cross the service/JSON boundary; a passive reducer does not use that same document-deserialization path.

Arc calls the non-generic GetInstanceById(Type, key) overload. For passive reducers, do not assume it performs the generic overload’s unconditional post-reduction release: Arc’s additional release is gated on an input command subject. Test sensitive state on the path you actually use.

On-demand state is still a snapshot, not a lock. Neither passive nor materialized read-model injection by itself binds the later append to the revision used by a decision. Use append-time constraints or an explicitly captured concurrency expectation where needed.

Both give a command access to current state, and they answer different questions.

Reach forWhen
Read modelYou need projected, possibly denormalized state to validate against or compute from, and eventual consistency is acceptable
Aggregate rootYou need to emit events, enforce an invariant inside a consistency boundary, or work from source-of-truth stream state
BothValidate against projected state, then make the change through the aggregate

If correctness depends on source-of-truth state under concurrency, prefer aggregate or event-stream state over a read model — or enforce it with a Chronicle constraint, which is checked at append time.

TopicDescription
Read models in commandsInjecting into a validator, Provide(), and Handle(), and what nullability means.
Read models from other providersInjecting one backed by Entity Framework Core or MongoDB, and declaring the key without Chronicle.
When resolution failsEvery failure mode, what it means, and how to fix it.