Command pipeline
When a background job or application service needs the same command behavior as an HTTP caller, inject ICommandPipeline. It runs the model-bound authorization, validation, provisioning, handler, response handlers, command operations, and execution scopes without an HTTP round trip.
Basic usage
Section titled “Basic usage”These complete caller types use AddItemToCart, ICartService, and the shared cart concepts from the service-backed example. Configure Arc and the example’s service registration before resolving them.
Without a service provider
Section titled “Without a service provider”using System.Threading.Tasks;using Cratis.Arc.Commands;
public class CartApplicationService(ICommandPipeline pipeline){ public Task<CommandResult<CartLineId>> Add(Sku sku, Quantity quantity) => pipeline.Execute<CartLineId>(new AddItemToCart(sku, quantity));}Each scope-free call creates and disposes its own DI scope. The returned task represents completed execution, including response handlers and scope completion. The caller must await it before using the result; returning the task from an asynchronous API, as above, preserves that contract.
With a service provider
Section titled “With a service provider”using System;using System.Threading.Tasks;using Cratis.Arc.Commands;
public class ScopedCartApplicationService(ICommandPipeline pipeline, IServiceProvider services){ public Task<CommandResult<CartLineId>> Add(Sku sku, Quantity quantity) => pipeline.Execute<CartLineId>(new AddItemToCart(sku, quantity), services);}Resolve this caller inside the existing scope you intend to share. Passing the root provider does not create a command scope. Scope-explicit calls use the supplied provider and leave its lifetime to the caller.
Execute for a specific tenant
Section titled “Execute for a specific tenant”An off-request worker has no HTTP tenant to inherit. Inject ITenantScope and begin a scope before calling the pipeline:
using System.Threading;using System.Threading.Tasks;using Cratis.Arc.Commands;using Cratis.Arc.Tenancy;
public class TenantCartJob(ITenantScope tenants, ICommandPipeline pipeline){ public async Task<CommandResult<CartLineId>> Add(TenantId tenant, Sku sku, Quantity quantity, CancellationToken stoppingToken) { using (tenants.Begin(tenant)) { return await pipeline.Execute<CartLineId>(new AddItemToCart(sku, quantity), stoppingToken); } }}ITenantScope.Begin selects a nonempty tenant for the current async flow. The explicit selection wins over the configured resolver, including during an HTTP request and when an authentication scheme selects a different principal. Nested scopes restore the outer tenant; disposal restores the previously resolved tenant. Disposal is idempotent. If an outer scope is disposed before an inner one, the inner tenant remains selected and the disposed outer tenant is skipped when the inner scope ends. The scope also works with Validate, queries, reads through IReadModels, and both typed and untyped execution, including cancellation-aware calls. Selecting a tenant does not grant authorization: enforce tenant access and establish a trusted principal separately when required. Tasks or timers started inside a scope capture its tenant even after the parent disposes it; do not reuse that captured context for unrelated work. Independent HTTP requests reset tenant selection when they enter Arc’s request pipeline, and observable emissions restore the subscriber’s tenant rather than inheriting the producer’s scope. On ASP.NET Core that reset runs in Arc’s startup filter, so it covers all application middleware, including middleware registered before UseCratisArc(), but not middleware added by another IStartupFilter registered before Arc’s; do not resolve tenant-scoped services in such middleware. ITenantScope requires Arc’s TenantIdAccessor as the effective ITenantIdAccessor registration. If you register a custom accessor (before or after Arc), resolving ITenantScope throws ExplicitTenantScopeRequiresArcAccessor rather than selecting a tenant that other services cannot see.
Create a new DI scope inside the tenant scope before resolving tenant-scoped services such as Chronicle’s event store or a DbContext. A service already resolved in an existing DI scope remains bound to that scope’s original tenant, even if you change the current tenant. Scope-free pipeline Execute and Validate create their own DI scopes inside the tenant scope for you; if you pass an existing IServiceProvider, you are responsible for creating it after Begin and for disposing it. The same rule applies to other tenant-scoped reads.
Cancellation
Section titled “Cancellation”HTTP command endpoints pass the request-aborted token. Provide() and Handle() may accept a CancellationToken, which Arc supplies directly.
The following complete command waits asynchronously; it is a timing demonstration, not a background-work scheduler:
using System.Threading;using System.Threading.Tasks;using Cratis.Arc.Commands.ModelBound;
[Command]public record WaitForInterval(int Milliseconds){ public Task Handle(CancellationToken cancellationToken) => Task.Delay(Milliseconds, cancellationToken);}Caller fragments, with Cratis.Arc.Commands and Cratis.Arc.Validation imported:
var result = await pipeline.Execute(new WaitForInterval(100), cancellationToken);var result = await pipeline.Execute( new WaitForInterval(100), serviceProvider, allowedSeverity: ValidationResultSeverity.Warning, cancellationToken: cancellationToken);Use these cancellation extension overloads with Arc’s cancellation-aware pipeline. A custom implementation of only ICommandPipeline, rather than ICommandPipelineWithCancellation, receives the compatibility call without a token. Awaiting the delayed command waits for its task; cancellation is handled through the pipeline’s result/error path, so inspect the result rather than assuming every cancellation throws to your caller.
Command results
Section titled “Command results”| Property | Meaning |
|---|---|
IsSuccess | Authorized, no remaining validation results, and no exceptions. |
IsAuthorized | The authorization verdict. |
IsValid | Whether ValidationResults is empty; this alone does not establish success. |
HasExceptions | Whether exception messages are present. |
ValidationResults | Individual Arc validation failures remaining after applicable filtering. |
ExceptionMessages, ExceptionStackTrace | Exception details; handle as potentially sensitive diagnostic information. |
AuthorizationFailureReason | A supplied reason for denial, when present. |
CorrelationId | Identifier for correlating this execution. |
Recovery | Optional backend-only command operation recovery summary; excluded from HTTP JSON. |
OperationOutcomes | Backend-only observations of started operation invocations; excluded from HTTP JSON. |
Typed command results
Section titled “Typed command results”Caller fragment using the cart example:
var result = await pipeline.Execute<CartLineId>(new AddItemToCart("BOOK-1", 2));if (result.IsSuccess){ var lineId = result.Response; Console.WriteLine(lineId);}Execute<TResult> returns CommandResult<TResult> with a Response property. Request the response type, not the raw tuple or Result wrapper: response processing determines the value exposed to callers. A response assignable to TResult, including an interface or base type, is supported. A genuinely incompatible requested type causes an InvalidCastException from the typed overload.
Without a response, the typed overload supplies default(TResult): null for a reference type, Guid.Empty for Guid, and zero for int. A void or Task handler has no response, but still runs to completion before the result is returned. Use non-generic Execute when you do not need a typed value.
Scope completion can fail after the handler produced a value. After scopes complete, failed execution clears an already selected response to the default of its concrete response type. When requesting that same type, this yields default(TResult), including Guid.Empty. Currently, adapting a cleared value-type response to object or a compatible interface can preserve its boxed default instead of null. For example, Execute<object> can return a boxed Guid.Empty after scope completion fails. Always check IsSuccess before using the response; neither null nor a default-valued response is itself an authorization or success verdict.
Exception handling
Section titled “Exception handling”The pipeline catches handler, filter, response-handler, and scope-completion exceptions and represents failures in CommandResult. Exceptions implementing IValidationFailure can be translated into validation outcomes; ordinary exceptions produce exception details. A wrong requested generic response type is a separate caller error, described above.
Arc does not undo arbitrary direct application-service writes on failure. Prefer command operations for inline work you want the framework to execute and, when safe, compensate. Their commit-aware recovery does not imply atomic rollback, automatic retries, or crash recovery. Specialized persistence coordination still belongs in a deliberately implemented execution scope. The Chronicle transaction integration is optional and has its own boundaries.
Validation without execution
Section titled “Validation without execution”Caller fragment:
var command = new AddItemToCart("BOOK-1", 2);var check = await pipeline.Validate(command);if (check.IsSuccess){ var execution = await pipeline.Execute<CartLineId>(command); Console.WriteLine(execution.IsSuccess);}Validate also has a scope-explicit overload and severity thresholds. It skips Provide(), Handle(), response processing, and execution scopes, but still runs context providers and command filters. It is not a guarantee that collaborators are side-effect free or that the later execution will succeed. See pre-flight validation.
Context and authentication
Section titled “Context and authentication”The current correlation context is used when available. Authorization uses the HTTP request principal when a request exists; otherwise it uses an explicitly established server-side principal. For trusted background execution, see server-side authorization scopes. Do not assume a background command is automatically authorized or that a fresh DI scope creates a tenant or user identity.