Define an Arc command
A command is a record that carries the user's intent and owns its own handler.
Arc discovers it, runs authorization and validation, calls Handle(), and turns
whatever Handle() returns into appended events, a response, or both.
Verified product sources
| Package | Version | Purpose |
|---|---|---|
Cratis.Arc.Core |
22.10.4 |
Cratis.Arc.Commands.ModelBound.CommandAttribute, CommandResult, CommandValidator<T> |
Cratis.Arc.Chronicle |
22.10.4 |
event append handling, Cratis.Arc.Chronicle.Commands.NotAuditedAttribute |
Cratis.Arc.ProxyGenerator.Build |
22.10.4 |
CratisProxiesOutputPath MSBuild integration |
Cratis.Fundamentals |
7.18.2 |
Cratis.Monads.Result<TResult, TError> |
Cratis.Chronicle |
16.39.1 |
EventTypeAttribute, EventForEventSourceId, ICanProvideEventSourceId |
@cratis/arc |
22.10.4 |
ICommand, CommandResult, ValidationResult |
Reverify before claiming support for another version. Arc without Chronicle is a supported setup; everything on this page that appends events needs Chronicle.
Declare the command
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
namespace <NamespaceRoot>.<Feature>;
[Command]
public record <ImperativeName>(<ConceptType> <Name>, <ConceptType> <Name>)
{
public <EventType> Handle() => new(<Name>);
}
[EventType]
public record <EventType>(<ConceptType> <Name>);
Rules the framework actually enforces:
[Command]isCratis.Arc.Commands.ModelBound.CommandAttributeand applies to a class declaration only. A type counts as a command when it carries[Command]and declares aHandlemethod; either one alone is not enough.Handleis found by name on the command type itself.ARC0003is an error when another class definesHandlefor a command, andARC0004is an error when a[Command]type has no public instanceHandle.ARC0002warns when a type hasHandlebut no[Command];ARC0007warns when a command is aclassrather than arecord.- Name the command as the action —
OpenDebitAccount, notOpenDebitAccountCommand. Handleparameters are resolved from DI, so ask for services there rather than through a constructor.- Never inject
IEventLogintoHandleto append the command's own events —ARCCHR0007warns, and it bypasses Arc's append pipeline. Express the append through the return type.
Use concept types rather than raw primitives for the command's values, and give
Chronicle-backed identities the EventSourceId<T> base — see the
cratis-fundamentals-concept skill for the exact shapes.
Choose the return shape
Handle()'s return value is dispatched by its runtime type. The shape decides
what is appended and what the caller gets back.
| Return | What Arc does |
|---|---|
| A registered event record | Appends it to the command's event source id |
IEnumerable<object> of registered events |
Appends each to the command's event source id |
EventForEventSourceId(id, @event) |
Appends that one event to id |
A collection mixing events and EventForEventSourceId |
Appends each to its own target, wrappers to their id and plain events to the command's |
| A tuple | Each element is dispatched on its own; the one element nothing can handle becomes the response |
| Anything else | Becomes the response payload |
Result<TEvent, ValidationResult> |
Success appends the event; failure becomes a validation failure |
Task<T> / ValueTask<T> of any of the above |
Awaited first, then dispatched |
See handler shapes for the dispatch order, the tuple rule, and the exact failure modes.
Two consequences worth knowing before writing the first command:
- The tuple is how a create command returns its new id. An
EventSourceId-derived value in the tuple is not appendable, so it becomes the response — and the response, when it is an event-source id value, is also what the events in that same tuple are appended to. Returning more than one unhandleable element throwsMultipleUnhandledTupleValues. - Events never carry their own event source. Cross-stream writes use
EventForEventSourceId; they are not expressed by a property on the event.
[Command]
public record Register<Thing>(<ConceptType> <Name>)
{
public (<ThingId>, <ThingRegistered>) Handle()
{
var id = <ThingId>.New();
return (id, new(<Name>));
}
}
[Command]
public record Transfer(<AccountId> From, <AccountId> To, <Money> Amount)
{
public IEnumerable<object> Handle() =>
[
new EventForEventSourceId(From, new <Withdrawn>(Amount)),
new EventForEventSourceId(To, new <Deposited>(Amount)),
];
}
Where the event source id comes from
Chronicle resolves the command's event source id in this order:
commandContext.Response, when the response is an event-source-id value — the tuple case above;ICanProvideEventSourceId.GetEventSourceId()on the command;- a single
EventSourceId/EventSourceId<T>-derived or[Key]-marked property on the command; - otherwise a freshly generated id.
A command with more than one candidate property is ambiguous and ARCCHR0002
warns; implement ICanProvideEventSourceId to say which value wins. Do not rely
on property order.
Two attributes are spelled [Key]. Chronicle reads Cratis.Chronicle.Keys.KeyAttribute.
Marking System.ComponentModel.DataAnnotations.KeyAttribute in an application
that uses Chronicle compiles, resolves nothing, and silently invents a new event
source id for every command — ARCCHR0008 reports it.
Fetch what the handler needs with Provide()
Provide is an optional instance method that runs before Handle. Its purpose
is to keep IO out of Handle: it fetches or computes values, and Arc binds them
to Handle's parameters by type.
[Command]
public record Open<Thing>(<ThingId> Id, <OwnerId> OwnerId)
{
public async Task<Result<<Owner>, ValidationResult>> Provide(IReadModels readModels)
{
var owner = await readModels.GetInstanceById<<Owner>>((EventSourceId)OwnerId);
return owner is null
? ValidationResult.Error("Owner must exist.")
: owner;
}
public <ThingOpened> Handle(<Owner> owner) => new(owner.Id);
}
- Short-circuit with
ValidationResult.Error(...); do not throw for a rejection the user can act on. A thrown exception surfaces asHasExceptionsand HTTP 500, not as a validation failure. - Every value
Providereturns must be consumed by aHandleparameter.ARC0005warns otherwise.ValidationResult,AuthorizationResultandCommandResultare exempt because they short-circuit rather than feedHandle. Provideruns after authorization and validation.
An Arc read model can also be injected directly into Provide, Handle, or a
CommandValidator<T> — but only by the command's own resolved key. Reading a
read model keyed by anything else needs an explicit by-id read, and an absent
instance does not always arrive as null. Read
read-model injection before relying on a
directly injected read model.
Decide what the causation chain may record
Every property value of the command is written to the causation of every event the command appends, and the event log is immutable. Decide this when the property is added.
| Marking | Use for | Effect |
|---|---|---|
[PII] (Chronicle) |
personal data | encrypted in the event and enrolled in erasure; already withheld from causation |
[NotAudited] (Arc Chronicle) |
a secret that is not personal data — password, token, API key | withheld from causation, nothing else |
[Command]
public record Change<Secret>(
<UserId> User,
[property: NotAudited] string Old<Secret>,
[property: NotAudited] string New<Secret>)
{
public <SecretChanged> Handle(I<Hasher> hasher) => new(hasher.Hash(New<Secret>));
}
[NotAudited] applies to a class, struct, property, or parameter. On the type it
withholds every property at once. Marking the concept type instead makes it
travel to every command that takes that value. The command is still named on the
chain either way; only the values are withheld.
ARCCHR0009 warns when a property's name reads like a secret and is unmarked.
It cannot see a secret whose name does not say so, so a clean build means
"nothing obvious was missed", not "no secrets are recorded". When the name only
reads like a secret and the value is safe to record, the framework's own guidance
is to mark it [NotAudited] anyway or rename the property — the value is written
either way, so the reading is all a reviewer has to go on.
Generate the TypeScript proxy
<PackageReference Include="Cratis.Arc.ProxyGenerator.Build" Version="22.10.4" />
<PropertyGroup>
<CratisProxiesOutputPath>$(MSBuildThisFileDirectory)../<Web>/src/api</CratisProxiesOutputPath>
</PropertyGroup>
dotnet build runs the generator after the build, and only when
CratisProxiesOutputPath is set. Output folders mirror the C# namespace, not the
file path. See proxy generation for the full
set of MSBuild knobs and the common failures.
The generated client contract
The generated proxy is a Command from @cratis/arc. Its members are the same
whatever renders it:
| Member | What it does |
|---|---|
route |
The generated route, including the configured API prefix |
roles |
The roles the command declares; empty when it declares none |
<property> |
Get or set one value; setting raises propertyChanged |
hasChanges |
True when any value differs from the initial values |
execute(allowedSeverity?, ignoreWarnings?) |
Sends the command; resolves to CommandResult |
validate() |
Runs authorization and validation on the server without the handler |
validateClientSide() |
Runs only the extracted rules locally; never touches the network |
setInitialValues(values) |
Sets the change-tracking baseline |
setInitialValuesFromCurrentValues() |
Rebaselines onto the current values |
revertChanges() / clear() |
Restore the baseline / reset everything |
Branch on the specific flag, not only on isSuccess. The exact CommandResult
and ValidationResult shapes are in
command result — in particular, a validation
failure carries members: string[] (camelCased) and a numeric severity, not a
propertyName string.
validateClientSide() runs only the rules the generator could extract, so it can
pass where execute() still fails validation — see
proxy generation for the exact extractable set.
Binding this proxy into a React component — the generated use() hook and the
Cratis Components command dialog and form fields — belongs to the Arc React and
Components guidance, not to this skill.
Route near misses
- Adding or changing a rule on an existing command: the Arc command validation guidance.
- Executing an existing command from backend code:
cratis-arc-command-execution. - Append-time uniqueness or concurrency constraints: the Chronicle event constraints guidance.
- Choosing the concept or identity type for a value:
cratis-fundamentals-concept.
Verify
- The command is a
record, carries[Command], and declares a public instanceHandle. - The return shape matches what the command is supposed to do, and any
cross-stream event is wrapped in
EventForEventSourceId. - At most one tuple element is unhandleable.
- The event source id resolves from exactly one place;
ARCCHR0002is silent. [Key], where used, isCratis.Chronicle.Keys.KeyAttribute.- No
IEventLogis injected intoHandle. - Every value
Providereturns is consumed byHandle. - Rejections are validation results, never thrown exceptions.
- Every secret or personal value is marked before it can reach the event log.
dotnet buildis clean in Debug and Release, with no suppressedARC*orARCCHR*diagnostic left unjustified.- The generated proxy exists at the configured output path and the frontend compiles against it.