Skip to content

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.

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.

  1. Write a ConceptValidator for the value type. On C# it reaches into the concept’s underlying Value and applies ordinary FluentValidation rules; on the JVM it implements Arc’s ConceptValidator interface and reads the same underlying value through value(); on TypeScript the rule targets name.value with notEmpty():
public class AuthorNameValidator : ConceptValidator<AuthorName>
{
public AuthorNameValidator()
{
RuleFor(x => x.Value).NotEmpty().WithMessage("An author needs a name.");
}
}

View C# snippet source on GitHub

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.

  1. Write a CommandValidator that 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.");
}
}

View C# snippet source on GitHub

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.");
}
}

View C# snippet source on GitHub

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" }));
}

View C# snippet source on GitHub

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.

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.

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.

AddAuthor.tsx — form fragment
<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.

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.

  • An AuthorNameValidator that guards the value type, enforced everywhere an AuthorName is used — and, on C# and TypeScript, also run client-side through the generated proxy.
  • A RegisterAuthorValidator business 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 →