Skip to content

Read models in commands

A command can take the read model Arc resolved for its key in three places: the constructor of a CommandValidator<TCommand>, a Provide() method, and a Handle() method. All three resolve from the same command scope, so all three see the same instance.

Which one to use is a question about what the state is for. These are focused dependency fragments using application read models, events, and commands; declarations without Handle() show only the validator’s input shape, not complete runnable commands.

PositionUse it whenThe state is…
CommandValidator<TCommand>The command should be rejected with a messagea gate
Handle()The event you produce is computed from the statean input
Provide()The state has to be combined with fetched data before the decisionan input to acquisition

Validators run before the handler, so a validator can reject early with a message. The read model is an ordinary constructor dependency. Under Off the rejection is advisory: two concurrent commands can both pass it. Under On the parameter is guarded, so a command that passed on stale state is not committed. See validators and read protection:

[Command]
public record SettleLedger(EventSourceId LedgerId)
{
public LedgerSettled Handle(LedgerBalance balance) => new(balance.Balance);
}
public class SettleLedgerValidator : CommandValidator<SettleLedger>
{
public SettleLedgerValidator(LedgerBalance balance) =>
RuleFor(command => command.LedgerId)
.Must(_ => balance.Balance > 0)
.WithMessage("Ledger has no funds to settle.");
}

Validators are discovered by convention — there is nothing to register. Their messages reach the client through CommandResult like any other validation error.

When the state is an input to the event rather than a gate on it, take it in the handler:

[Command]
public record UseReducerReadModelInHandle(EventSourceId AccountId)
{
public BalanceRecorded Handle(ReducerAccountSummary summary) => new(summary.Balance);
}

The injection signature is the same for classic reducers, self-reducing models (Chronicle 19.58.0+), fluent projections, and model-bound projections, including passive models. A self model needs no separate reducer registration in Arc: Chronicle’s discovered reducer set supplies it. Existing instances are read through IReadModels and released before injection; absent self-model state resolves to null. Under Off, none of these snapshots protects a later append against concurrent changes. Under On, a model that read protection can guard is read fresh and guarded, and a reducer that it cannot guard is refused. Freshness and release paths differ; see materialized and passive paths.

Provide() acquires the data Handle() needs, and its return value is passed to Handle() as an argument. A read model can be one of Provide’s own inputs:

[Command]
public record ProvideReadModelDependencyCommand(EventSourceId AccountId)
{
public ProvidedAccountBalance Provide(AccountBalanceReadModel readModel) =>
new(readModel.Balance);
public ReadModelDependencyProvided Handle(ProvidedAccountBalance balance) =>
new(balance.Value);
}

Use this shape when the projected state has to be combined with something fetched — a rate, a policy, an external lookup — before Handle() can decide. For plain validation, prefer a validator; Provide() exists to keep IO out of the decision, not to host rules. See Provide data to a command handler.

The key on a command identifies which read model instance to resolve. It does not prove that instance exists. Nullability is how you declare what absence means, and Arc behaves differently for each choice.

Nullable — absence is a business condition

Section titled “Nullable — absence is a business condition”

Declare the parameter nullable when “does not exist” is a state your rule is written around. Arc injects null and your code decides:

[Command]
public record RegisterCustomer([Key] Guid CustomerId, string Name);
public class RegisterCustomerValidator : CommandValidator<RegisterCustomer>
{
public RegisterCustomerValidator(Customer? customer) =>
RuleFor(_ => customer)
.Null()
.WithMessage("Customer is already registered");
}

The mirror image — reject when the entity is missing — is the same shape with the rule inverted, and When guards the rules that dereference it:

public class AssignPersonToRoleValidator : CommandValidator<AssignPersonToRole>
{
public AssignPersonToRoleValidator(RoleReadModel? role)
{
RuleFor(_ => role)
.NotNull()
.WithMessage("Role does not exist");
When(_ => role is not null, () =>
{
RuleFor(command => command.PersonId)
.Must(personId => !role!.AssignedPersonIds.Contains(personId))
.WithMessage("Person is already assigned to this role");
RuleFor(command => command)
.Must(_ => role!.Status == RoleStatus.Active)
.WithMessage("Cannot assign people to inactive roles");
});
}
}

Non-nullable — the projection is required

Section titled “Non-nullable — the projection is required”

Keep the parameter non-nullable when the command genuinely requires the projection and cannot evaluate its rules without it. Arc then fails the command with ReadModelDoesNotExistForCommand before your code runs, and you write rules against the state directly:

