2. Make it trustworthy
We left chapter 1 with a working slice and an honest problem: a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A trustworthy app has to be able to say no — clearly, and as early as possible.
There are two different kinds of “no” here, and Arc handles them in two different places:
- “That input is malformed.” A blank name is wrong on its face — you don’t need to look at anything else to know it. That’s a validation rule, and it belongs on the value.
- “That input collides with what already exists.” A duplicate name is only wrong relative to the rest of the system. That’s a business rule, and it needs to consult state.
Let’s add both. This chapter extends the files and imports from chapter 1. On C#, put each validator in its own file under src/Authors/ with namespace Library.Authors;; choose the same database branch. On the JVM, put each validator in its own file in the same package as the command and mark it @Component so Spring registers it. On TypeScript, put the validators under the Features/ root used by both discover() and the generator; regenerate the metadata so the host discovers them. Name comparison is exact in this lesson, not case-folded or normalized.
Validate the value, once
Section titled “Validate the value, once”Your first instinct might be to validate Name inside a RegisterAuthor validator. But think about it: every command that takes an AuthorName wants the same guarantee — never blank. Arc lets you state that rule on the value type itself, so it’s enforced everywhere an AuthorName is used, and you never write it twice.
- Write a
ConceptValidatorfor the value type. On C# it reaches into the concept’s underlyingValueand applies ordinary FluentValidation rules; on the JVM it implements Arc’sConceptValidatorinterface and reads the same underlying value throughvalue(); on TypeScript the rule targetsname.valuewithnotEmpty():
public class AuthorNameValidator : ConceptValidator<AuthorName>{ public AuthorNameValidator() { RuleFor(x => x.Value).NotEmpty().WithMessage("An author needs a name."); }}@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.'); }}That’s the whole change. C# discovers the validator by convention, with no registration at all; the JVM one is an ordinary Spring bean, so @Component is all the wiring it needs. TypeScript discovers the decorated validator under Features/ when you regenerate metadata and restart the host. From then on, any command carrying an AuthorName rejects a blank one before the handler ever runs.
Reject the duplicate against existing state
Section titled “Reject the duplicate against existing state”Uniqueness is the harder kind of “no”, because it depends on what’s already there. Input validation can’t answer it — you have to look in the database. That’s a job for a CommandValidator, which validates a whole command and, because Arc constructs it from the container, can take the dependencies it needs to check state.
- Write a
CommandValidatorthat asks the database. Every backend takes the dependency it needs and looks for an author already carrying this name; if one exists, the command is rejected:
public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>{ public RegisterAuthorValidator(IMongoCollection<Author> authors) { RuleFor(c => c.Name) .MustAsync(async (name, _) => !await authors.Find(a => a.Name == name).AnyAsync()) .WithMessage("An author with that name is already registered."); }}public final class RegisterAuthorValidator implements BlockingCommandValidator<RegisterAuthor> { private final AuthorRepository authors;
public RegisterAuthorValidator(AuthorRepository authors) { this.authors = authors; }
@Override public Class<RegisterAuthor> getCommandType() { return RegisterAuthor.class; }
// validate has no coroutine in its signature, so the blocking lookup is called // directly. Reach for AsyncCommandValidator when the lookup itself is asynchronous. @Override public List<ValidationResult> validate(RegisterAuthor command, CommandContext context) { return authors.existsByName(command.name()) ? 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(AuthorRepository authors) { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator(authors)); }}@Componentclass RegisterAuthorValidator( private val authors: AuthorRepository) : CommandValidator<RegisterAuthor> { override val commandType: Class<RegisterAuthor> = RegisterAuthor::class.java
// validate suspends, so the lookup is awaited directly. override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List<ValidationResult> = if (authors.existsByName(command.name)) { listOf( ValidationResult.error( "An author with that name is already registered.", listOf("name") ) ) } else { emptyList() }}import { CommandValidator, currentServices, validator } from '@cratis/arc.core';
@validator(RegisterAuthor)export class RegisterAuthorValidator extends CommandValidator<RegisterAuthor> { constructor() { super(); this.ruleFor(command => command.name) .mustAsync(async (_name, command, signal) => { const authors = await currentServices().resolve(AuthorRepository); return !await authors.existsByName(command.name, signal); }) .withMessage('An author with that name is already registered.'); }}The two shapes genuinely differ, and it is worth seeing why. C# expresses the lookup as FluentValidation’s MustAsync predicate. Arc.Kotlin has no async predicate rule to reach for — its shared fluent surface is deliberately limited to literal rules — so the JVM sibling is the plain imperative CommandValidator bean, whose validate is already a suspending function and can simply await the repository. TypeScript uses mustAsync on the command’s name and resolves the application-owned AuthorRepository token from currentServices() inside the rule. Arc constructs validators during host preflight, before there is a tenant; constructor-injecting a tenant-scoped MongoDB repository would fail startup. Every backend returns the same rejection to the caller.
The handler doesn’t change — Handle() on C# and handle on the JVM and TypeScript still perform the chapter-1 write. The validator runs first; if it fails, the handler never executes.
On the relational branch, the .NET validator injects the context instead of the collection. The JVM and TypeScript validators still ask their repository; its persistence implementation supplies the relational lookup:
public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>{ public RegisterAuthorValidator(LibraryDbContext db) { RuleFor(c => c.Name) .MustAsync(async (name, ct) => !await db.Authors.AnyAsync(a => a.Name == name, ct)) .WithMessage("An author with that name is already registered."); }}public final class RegisterAuthorValidator implements BlockingCommandValidator<RegisterAuthor> { private final AuthorRepository authors;
public RegisterAuthorValidator(AuthorRepository authors) { this.authors = authors; }
@Override public Class<RegisterAuthor> getCommandType() { return RegisterAuthor.class; }
// validate has no coroutine in its signature, so the blocking lookup is called // directly. Reach for AsyncCommandValidator when the lookup itself is asynchronous. @Override public List<ValidationResult> validate(RegisterAuthor command, CommandContext context) { return authors.existsByName(command.name()) ? 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(AuthorRepository authors) { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator(authors)); }}@Componentclass RegisterAuthorValidator( private val authors: AuthorRepository) : CommandValidator<RegisterAuthor> { override val commandType: Class<RegisterAuthor> = RegisterAuthor::class.java
// validate suspends, so the lookup is awaited directly. override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List<ValidationResult> = if (authors.existsByName(command.name)) { listOf( ValidationResult.error( "An author with that name is already registered.", listOf("name") ) ) } else { emptyList() }}import { CommandValidator, currentServices, validator } from '@cratis/arc.core';
@validator(RegisterAuthor)export class RegisterAuthorValidator extends CommandValidator<RegisterAuthor> { constructor() { super(); this.ruleFor(command => command.name) .mustAsync(async (_name, command, signal) => { const authors = await currentServices().resolve(AuthorRepository); return !await authors.existsByName(command.name, signal); }) .withMessage('An author with that name is already registered.'); }}Install the database constraint
Section titled “Install the database constraint”A check-then-write has a race: two registrations can both pass validation. The validator supplies the friendly message; the database must enforce uniqueness independently.
In the .NET host, add the following block to Program.cs after app.UseCratisArc() and before RunAsync(). On the JVM, add the Spring AuthorIndexes component after declaring Author, with MongoTemplate on the classpath; its constructor installs the unique index at startup. On TypeScript, after registering the artifacts, repository, and MongoDB in the standalone host, replace that host’s builder.build() / app.run() lines with await startWithAuthorIndex(builder). The helper creates a scope for tenant default, resolves its MongoCollection<Author>, installs the index, disposes the scope, then runs the host. The Arc validators do not create indexes:
using (var scope = app.Services.CreateScope()){ var authors = scope.ServiceProvider.GetRequiredService<IMongoCollection<Author>>(); await authors.Indexes.CreateOneAsync(new CreateIndexModel<Author>( Builders<Author>.IndexKeys.Ascending(author => author.Name), new CreateIndexOptions { Unique = true, Name = "unique_author_name" }));}@Componentclass AuthorIndexes { AuthorIndexes(MongoTemplate template) { template.indexOps(Author.class) .createIndex(new Index().on("name", Sort.Direction.ASC).unique()); }}@Componentclass AuthorIndexes(template: MongoTemplate) { init { template.indexOps(Author::class.java) .createIndex(Index().on("name", Sort.Direction.ASC).unique()) }}import { Severity, type ArcApplicationBuilder } from '@cratis/arc.core';import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb';
export async function ensureAuthorNameIndex(collection: MongoCollection<Author>): Promise<void> { await collection.native.createIndex( { [collection.codec.fieldName('name')]: 1 }, { unique: true, name: 'unique_author_name' });}
// In start(), call startWithAuthorIndex(builder) instead of its build/run lines.export async function startWithAuthorIndex(builder: ArcApplicationBuilder): Promise<void> { const app = await builder.build(); try { const scope = app.server.services.createScope({ tenantId: 'default', correlationId: crypto.randomUUID(), principal: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning }); try { await ensureAuthorNameIndex(await scope.resolve(mongoCollection(Author))); } finally { await scope.dispose(); } await app.run({ port: 3000 }); } finally { await app.dispose(); }}Restart the host and require index creation to succeed. Existing duplicate names must be resolved first; do not ignore an index-creation failure. The C# and TypeScript examples resolve stored field names through their serializers rather than guessing them; the Spring Data example uses its mapped name property.
Install a unique constraint on the stored author name. On .NET merge the override into your existing LibraryDbContext (do not declare it twice); on the JVM replace the AuthorRow mapping from the relational slice with the constrained version. On TypeScript, define your Drizzle authors table with the constrained version shown here (or add .unique() to your chapter-1 table) and apply the updated SQL schema before accepting writes; the EF Core override does not configure it.
public class LibraryDbContext : DbContext{ public DbSet<Author> Authors => Set<Author>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity<Author>().HasIndex(author => author.Name).IsUnique(); }}@Entity@Table(name = "authors", uniqueConstraints = @UniqueConstraint(name = "unique_author_name", columnNames = "name"))class AuthorRow { @Id UUID id; @Column(name = "name", nullable = false) String name; protected AuthorRow() {} AuthorRow(UUID id, String name) { this.id = id; this.name = name; }}@Entity@Table(name = "authors", uniqueConstraints = [UniqueConstraint(name = "unique_author_name", columnNames = ["name"])])class AuthorRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "name", nullable = false) var name: String = "")import { sqliteTable, text } from 'drizzle-orm/sqlite-core';import { DrizzleDialect, guidCodec, sqliteColumn } from '@cratis/arc.drizzle';
export const authors = sqliteTable('authors', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), name: text('name').notNull().unique()});For the disposable SQLite tutorial database, stop the host, remove only Library/library.db, and restart so EnsureCreatedAsync creates the new schema. This loses tutorial data; re-register the authors afterward. For retained data, create/apply a migration instead. EnsureCreatedAsync never upgrades an existing database.
These constraints belong to each store, not to Arc. On the JVM, Spring Data MongoDB index operations or a JPA unique constraint applied by your schema tooling provide the hard guarantee; on TypeScript, create the MongoDB index at startup or apply the Drizzle schema change through a migration. The validator gives the friendly message, and the database constraint holds under a race.
The UI already knows
Section titled “The UI already knows”Here’s the part that feels like a gift. You didn’t touch the React code from chapter 1 — but the form now validates.
When you wrote the ConceptValidator, the C# and TypeScript proxy generators extracted its portable notEmpty rule into the generated TypeScript too, so the CommandDialog runs it on the client and disables the confirm button on a blank name — no round trip. The uniqueness rule can’t run on the client (it has to look in the database), so it runs on the server; when it rejects, the message comes back through the same proxy and CommandDialog renders it against the form — “An author with that name is already registered.” — because the dialog wires command results to fields for you.
This is a fragment of AddAuthor from chapter 1; retain its imports and per-operation const [id] = useState(() => Guid.create()) initialization. Mount a new dialog instance for each registration.
<CommandDialog<RegisterAuthor> command={RegisterAuthor} title="Add author" okLabel="Add" initialValues={{ id }}> <InputTextField<RegisterAuthor> value={i => i.name} title="Name" /></CommandDialog>One rule on the value type, one rule on the command, and both ends of the app honor them — because the validation, like the types, flows from one source.
Verify the rejection paths
Section titled “Verify the rejection paths”Rebuild the backend to regenerate validation metadata — dotnet build -c Debug on C#, ./gradlew generateArcProxies on the JVM, or npm run generate on TypeScript — then run the frontend type-check/build. Open a new dialog: blank Name must keep confirmation disabled. Submit a new name successfully; open another dialog and submit that exact name again. Expect a server validation message and no additional row. Confirm the unique index/schema step succeeded separately — a friendly duplicate rejection alone does not prove concurrency protection.
What you built
Section titled “What you built”- An
AuthorNameValidatorthat guards the value type, enforced everywhere anAuthorNameis used — and, on C# and TypeScript, also run client-side through the generated proxy. - A
RegisterAuthorValidatorbusiness rule that checks existing state in the database, with a unique index behind it for the hard guarantee. - A React form that surfaces every server rejection with no extra frontend code, plus the client-side rules the backend chose to share.
Our author is trustworthy now. But an author with no books is a lonely thing. Next we’ll give them a catalog — a second feature that relates to this one. Let’s add books →