Use current state in a command
Goal: decide against existing state without repeating the same lookup in a validator, Provide(), and Handle().
Arc can resolve a read model once in the command scope and share that instance between the positions the backend supports. This needs both a usable command key and a provider that owns key-based resolution. Marking the type [ReadModel] (C#), @ReadModel (JVM), or @readModel() (TypeScript) is not by itself a storage configuration.
Declare the key and provider
Section titled “Declare the key and provider”Standalone Arc resolves keys from [Key] or ICanProvideKeyForCommand.GetKey() in C#, from @CommandKey or CommandKeyProvider.commandKey() on the JVM, and from a registered CommandKeyResolver, getKey(), or @key() field in TypeScript. No backend infers a key merely from a property named Id. Declare at most one key: C# uses the first [Key] property it finds rather than rejecting a second. A composite application key can be composed by the provider interface, but the selected provider must still support the resulting stored key.
| Provider | What enables resolution |
|---|---|
| MongoDB (C#) | Configured Arc MongoDB integration discovers read-model candidates as fallback ownership and loads by mapped document id |
| MongoDB (TypeScript) | withMongoDB registers a resolver for models listed in readModels and loads by their declared document key; applications can register their own resolver |
| EF Core (C#) | A registered ReadOnlyDbContext owns a [ReadModel] entity through DbSet<T>; resolution currently requires a single-property primary key |
| Spring Data JPA / MongoDB (JVM) | The integrations contribute storage-neutral CanResolveReadModelForCommand providers for exact mapped @ReadModel types; JPA claims DECLARED ownership, MongoDB FALLBACK |
| Chronicle (optional) | A projection or reducer supplies integration-specific ownership and event-source key resolution; TypeScript’s integration is experimental |
A normal writable BaseDbContext like the tutorial’s LibraryDbContext is not enough to establish EF command-read-model ownership. Keep method-injecting that context for explicit lookups/writes, or configure a read-only context intentionally. Configured auto-discovery is supported; do not duplicate it with mandatory manual registration.
On the JVM, equal-strength claims for the same type fail startup rather than picking a store by bean order, and a registry-owned read-model parameter is resolved before ordinary Spring services, so a bean of the model class cannot bypass tenant or key selection.
Write against standalone state
Section titled “Write against standalone state”This command declaration uses the MongoDB tutorial’s setup, imports, and author types. Add the shown key import to the file:
using System.ComponentModel.DataAnnotations;
[Command]public record RenameAuthor([property: Key] AuthorId Id, AuthorName NewName){ public Task Handle(Author author, IMongoCollection<Author> authors) => authors.ReplaceOneAsync(existing => existing.Id == author.Id, author with { Name = NewName });}import io.cratis.arc.artifacts.CommandKey;
@Commandpublic record RenameAuthor(@CommandKey AuthorId id, AuthorName newName) { public void handle(Author author, @FromServices AuthorRepository authors) { authors.save(new Author(author.id(), newName)); }}import io.cratis.arc.artifacts.CommandKey
@Commanddata class RenameAuthor(@CommandKey val id: AuthorId, val newName: AuthorName) { suspend fun handle(author: Author, @FromServices authors: AuthorRepository) { authors.save(author.copy(name = newName)) }}import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key } from '@cratis/arc.core';
@command()class RenameKnownAuthor { @field(AuthorId) @key() id!: AuthorId; @field(AuthorName) newName!: AuthorName;
@inject(commandReadModel(Author), AuthorRepository) async handle(author: Author, authors: AuthorRepository): Promise<void> { author.name = this.newName; await authors.save(author); }}// Configure Author as a MongoDB-owned read model; Arc will look it up by the declared key.Arc loads the author for the command key, then injects it alongside the collection or repository that performs the write. The explicit replacement persists the change. A returned DTO alone would not.
Choose the decision point
Section titled “Choose the decision point”| Need | Position |
|---|---|
| Reject with a friendly message before work | CommandValidator<TCommand> |
| Acquire data using current state | Provide() / provide |
| Compute and perform the write or response | Handle() / handle |
All three positions receive the provider-owned instance in C#. On the JVM the registry resolves it for provide and handle; a validator that needs current state injects its own repository, as the fragments below show. TypeScript binds commandReadModel(Type) to a provide() or handle() parameter. Its validator does not take a read model as a constructor parameter either; the TypeScript fragments inject an application-owned repository, and a validator can instead read one inside an async rule with readModelForValidation.
For example, a validator fragment beside the command can reject a rename to the current name:
public class RenameAuthorValidator : CommandValidator<RenameAuthor>{ public RenameAuthorValidator(Author? author) { RuleFor(command => command.Id) .Must(_ => author is not null) .WithMessage("Author does not exist."); When(_ => author is not null, () => RuleFor(command => command.NewName) .Must(name => name != author!.Name) .WithMessage("Choose a different name.")); }}public final class RenameAuthorValidator implements BlockingCommandValidator<RenameAuthor> { private final AuthorRepository authors;
public RenameAuthorValidator(AuthorRepository authors) { this.authors = authors; }
@Override public Class<RenameAuthor> getCommandType() { return RenameAuthor.class; }
@Override public List<ValidationResult> validate(RenameAuthor command, CommandContext context) { var author = authors.findById(command.id()); if (author == null) { return List.of(ValidationResult.error("Author does not exist.", List.of("id"))); }
return author.name().equals(command.newName()) ? List.of(ValidationResult.error("Choose a different name.", List.of("newName"))) : List.of(); }}
@Configurationpublic class RenameAuthorValidation { @Bean public CommandValidator<RenameAuthor> renameAuthorValidator(AuthorRepository authors) { return new BlockingCommandValidatorAdapter<>(new RenameAuthorValidator(authors)); }}@Componentclass RenameAuthorValidator( private val authors: AuthorRepository) : CommandValidator<RenameAuthor> { override val commandType: Class<RenameAuthor> = RenameAuthor::class.java
override suspend fun validate( command: RenameAuthor, context: CommandContext ): List<ValidationResult> { val author = authors.findById(command.id) ?: return listOf(ValidationResult.error("Author does not exist.", listOf("id")))
return if (author.name == command.newName) { listOf(ValidationResult.error("Choose a different name.", listOf("newName"))) } else { emptyList() } }}import { CommandValidator, injectable, validator } from '@cratis/arc.core';
@validator(RenameAuthor)@injectable(AuthorRepository)export class RenameAuthorValidator extends CommandValidator<RenameAuthor> { constructor(authors: AuthorRepository) { super(); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => await authors.findById(command.id, signal) !== undefined) .withMessage('Author does not exist.'); this.ruleFor(command => command.newName) .mustAsync(async (newName, command, signal) => { const author = await authors.findById(command.id, signal); return author === undefined || author.name.value !== newName; }) .withMessage('Choose a different name.'); }}In C# the nullable validator parameter turns absence into a business rejection before the non-nullable handler runs. Provider-owned resolution reaches the JVM validator differently: Arc.Kotlin resolves an owned read model for the handle and provide parameters, not into a CommandValidator constructor, so the JVM sibling injects its own repository and looks the author up itself. The TypeScript sibling does the same with an injected application-owned repository. The rule, the message, and the member it is reported against are the same. See Provide data to a command handler for combining state with external data.
Say what absence means
Section titled “Say what absence means”- Nullable: a valid key with no record supplies
null; write the business rule around that possibility. TypeScript declares this withcommandReadModel(Type, { optional: true }). On the JVM a KotlinT?parameter observesnulland an ordinary JavaOptional<T>observesOptional.empty(). - Non-nullable: the instance is required. Missing state produces
ReadModelDoesNotExistForCommandas a validation failure (HTTP 400) in C#, onedependencyUnavailablevalidation result and HTTP 400 on the JVM, and a validation failure in TypeScript — rather than invoking your code with a fabricated model. - No usable key: even a nullable parameter cannot mean “not found” when Arc cannot identify what to fetch. Key-resolution failure is separate from absence.
For registration, absence can be the desired state. This alternative domain fragment assumes a keyed RegisterCustomer and a provider-owned Customer:
public class RegisterCustomerValidator : CommandValidator<RegisterCustomer>{ public RegisterCustomerValidator(Customer? customer) => RuleFor(_ => customer).Null().WithMessage("Customer is already registered.");}public final class RegisterCustomerValidator implements BlockingCommandValidator<RegisterCustomer> { private final CustomerRepository customers;
public RegisterCustomerValidator(CustomerRepository customers) { this.customers = customers; }
@Override public Class<RegisterCustomer> getCommandType() { return RegisterCustomer.class; }
@Override public List<ValidationResult> validate(RegisterCustomer command, CommandContext context) { return customers.findById(command.id()).isEmpty() ? List.of() : List.of(ValidationResult.error("Customer is already registered.", List.of("id"))); }}
@Configurationpublic class RegisterCustomerValidation { @Bean public CommandValidator<RegisterCustomer> registerCustomerValidator(CustomerRepository customers) { return new BlockingCommandValidatorAdapter<>(new RegisterCustomerValidator(customers)); }}@Componentclass RegisterCustomerValidator( private val customers: CustomerRepository) : CommandValidator<RegisterCustomer> { override val commandType: Class<RegisterCustomer> = RegisterCustomer::class.java
override suspend fun validate( command: RegisterCustomer, context: CommandContext ): List<ValidationResult> = if (customers.findById(command.id) == null) { emptyList() } else { listOf(ValidationResult.error("Customer is already registered.", listOf("id"))) }}import { CommandValidator, injectable, validator } from '@cratis/arc.core';
@validator(RegisterCustomer)@injectable(CustomerRepository)export class RegisterCustomerValidator extends CommandValidator<RegisterCustomer> { constructor(customers: CustomerRepository) { super(); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => await customers.findById(command.id, signal) === undefined) .withMessage('Customer is already registered.'); }}For an operation requiring an existing order, a non-nullable dependency can instead express that precondition. This fragment assumes a keyed SubmitOrder, a provider-owned order read model, and an application OrderStatus enum:
public class SubmitOrderValidator : CommandValidator<SubmitOrder>{ public SubmitOrderValidator(OrderReadModel order) => RuleFor(_ => order.Status) .Equal(OrderStatus.ReadyForSubmission) .WithMessage("Only orders that are ready for submission can be submitted.");}@Commandpublic record SubmitOrder(@CommandKey UUID id) { public void handle(OrderView order, @FromServices OrderSubmissions orders) { orders.submit(order.id()); }}@Commanddata class SubmitOrder(@CommandKey val id: UUID) { suspend fun handle(order: OrderView, @FromServices orders: OrderSubmissions) { orders.submit(order.id) }}import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key, rejected, validation, type Outcome } from '@cratis/arc.core';
@command()class SubmitReadyOrder { @field(OrderId) @key() id!: OrderId;
@inject(commandReadModel(Order)) handle(order: Order): Outcome<void> | void { if (order.status !== OrderStatus.ReadyForSubmission) return rejected(validation('The order is not ready for submission', ['id'])); // Perform the application write here; reading a model does not persist a change. }}// Register Order with its owning read-model integration before running this command.The tabs put the same statement in the position each backend supports. C# declares the requirement on the validator’s non-nullable constructor parameter; the JVM and TypeScript declare it on the handler’s read-model parameter, because their validators do not take read models as constructor parameters. Either way, a missing record rejects the command before your code decides anything.
ARC0006 warns on non-nullable read-model dependencies in C# so the choice is explicit.
A shared instance prevents duplicate lookups; it is not a lock, transaction, or freshness guarantee. Other commands can change the database after it was read. Protect hard invariants with database constraints or an atomic conditional write/transaction. Chronicle projections may additionally lag their events; its constraints enforce supported invariants at append time.
Optional: projected state with Chronicle
Section titled “Optional: projected state with Chronicle”With Chronicle integration, read models can be backed by fluent IProjectionFor<T>, model-bound projection attributes, or IReducerFor<T>. Integration-specific key resolution can use event-source identifiers. The same injection positions apply.
These are integrated domain fragments, assuming an event-source LedgerId / AccountId, configured projections, and registered event types; they do not persist anything in standalone Core:
[Command]public record SettleLedger(LedgerId LedgerId){ public Result<LedgerSettled, ValidationResult> Handle(LedgerBalance? balance) => balance is null ? ValidationResult.Error("The ledger does not exist", [nameof(LedgerId)]) : new LedgerSettled(balance.Balance);}
[Command]public record WithdrawFunds(AccountId AccountId, decimal Amount){ public Result<FundsWithdrawn, ValidationResult> Handle(AccountBalance? balance) => balance is null ? ValidationResult.Error("The account does not exist", [nameof(AccountId)]) : new FundsWithdrawn(Amount, balance.Balance - Amount);}@Commandpublic record SettleLedger(@CommandKey LedgerId ledgerId) { public LedgerSettled handle(LedgerBalance balance) { return new LedgerSettled(balance.balance()); }}
@Commandpublic record WithdrawFunds(@CommandKey AccountId accountId, BigDecimal amount) { public FundsWithdrawn handle(AccountBalance balance) { return new FundsWithdrawn(amount, balance.balance().subtract(amount)); }}@Commanddata class SettleLedger(@CommandKey val ledgerId: LedgerId) { fun handle(balance: LedgerBalance): LedgerSettled = LedgerSettled(balance.balance)}
@Commanddata class WithdrawFunds(@CommandKey val accountId: AccountId, val amount: BigDecimal) { fun handle(balance: AccountBalance): FundsWithdrawn = FundsWithdrawn(amount, balance.balance - amount)}import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core';import { eventType } from '@cratis/chronicle/events';import { fromEvent } from '@cratis/chronicle/projections';
@eventType() class LedgerSettled { @field(Number) balance: number; constructor(balance: number) { this.balance = balance; }}@readModel() @fromEvent(LedgerSettled)class LedgerBalance { @field(String) id = ''; @field(Number) balance = 0; }
@command()class SettleLedger { @field(String) @key() id = ''; @inject(commandReadModel(LedgerBalance)) handle(ledger: LedgerBalance): LedgerSettled { return new LedgerSettled(ledger.balance); }}// Call builder.withChronicle(...) before builder.add(SettleLedger, LedgerBalance, LedgerSettled).In C#, the nullable parameter satisfies ARC0006, and missing state rejects the command with a validation error. Do not return a nullable event to signal “nothing to do”: the command then succeeds without recording anything, and the caller cannot tell that it never happened.
The event-source identity is stated differently. TypeScript’s experimental Chronicle integration uses the resolved command key as its default event-source ID; declare @key() or supply a resolver rather than relying on a property name. C# also accepts an EventSourceId<T>-derived property as the identity candidate, so SettleLedger needs no attribute; Arc.Kotlin reads CommandContext.commandKey, so the identifying property carries @CommandKey and a command returning events without a resolvable key is rejected rather than appended to the wrong stream.
A SettleLedgerValidator can reject a nonpositive balance; an order validator can similarly gate on ReadyForSubmission. Those are state checks, not concurrency guarantees.
A Chronicle command scenario can pin the read model the command reads. This setup fragment supplies that state directly:
void Establish() => _scenario.Given.ForEventSource(_accountId) .ReadModel(new AccountBalance(150m));@BeforeEachvoid establish() { scenario.withReadModelForKey( AccountBalance.class, accountId, new AccountBalance(new BigDecimal("150")));}@BeforeEachfun establish() { scenario.withReadModelForKey( AccountBalance::class.java, accountId, AccountBalance(BigDecimal("150")) )}import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core';import { eventType } from '@cratis/chronicle/events';import { fromEvent } from '@cratis/chronicle/projections';import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing';
@eventType() class AccountOpened { @field(Number) balance = 0; }@readModel() @fromEvent(AccountOpened)class AccountBalance { @field(String) id = ''; @field(Number) balance = 0; }@command() class CheckAccount { @field(String) @key() id = ''; @inject(commandReadModel(AccountBalance)) handle(balance: AccountBalance): number { return balance.balance; }}
const scenario = ChronicleCommandScenario.for(CheckAccount, AccountBalance, AccountOpened);const balance = new AccountBalance();balance.id = 'account-1';balance.balance = 25;scenario.givenReadModel(AccountBalance, 'account-1', balance);const result = await scenario.execute({ id: 'account-1' });result.shouldBeSuccessful();await scenario.dispose();C# pins through the Chronicle-specific Given.ForEventSource(id).ReadModel(...). TypeScript pins with givenReadModel(AccountBalance, id, instance); given.forEventSource(id).readModel(instance) does the same. Arc.Kotlin pins through the scenario’s own ownership registry with withReadModelForKey, which works for Chronicle-, JPA-, or MongoDB-owned read models and also proves the command carries the key it should.
Seeding events instead of pinning establishes event-source history, but what it gives the command differs by backend. In C#, seeded events given with Given.ForEventSource(id).Events(...) materialize reducer- and projection-backed read models. In TypeScript, the in-memory ChronicleCommandScenario materializes reducer-backed read models from seeded events and rejects projection-backed ones with seeded history; test those with ChronicleKernelScenario. On the JVM, seeded events populate only the in-memory event log, not the command read-model registry, so pin the read model the command reads.
See Chronicle testing and the JVM testing guide for the complete fixtures. Standalone tests instead seed their provider or register application-service fakes as in Test a command.
See also
Section titled “See also”- Read models from other providers — provider ownership and explicit standalone keys.
- Resolution failures — distinguish missing state, missing keys, and configuration errors.
- Validate a command — choose the narrowest place for a rule.
- Chronicle event-source resolution — optional integration key conventions.
- Identify a command with a key —
@CommandKeyandCommandKeyProvideron the JVM. - Repositories in queries and commands — JVM provider ownership, tenancy, and absence behavior.