[Command]
public record SubmitOrder([Key] Guid OrderId);
public class SubmitOrderValidator : CommandValidator<SubmitOrder>
{
public SubmitOrderValidator(OrderReadModel order)
{
RuleFor(_ => order.Status)
.Equal(OrderStatus.ReadyForSubmission)
.WithMessage("Only orders that are ready for submission can be submitted");
RuleFor(_ => order.Lines)
.NotEmpty()
.WithMessage("Order must have at least one line");
}
}

A missing OrderReadModel here is a dependency-unavailable validation failure, not a domain-rule rejection: the validator declared the projection required, so its rules could not be evaluated.

ARC0006 reports a warning on every non-nullable command-scoped read model parameter, in a validator, Provide(), or Handle(). It is not saying non-nullable is wrong — it is making sure the required-state choice was made deliberately rather than by default.

The same nullability rules apply in all three positions:

[Command]
public record UseNullableReducerReadModelInHandle(EventSourceId AccountId)
{
public ReadModelAbsenceRecorded Handle(ReducerAccountSummary? summary) => new(summary is null);
}

A command can take both — projected state as context, and the aggregate as the thing that changes:

[Command]
public record AddItemToCart([Key] Guid CartId, Guid ProductId, int Quantity)
{
public async Task<AggregateRootCommitResult> Handle(
ShoppingCart cart,
ShoppingCartSummary? summary,
ILogger<AddItemToCart> logger)
{
logger.LogInformation("Cart had {Count} items", summary?.TotalItems ?? 0);
await cart.AddItem(ProductId, Quantity);
return await cart.Commit();
}
}

This fragment assumes an asynchronous aggregate mutation and Cratis.Arc.Chronicle.Aggregates.AggregateRootCommitResult. Do not also return the event the aggregate applies. Explicit commit propagates aggregate failures but finalizes the shared unit of work; see commit boundaries.

Read models never emit events. Under Off, materialized models can lag and passive models are snapshots rather than locks. For concurrent invariants use a Chronicle constraint for uniqueness, or read protection On for state. An aggregate guards only its own event source and the event types it handles. Under On, an aggregate and a plain read of the same event source in one command are refused; a read of a different source is allowed. See choosing how a command’s rule holds.

Decision reads for event-dependent commands

Section titled “Decision reads for event-dependent commands”

Under read protection On, a plain T parameter in Handle(), Provide() or a validator constructor is a guarded read. Nothing else is needed on the command. Getting started shows the whole path, and Reads lists what each read becomes.

You writeUnder Off or MeasureUnder On
Handle(BookStock? stock), or a validator constructor parameterPlain readGuarded read
[Unprotected("why")] on the parameter or the modelPlain readAdvisory read: stored, unguarded
[LookUpBy(nameof(Property))] on a parameterSelects the keySelects the key
DecisionRead<T> with [ProtectedDecision]Guarded read for that commandGuarded read. The attribute is redundant
IDecisionReads.Get<T>(key) in Provide()Guarded for [ProtectedDecision] commandsGuarded read
IReadModels, IEventStore or IEventLog readsAllowedRefused unless the method is [Unprotected("why")]

A conflict returns concurrencyViolation with the detail ReadChanged, and the command is not retried. See Refusals.

Validate is advisory: it reads fresh state and does not append or guard, so a successful validation response does not promise that Execute will commit.

A command that is not under On can opt in on its own. Mark it with [ProtectedDecision] (Cratis.Arc.Chronicle.ReadModels) before injecting DecisionRead<T> into Provide() or Handle(); use .Instance and .Exists. Inject the command-aware IDecisionReads and call Get<T>(otherKey) to guard a different event source. The profile is fixed when the Arc pipeline enters each command, including nested commands. An unmarked command under Off keeps its plain reads, and IDecisionReads.GetDetached<T> returns a detached snapshot as Chronicle’s own reader does. Inside a [ProtectedDecision] command GetDetached<T> is refused, because a detached snapshot is not enrolled in the decision. A DecisionRead<T> returned from Provide() must have been issued in that invocation.

A [ProtectedDecision] command outside On runs a discoverable validator only if it has one public constructor with no parameters. Under On a validator with read-model parameters is allowed. See Validators and read protection.

[Command]
[ProtectedDecision]
public record RenameLedger(EventSourceId LedgerId, string Name)
{
public LedgerRenamed Handle(DecisionRead<LedgerBalance> balance) => new(Name);
}
public class RenameLedgerValidator : CommandValidator<RenameLedger>
{
public RenameLedgerValidator() =>
RuleFor(command => command.Name).NotEmpty().WithMessage("A ledger needs a name.");
}

