Skip to content

Validate a command

Goal: reject bad input before a command changes state. A blank name, a negative quantity, a duplicate email — you want the command rejected, with a clear reason, before Handle() runs.

Arc runs validators before it invokes Handle(). A command that fails validation never invokes Handle() and returns a CommandResult carrying the errors. The proxy generator extracts a supported subset of rules for client feedback; async, custom, and state-dependent rules still require the authoritative server check. There are three places a rule can live; reach for the narrowest one that fits.

The validator fragments below assume the standalone tutorial’s domain types and imports. Choose one RegisterAuthorValidator example, not multiple competing validators. State/service examples additionally require the provider or application-owned collaborator stated beside them.

  1. A rule that’s true of a value everywhere → validate the value type. Write a ConceptValidator<T> and it applies to every command carrying that concept:
public class AuthorNameValidator : ConceptValidator<AuthorName>
{
public AuthorNameValidator() =>
RuleFor(x => x.Value).NotEmpty().WithMessage("An author needs a name.");
}

View C# snippet source on GitHub

The C# validator is FluentValidation-based; the Kotlin one implements Arc’s ConceptValidator interface and is registered as an ordinary Spring bean. Either way the rule travels with the value, not with one command.

  1. A rule specific to one command → validate the command. Use a CommandValidator<TCommand> for cross-field or command-only rules:
public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>
{
public RegisterAuthorValidator() =>
RuleFor(c => c.Name).NotEmpty().MaximumLength(200);
}

View C# snippet source on GitHub

For lightweight cases, C# data annotations like [Required] on the command record work too. On the JVM, CommandValidator<T> is an imperative suspending bean rather than a fluent builder; when a literal member rule should also run in the generated TypeScript client, declare it with shared fluent validation instead, which is limited to string, collection, and numeric members.

  1. A rule that depends on existing state → use a provider or application service. For by-key read-model injection, configure provider ownership and an explicit command key. Otherwise inject a database context, collection, or application collaborator and query explicitly.

    A validation pre-check is not atomic, even when moved into Handle(). Back uniqueness with a database unique index; use transactions or atomic conditional updates for other invariants. The tutorial installs the index and explains the remaining duplicate-exception translation. Typed handler failures can express a rejected write without implying that a returned object was persisted.

    With optional Chronicle integration, a registered event return can be appended and a Chronicle constraint can enforce its supported invariant at append time. Neither behavior belongs to standalone Arc.Core.

    When a snapshot check is sufficient, place the rule in the validator. If the condition must still hold when the write commits, enforce it through an atomic write, transaction, or appropriate constraint. Order status, account freezes, and role existence can all change after validation; a validator gives early feedback, not a commit-time guarantee:

    This domain fragment assumes a keyed SubmitOrder, a provider-owned OrderReadModel, and an application OrderStatus enum:

public class SubmitOrderValidator : CommandValidator<SubmitOrder>
{
public SubmitOrderValidator(OrderReadModel? order)
{
RuleFor(_ => order).NotNull().WithMessage("Order does not exist.");
When(_ => order is not null, () =>
RuleFor(_ => order!.Status)
.Equal(OrderStatus.ReadyForSubmission)
.WithMessage("Only orders that are ready for submission can be submitted."));
}
}

View C# snippet source on GitHub

The backends differ in where the current state comes from. C# resolves a provider-owned read model straight into the validator’s constructor, and the nullable parameter is how you say a missing record is a business condition rather than a fault — Use current state in a command covers that choice and all three positions in full. On the JVM the owned read model is resolved for the handler (fun handle(order: OrderView?)), so a validator loads it through its own injected repository. TypeScript can inject an application repository into a validator; an async rule can also use readModelForValidation for a command-keyed model.

A validator can also reach outside the command’s own state. It’s resolved through dependency injection, so it can take a collaborator and consult it.

IAuthorsCatalog / AuthorsCatalog is an application-owned contract, not an Arc interface. Its implementation must be available through DI:

public interface IAuthorsCatalog
{
Task<bool> IsRegistered(AuthorName name);
}
public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>
{
public RegisterAuthorValidator(IAuthorsCatalog authors)
{
RuleFor(c => c.Name).NotEmpty().MaximumLength(200);
RuleFor(c => c)
.MustAsync(async (command, ct) => !await authors.IsRegistered(command.Name))
.WithMessage("An author with that name is already registered.");
}
}

View C# snippet source on GitHub

A rule like this runs on the server only — unlike the declarative rules, it can’t be extracted into the generated proxy. The shapes genuinely diverge here: C# expresses it as FluentValidation’s MustAsync predicate, while Arc.Kotlin’s shared fluent surface is deliberately literal and has no dynamic or asynchronous predicate rule. The JVM sibling is therefore the ordinary imperative CommandValidator, whose validate is already a suspending function.

Validators are discovered by convention — you never register them. The frontend surfaces the messages automatically; see Execute a command from React.