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.
| Position | Use it when | The state is… |
|---|---|---|
CommandValidator<TCommand> | The command should be rejected with a message | a gate |
Handle() | The event you produce is computed from the state | an input |
Provide() | The state has to be combined with fetched data before the decision | an input to acquisition |
In a validator
Section titled “In a validator”Validators run before the handler, which makes them the natural place for state-based rejection. The read model is an ordinary constructor dependency:
[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.
In Handle()
Section titled “In Handle()”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);}This works identically whether ReducerAccountSummary is materialized by a reducer, a fluent projection, or a model-bound projection.
In Provide()
Section titled “In Provide()”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.
Nullable means you handle absence
Section titled “Nullable means you handle absence”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 its absence is a fault, not an outcome. 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 not a validation outcome — it is a rejected command, because the validator declared the projection required.
The analyzer makes the choice explicit
Section titled “The analyzer makes the choice explicit”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);}Combining with an aggregate root
Section titled “Combining with an aggregate root”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 ItemAddedToCart Handle( ShoppingCart cart, // aggregate root — emits the events ShoppingCartSummary? summary, // read model — projected context ILogger<AddItemToCart> logger) { logger.LogAddingItem(summary?.TotalItems ?? 0); cart.AddItem(ProductId, Quantity);
return new ItemAddedToCart(ProductId, Quantity); }}Read models never emit events. If the decision must hold under concurrency, drive it from the aggregate or from a Chronicle constraint rather than from projected state — read models are eventually consistent.
Read models from other providers
Section titled “Read models from other providers”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.
Testing
Section titled “Testing”Seed the state the command should see with the Given builder — either the events behind it or a pinned instance — and execute through the real pipeline. See Testing with Chronicle.
See also
Section titled “See also”- Use current state in a command — the short recipe.
- When resolution fails — every error and what it means.
- Command validation — the rest of Arc’s validation model.