A guarded read requires a model that Chronicle admits for direct, event-source-keyed event-log reads with a supported key conversion and a known, nonempty event-type set. Flat projections, unfiltered reducers and self-reducing models without constructor dependencies other than ILogger<T> are admitted. Reducers with a filter or other dependencies, joins, nested or child sources, FromEvery, by-property keys, stream-keyed projections read as a plain parameter, open-ended event types and Pinned generation delivery are refused. The model needs an Id property of a string-like or Guid type, which is matched to the command’s key or to a [LookUpBy] property.

Revisions, redactions, projection definition changes, other event sequences, external state and writes that bypass the kernel’s append path are not guarded. A guard detects every matching event appended through the kernel, whoever wrote it. An unguarded writer’s own decision is not protected.

Arc claims ownership of the command’s Chronicle unit of work and commits or rolls it back as owner. A direct Commit() or Rollback() of that unit from command code is refused with ProtectedUnitOfWorkRequiresOwner. Immediate IEventLog.Append* calls inside a handler are not part of the guarded commit: under On they are refused; return events instead.

Declare [EventStream<TSource>(stream)] from Cratis.Chronicle.ReadModels on a source-keyed model when it always represents one stream. Take it as a plain parameter in Handle(), Provide() or a CommandValidator<T> constructor. Arc derives the source id, source type, stream type and stream id from the command’s route, including [EventStreamId("{Month}")] templates. The monthly-limit example includes the declaration, validator and a ForMonth query.

ModePer-stream parameter
OnFresh stream fold, enrolled before the value is returned; validator and handler share one fold
Off or MeasurePlain stream fold, no guard
On with [Unprotected("why")] on the parameter or modelAdvisory stream fold, no guard

A different source or stream, All, or a missing/default stream id is refused in every mode with ReadModelStreamNotOnCommandRoute. A missing template part returns 400 naming the command property before reading. [LookUpBy] is refused on per-stream parameters: the source belongs to the command’s route, not the lookup convention. A DecisionRead<T> of a declared per-stream model uses that same route; other models remain key-based.

The model is implicitly passive. Queries use IReadModels.GetInstanceById<T>(EventSourceId, EventStreamId), which supplies no guard; key-only reads and watchers are refused. Combining a stream model with a whole-source read on the same source chooses a whole-source guard. All writers must use the definition route: events without its source type are neither folded nor guarded.

For another stream or source, or a projection without a per-stream declaration, inject the command-aware IDecisionReads and pass the source id, stream type and stream id explicitly. This command fragment assumes an admitted event-source-keyed PeriodState projection with an integer Value, and a registered PeriodValueDecided(int Value) event:

using Cratis.Arc.Chronicle.ReadModels;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.ReadModels;
[Command]
[EventSourceType("reports")]
[EventStreamType("approval")]
[EventStreamId("{Period}")]
public record DecideWithinPeriod(EventSourceId Id, string Period)
{
public Task<DecisionRead<PeriodState>> Provide(IDecisionReads reads) =>
reads.Get<PeriodState>(Id, "approval", Period, "reports");
public PeriodValueDecided Handle(DecisionRead<PeriodState> state) => new(state.Instance?.Value ?? 0);
}

Under On the command needs no attribute. Outside On, add [ProtectedDecision]. The fold and its append guard use those same dimensions. Provide() returns the issued token to Handle(); Arc enrolls it in the command transaction and caches identical reads only within that invocation. A whole-source read or another stream’s read does not share that cache entry. The optional event source type narrows both the fold and guard. Keep it aligned with the returned event route.

Use AdmitForStream<T>() to check the projection shape without reading. Chronicle also admits consistently stream-id-keyed projections through this overload; reading them through a key-only Get<T> refuses with StreamKeyRequiresStreamScope. An injected DecisionRead<T> is narrowed automatically only when T declares [EventStream<TSource>(stream)]; otherwise it stays key-based. Use the explicit stream overload for another stream or source.

The stream type must be specific, not EventStreamType.All. See Chronicle’s stream-scoped decision-read contract for default-id behavior, key requirements and merged guard widening. The kernel must acknowledge stream scoping; unsupported scoping fails rather than silently folding the whole source.

For the decision matrix, costs, pitfalls and scenarios that put these pieces together, see Choosing how a command’s rule holds, its scenarios and its pitfalls.

Injection is not Chronicle-only. A read model backed by Entity Framework Core or MongoDB is injected into a command exactly the same way, and everything on this page — the three positions, and what nullability means — applies unchanged.

What differs is where the read model is loaded from and what key loads it, including how a command declares its key when there is no Chronicle to resolve one. See Read models from other providers.

For legacy plain read models, seed state with the Given builder — either events or a pinned instance. For guarded reads, explicitly select the single-log decision scenario and seed actual events; pinned state cannot supply a valid guard. See Test the race.