Provide data to a command handler
Goal: your command’s Handle() needs data it doesn’t carry — usually data from another service, such as a credit bureau, pricing service, inventory lookup, risk model, or tenant configuration — and you don’t want that IO tangled into the decision logic, where it’s hard to test.
Lift the IO out of Handle
Section titled “Lift the IO out of Handle”Add a Provide() method next to Handle() on the command. Provide fetches or computes the data the decision needs, and its return value is passed into Handle() as an argument. Handle can then be a pure function of its arguments. To separate the write side as well, return a command operation describing the required service work, or return events with the optional Chronicle integration. Provide does not make persistence automatic.
Provide runs before Handle, after validation and authorization pass. Its parameters are resolved from dependency injection just like Handle’s, and this is the command, so it can read the command’s own data.
The following are alternative domain fragments, not a complete loan system. LoanId, ApplicantId, CreditScore, RiskBand, LoanAssessment, and the bureau/risk/rates interfaces are application-owned types. Supply their declarations and DI implementations; import Cratis.Arc.Commands.ModelBound, Cratis.Arc.Validation, and Cratis.Monads for the Arc attributes and result examples. LoanAssessment is an ordinary response DTO, not a persisted approval or Chronicle event.
[Command]public record AssessLoan(LoanId LoanId, ApplicantId Applicant){ public CreditScore Provide(ICreditBureau bureau) => bureau.GetScore(Applicant);
public LoanAssessment Handle(CreditScore creditScore) => new(LoanId, creditScore);}Why this matters: testability
Section titled “Why this matters: testability”Because the IO lives in Provide, testing the decision needs no mocking — construct the command and call Handle directly with the values it would have received:
[Fact] void should_return_the_assessment() => new AssessLoan(LoanId.New(), ApplicantId.New()) .Handle(new CreditScore(800)) .ShouldBeOfExactType<LoanAssessment>();Provide has a single job — acquire data — so it is easy to test in isolation too, with a stubbed ICreditBureau.
Return more than one value
Section titled “Return more than one value”Return a tuple to feed several Handle parameters; each value is matched to a parameter by type:
public (CreditScore, RiskBand) Provide(ICreditBureau bureau, IRiskModel risk) => (bureau.GetScore(Applicant), risk.Band(Applicant));
public LoanAssessment Handle(CreditScore score, RiskBand band) => new(LoanId, score, band);Provide may be synchronous or async (Task<T> / ValueTask<T>).
Use cancellation when the work can stop
Section titled “Use cancellation when the work can stop”Provide and Handle can both take a CancellationToken. Arc supplies the current command execution token. For HTTP command endpoints, that is the request-aborted token. For direct ICommandPipeline execution, pass the token to the pipeline call.
[Command]public record AssessLoan(LoanId LoanId, ApplicantId Applicant){ public Task<CreditScore> Provide(ICreditBureau bureau, CancellationToken cancellationToken) => bureau.GetScore(Applicant, cancellationToken);
public Task<LoanAssessment> Handle(CreditScore creditScore, CancellationToken cancellationToken) => Task.FromResult(new LoanAssessment(LoanId, creditScore));}Use the token for IO or long-running work. You do not register CancellationToken in the service container; Arc treats it as part of the command execution context.
Short-circuit before Handle runs
Section titled “Short-circuit before Handle runs”Provide can stop the command before Handle is ever called:
| Return / do | Result |
|---|---|
ValidationResult.Error("…") | command fails validation (HTTP 400) |
an AuthorizationResult that is not authorized | command is unauthorized (HTTP 403) |
| throw | command fails with the exception (HTTP 500) |
| a value | flows into Handle as an argument |
Use Result<,> to reject or proceed when the data acquisition itself determines whether the command can continue — return the error to stop, or the value to continue:
public Result<CreditScore, ValidationResult> Provide(ICreditBureau bureau){ var score = bureau.GetScore(Applicant); return score is null ? ValidationResult.Error("No credit history", [nameof(Applicant)]) : score;}A Provide value that no Handle parameter consumes is almost always a mistake, so the analyzer flags it (ARC0005).
For ordinary command validation, including read-model existence checks, prefer a CommandValidator<>. Use Provide() when Handle() needs acquired data as an input to the decision.
Provider-owned state can already be available
Section titled “Provider-owned state can already be available”With a configured by-key read-model provider and a usable command key, Provide need not fetch that model again. Arc resolves it as a parameter alongside the services it uses — handy when existing state determines what to fetch. Without those prerequisites, inject your application service or context and fetch explicitly:
public Task<ShippingQuote> Provide(OrderReadModel? order, IShippingRates rates) => order is null ? Task.FromResult(ShippingQuote.None) : rates.Quote(order.Destination, order.TotalWeight);See Use current state in a command for how that resolution works and what a nullable parameter means.
Cross-cutting values
Section titled “Cross-cutting values”Provide is per-command. For a value that many handlers need — the current tenant’s settings, say — register it as a scoped service and take it as a Handle parameter. Arc resolves any Handle or Provide parameter it can’t otherwise supply from the container.
When not to use it
Section titled “When not to use it”If Handle already has everything it needs from the command and its injected services, you don’t need Provide. Reach for it specifically when a decision depends on data you must fetch — most often external or application-service data — where moving the fetch out buys you the testability.
See also
Section titled “See also”- Return a result or an error — the shapes
Handle()itself can return. - Test a command — testing a slice through the real pipeline.
- Validate a command — where
ValidationResult.Errorcomes from. - Use current state in a command — the read model Arc resolves for you, in all three positions.