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.
Validation runs before the handler
Section titled “Validation runs before the handler”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.
- 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.");}// ConceptValidator has no coroutine in its signature, so Java implements it directly// and publishes it as an ordinary component.@Componentpublic final class AuthorNameValidator implements ConceptValidator<AuthorName> { @Override public Class<AuthorName> getConceptType() { return AuthorName.class; }
@Override public List<ValidationResult> validate(AuthorName concept) { return concept.value().isBlank() ? List.of(ValidationResult.error("An author needs a name.")) : List.of(); }}@Componentclass AuthorNameValidator : ConceptValidator<AuthorName> { override val conceptType: Class<AuthorName> = AuthorName::class.java
override fun validate(concept: AuthorName): List<ValidationResult> = if (concept.value().isBlank()) { listOf(ValidationResult.error("An author needs a name.")) } else { emptyList() }}import { ConceptValidator, validator } from '@cratis/arc.core';
@validator(AuthorName)export class AuthorNameValidator extends ConceptValidator<AuthorName> { constructor() { super(); this.ruleFor(name => name.value).notEmpty().withMessage('An author needs a name.'); }}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.
- 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);}public final class RegisterAuthorValidator implements BlockingCommandValidator<RegisterAuthor> { @Override public Class<RegisterAuthor> getCommandType() { return RegisterAuthor.class; }
@Override public List<ValidationResult> validate(RegisterAuthor command, CommandContext context) { var name = command.name().value(); if (name.isBlank()) { return List.of(ValidationResult.error("An author needs a name.", List.of("name"))); } if (name.length() > 200) { return List.of(ValidationResult.error( "An author name is at most 200 characters.", List.of("name"))); }
return List.of(); }}
@Configurationpublic class RegisterAuthorValidation { @Bean public CommandValidator<RegisterAuthor> registerAuthorValidator() { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator()); }}@Componentclass RegisterAuthorValidator : CommandValidator<RegisterAuthor> { override val commandType: Class<RegisterAuthor> = RegisterAuthor::class.java
override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List<ValidationResult> = when { command.name.value().isBlank() -> listOf(ValidationResult.error("An author needs a name.", listOf("name"))) command.name.value().length > 200 -> listOf(ValidationResult.error("An author name is at most 200 characters.", listOf("name"))) else -> emptyList() }}import { CommandValidator, validator } from '@cratis/arc.core';
@validator(RegisterAuthor)export class RegisterAuthorValidator extends CommandValidator<RegisterAuthor> { constructor() { super(); this.ruleFor(command => command.name).notEmpty().maxLength(200); }}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.
-
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-ownedOrderReadModel, and an applicationOrderStatusenum:
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.")); }}public final class SubmitOrderValidator implements BlockingCommandValidator<SubmitOrder> { private final OrderViewRepository orders;
public SubmitOrderValidator(OrderViewRepository orders) { this.orders = orders; }
@Override public Class<SubmitOrder> getCommandType() { return SubmitOrder.class; }
@Override public List<ValidationResult> validate(SubmitOrder command, CommandContext context) { var order = orders.findById(command.id()).orElse(null); if (order == null) { return List.of(ValidationResult.error("Order does not exist.", List.of("id"))); }
return order.status() == OrderStatus.READY_FOR_SUBMISSION ? List.of() : List.of(ValidationResult.error( "Only orders that are ready for submission can be submitted.", List.of("id"))); }}
@Configurationpublic class SubmitOrderValidation { @Bean public CommandValidator<SubmitOrder> submitOrderValidator(OrderViewRepository orders) { return new BlockingCommandValidatorAdapter<>(new SubmitOrderValidator(orders)); }}@Componentclass SubmitOrderValidator( private val orders: OrderViewRepository) : CommandValidator<SubmitOrder> { override val commandType: Class<SubmitOrder> = SubmitOrder::class.java
override suspend fun validate( command: SubmitOrder, context: CommandContext ): List<ValidationResult> { val order = orders.findById(command.id).orElse(null) ?: return listOf(ValidationResult.error("Order does not exist.", listOf("id")))
return if (order.status == OrderStatus.READY_FOR_SUBMISSION) { emptyList() } else { listOf( ValidationResult.error( "Only orders that are ready for submission can be submitted.", listOf("id") ) ) } }}import { CommandValidator, injectable, validator } from '@cratis/arc.core';
@validator(SubmitOrder)@injectable(OrderRepository)export class SubmitOrderValidator extends CommandValidator<SubmitOrder> { constructor(orders: OrderRepository) { super(); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => await orders.findById(command.id, signal) !== undefined) .withMessage('Order does not exist.'); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => { const order = await orders.findById(command.id, signal); return order === undefined || order.status === OrderStatus.ReadyForSubmission; }) .withMessage('Only orders that are ready for submission can be submitted.'); }}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."); }}public interface AuthorsCatalog { CompletionStage<Boolean> isRegistered(AuthorName name);}
// AsyncCommandValidator returns a CompletionStage, so the lookup composes without// blocking - there is no fluent async-predicate rule to reach for.public final class RegisterAuthorValidator implements AsyncCommandValidator<RegisterAuthor> { private final AuthorsCatalog authors;
public RegisterAuthorValidator(AuthorsCatalog authors) { this.authors = authors; }
@Override public Class<RegisterAuthor> getCommandType() { return RegisterAuthor.class; }
@Override public CompletionStage<List<ValidationResult>> validate( RegisterAuthor command, CommandContext context ) { return authors.isRegistered(command.name()).thenApply(registered -> registered ? List.of(ValidationResult.error( "An author with that name is already registered.", List.of("name"))) : List.of()); }}
@Configurationpublic class RegisterAuthorValidation { @Bean public CommandValidator<RegisterAuthor> registerAuthorValidator(AuthorsCatalog authors) { return new AsyncCommandValidatorAdapter<>(new RegisterAuthorValidator(authors)); }}interface AuthorsCatalog { suspend fun isRegistered(name: AuthorName): Boolean}
@Componentclass RegisterAuthorValidator( private val authors: AuthorsCatalog) : CommandValidator<RegisterAuthor> { override val commandType: Class<RegisterAuthor> = RegisterAuthor::class.java
// validate is a suspending function, so the lookup is awaited directly - // there is no fluent async-predicate rule to reach for. override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List<ValidationResult> = if (authors.isRegistered(command.name)) { listOf( ValidationResult.error( "An author with that name is already registered.", listOf("name") ) ) } else { emptyList() }}import { CommandValidator, injectable, validator } from '@cratis/arc.core';
// An abstract class, not an interface: the class itself is the service token.export abstract class AuthorsCatalog { abstract isRegistered(name: AuthorName, signal: AbortSignal): Promise<boolean>;}
@validator(RegisterAuthor)@injectable(AuthorsCatalog)export class RegisterAuthorValidator extends CommandValidator<RegisterAuthor> { constructor(authors: AuthorsCatalog) { super(); this.ruleFor(command => command.name).notEmpty().maxLength(200); this.ruleFor(command => command.name) .mustAsync(async (_name, command, signal) => !await authors.isRegistered(command.name, signal)) .withMessage('An author with that name is already registered.'); }}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.
See also
Section titled “See also”- Command Validation and Validation — the full validation model.
- Make it trustworthy — the same ideas, taught step by step.
- Return a result or an error — standalone responses and the
Result<,>return shape. - Use current state in a command — injecting provider-owned state into a validator,
Provide(), orHandle().