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 Provide() next to Handle() on C#, or provide() next to handle() on JVM and TypeScript. 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 on the C# backend, 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);}@Commandpublic record AssessLoan(LoanId loanId, ApplicantId applicant) { public CreditScore provide(CreditBureau bureau) { return bureau.score(applicant); }
public LoanAssessment handle(CreditScore creditScore) { return new LoanAssessment(loanId, creditScore); }}@Commanddata class AssessLoan(val loanId: LoanId, val applicant: ApplicantId) { suspend fun provide(bureau: CreditBureau): CreditScore = bureau.score(applicant)
fun handle(creditScore: CreditScore): LoanAssessment = LoanAssessment(loanId, creditScore)}import { field } from '@cratis/fundamentals';import { command, currentServices } from '@cratis/arc.core';
@command()export class AssessLoan { @field(LoanId) loanId!: LoanId; @field(ApplicantId) applicant!: ApplicantId;
// provide() takes no parameters, so it resolves its services from the execution scope. async provide(): Promise<CreditScore> { const bureau = await currentServices().resolve(CreditBureau); return bureau.scoreFor(this.applicant); }
handle(creditScore: CreditScore): LoanAssessment { return new LoanAssessment(this.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 the handler 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>();@Testvoid handleReturnsTheAssessment() { var loanId = new LoanId(UUID.randomUUID()); var command = new AssessLoan(loanId, new ApplicantId(UUID.randomUUID()));
var assessment = command.handle(new CreditScore(800));
assertEquals(new LoanAssessment(loanId, new CreditScore(800)), assessment);}@Testfun `handle returns the assessment`() { val loanId = LoanId(UUID.randomUUID()) val command = AssessLoan(loanId, ApplicantId(UUID.randomUUID()))
val assessment = command.handle(CreditScore(800))
assertEquals(LoanAssessment(loanId, CreditScore(800)), assessment)}import { describe, expect, it } from 'vitest';import { AssessLoan } from './AssessLoan.js';
describe('when assessing a loan', () => { it('should return the assessment', () => { const command = new AssessLoan(); command.loanId = LoanId.create(); command.applicant = ApplicantId.create();
expect(command.handle(new CreditScore(800))).toBeInstanceOf(LoanAssessment); });});Provide has a single job — acquire data — so it is easy to test in isolation too, with a stubbed credit bureau.
Return more than one value
Section titled “Return more than one value”On C#, return a tuple; on Kotlin, return a Pair to feed several typed handler parameters. On TypeScript, return tuple(a, b) from provide() and mark each handle() parameter with @inject(provided(TypeA), provided(TypeB)); Arc matches the values by runtime type. Returning a single object for handle() to destructure also works:
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);public CommandProvidedValues provide(CreditBureau bureau, RiskModel risk) { return CommandProvidedValues.of(bureau.score(applicant), risk.band(applicant));}
public LoanAssessment handle(CreditScore score, RiskBand band) { return new LoanAssessment(loanId, score, band);}suspend fun provide(bureau: CreditBureau, risk: RiskModel): Pair<CreditScore, RiskBand> = bureau.score(applicant) to risk.band(applicant)
fun handle(score: CreditScore, band: RiskBand): LoanAssessment = LoanAssessment(loanId, score, band)// One typed object keeps RiskBand as its enum; provide() can also return tuple(score, band) for provided(...) handle parameters.async provide(): Promise<CreditProfile> { const bureau = await currentServices().resolve(CreditBureau); const risk = await currentServices().resolve(RiskModel); return { score: await bureau.scoreFor(this.applicant), band: await risk.bandFor(this.applicant) };}
handle({ score, band }: CreditProfile): LoanAssessment { return new LoanAssessment(this.loanId, score, band);}In C#, Provide may be synchronous or async (Task<T> / ValueTask<T>). On the JVM it may be ordinary, suspend, or return a CompletionStage, and a Triple or CommandProvidedValues carries more than two values.
Use cancellation when the work can stop
Section titled “Use cancellation when the work can stop”[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));}@Commandpublic record AssessLoan(LoanId loanId, ApplicantId applicant) { // No token parameter: Arc awaits the returned stage inside its command coroutine and cancels // that stage when the request is cancelled. It cannot stop work the stage does not own. public CompletionStage<CreditScore> provide(CreditBureau bureau) { return bureau.scoreAsync(applicant); }
public CompletionStage<LoanAssessment> handle(CreditScore creditScore) { return CompletableFuture.completedFuture(new LoanAssessment(loanId, creditScore)); }}@Commanddata class AssessLoan(val loanId: LoanId, val applicant: ApplicantId) { // No token parameter: a suspending provide or handle runs inside Arc's command // coroutine, so cancelling the request cancels the work it is awaiting. suspend fun provide(bureau: CreditBureau): CreditScore = bureau.score(applicant)
suspend fun handle(creditScore: CreditScore): LoanAssessment { currentCoroutineContext().ensureActive() return LoanAssessment(loanId, creditScore) }}import { field } from '@cratis/fundamentals';import { command, currentContext, currentServices } from '@cratis/arc.core';
@command()export class AssessLoan { @field(LoanId) loanId!: LoanId; @field(ApplicantId) applicant!: ApplicantId;
// No token parameter: the execution context carries the request's AbortSignal. async provide(): Promise<CreditScore> { const bureau = await currentServices().resolve(CreditBureau); return bureau.scoreFor(this.applicant, currentContext()?.signal); }
handle(creditScore: CreditScore): LoanAssessment { currentContext()?.signal.throwIfAborted(); return new LoanAssessment(this.loanId, creditScore); }}Cancellation differs by backend. In C#, Provide and Handle can each take a CancellationToken and Arc supplies the current command execution token — the request-aborted token for HTTP endpoints, or the token you pass to ICommandPipeline. You do not register CancellationToken in the service container; Arc treats it as part of the command execution context.
TypeScript has no CancellationToken; inject the request’s AbortSignal with
@inject(abortSignal()) on provide() or handle(), or read currentContext()?.signal,
and pass it to cancellable work. See model-bound commands. On the JVM there is no token parameter at all: a suspend provide or handle already runs inside Arc’s command coroutine, so cancelling the request cancels everything it awaits. Check ensureActive() when a long computation has no suspension point of its own.
Short-circuit before Handle runs
Section titled “Short-circuit before Handle runs”Provide can stop the command before Handle is ever called. The table below
shows C# return shapes; TypeScript returns rejected(validation(...)) from
provide() for a typed failure, as its tab demonstrates.
| 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;}// An Object return lets provide hand back either the value handle consumes or a// control signal; a returned ValidationResult rejects before handle runs.public Object provide(CreditBureau bureau) { var score = bureau.score(applicant); return score == null ? ValidationResult.error("No credit history", List.of("applicant")) : score;}
public LoanAssessment handle(CreditScore creditScore) { return new LoanAssessment(loanId, creditScore);}// An Any return lets provide hand back either the value handle consumes or a// control signal; a returned ValidationResult rejects before handle runs.suspend fun provide(bureau: CreditBureau): Any = bureau.score(applicant) ?: ValidationResult.error("No credit history", listOf("applicant"))
fun handle(creditScore: CreditScore): LoanAssessment = LoanAssessment(loanId, creditScore)// A rejected(...) outcome stops the command with 400 before handle() runs.async provide(): Promise<CreditScore | Outcome<never>> { const bureau = await currentServices().resolve(CreditBureau); const score = await bureau.findScore(this.applicant); return score ?? rejected(validation('No credit history', ['applicant']));}
handle(creditScore: CreditScore): LoanAssessment { return new LoanAssessment(this.loanId, creditScore);}C# states the alternative in the return type with Result<,>. Arc.Kotlin recognizes the control value at runtime instead: a provide declared to return Any may hand back either the value the handler consumes or a ValidationResult, AuthorizationResult, or CommandResult, and Arc short-circuits on the control value. TypeScript uses an Outcome such as
rejected(validation(...)) instead of the C# Result<,> shape.
A provided value that no handler parameter consumes is almost always a mistake. C#‘s ARC0005 analyzer flags it; TypeScript’s arc-core/arc0005 ESLint rule flags a direct provide() return when handle() takes no parameters.
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);// An Optional parameter preserves owned read-model absence as Optional.empty().public ShippingQuote provide(Optional<OrderView> order, ShippingRates rates) { return order .map(view -> rates.quote(view.destination(), view.totalWeight())) .orElse(ShippingQuote.NONE);}suspend fun provide(order: OrderView?, rates: ShippingRates): ShippingQuote = if (order == null) { ShippingQuote.NONE } else { rates.quote(order.destination, order.totalWeight) }import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core';
@readModel()class LoanState { @field(String) id = ''; @field(Number) score = 0; }
@command()class AssessFromCurrentState { @field(String) @key() id = '';
@inject(commandReadModel(LoanState)) provide(state: LoanState): number { return state.score; }
handle(score: number): number { return score; }}// Register LoanState with the owning Chronicle or MongoDB integration before executing.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. Each backend resolves declared handler or provider dependencies from its service container; on TypeScript, use @inject(...) or generated metadata for erased parameter bindings.
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.