Skip to content

From script to stage · Part 07: Validation all the way down

Cratis: from script to stage, and the long run · Part 07 of 26

In the author feature, the rule that a name has at most 200 characters can run in the browser and again on the server. The rule that a name is unique within the organization runs in neither place. Chronicle checks it while it appends the event, and that’s the only place where the check still holds when two people press Register with the same name at the same moment.

A Cratis application has several places a rule can live, from the TypeScript the proxy generator writes for the browser down to the Chronicle kernel. Arc: commands and queries without the plumbing and Rules that hold when you write took the command pipeline and the constraints one at a time. A RegisterAuthor request meets both, and the layers between them, in a fixed order. Each layer promises something the one before it can’t.

A diagram titled Where a rule can live. Six numbered cards in two rows, in the order a request meets them. 1, Browser, copied rules: early feedback, skippable. 2, Arc, authorization: decided first, 403. 3, Arc, validation: every rule runs. A bar under cards 2 and 3 reads: /validate runs only 2 and 3. 4, Arc, Provide and Handle: snapshot reads, unless protected. 5, Chronicle, schema: matches its generation. 6, Chronicle, constraints: every writer, races included. An amber note reads: X-Allowed-Severity can lift errors in .NET, unless the command has BlockOnValidationSeverity. A band along the bottom reads: Only the append sees every writer.

The layers a rule can live in, and what each one holds for.

That a name isn’t empty and has at most 200 characters is true of an author name wherever it appears. In Arc, that kind of rule goes on the concept:

[PII]
public record AuthorName(string Value) : ConceptAs<string>(Value)
{
public static implicit operator AuthorName(string value) => new(value);
}
public class AuthorNameValidator : ConceptValidator<AuthorName>
{
public AuthorNameValidator()
{
RuleFor(name => name.Value).NotEmpty().WithMessage("An author needs a name.");
RuleFor(name => name.Value).MaximumLength(200).WithMessage("An author name is at most 200 characters.");
}
}

Arc walks the whole command, nested models and collection items included, and runs the validator registered for each value’s type. A rule on AuthorName therefore covers every command and every query argument that carries an author name, including the ones nobody has written yet. A failure is reported on the field that holds the value, name, and not on the concept’s inner value, in all three Arc implementations. In .NET, FluentValidation’s default message still names the inner value, as in The length of 'Value' must be 200 characters or fewer., which is why the C# rules above carry their own messages.

The walk has two edges. In .NET it skips a member that’s null, so a concept rule never sees an absent AuthorName?, and requiring a value is the command’s job. A concept rule also sees one value at a time. A rule that compares two fields, or that applies to one command only, goes in a CommandValidator<T>. When one command really does need a concept unchecked, it opts out for that one property, with IgnoreConceptRules() in .NET, .ignoreConceptRules() in TypeScript and a ConceptValidationExclusion registration on the JVM.

After a backend build, the proxy generator copies the rules it can express into the generated TypeScript, and the form runs them as the user types. What makes the trip depends on the backend:

Backend Copied into the generated TypeScript Stays on the server
.NET NotEmpty, NotNull, EmailAddress, length rules, Matches and numeric comparisons with constants, from command and concept validators, and the matching data annotations Must, MustAsync, rules that call services, cross-property comparisons and comparisons against non-numeric constants
Kotlin and Java Literal rules in a FluentModelValidator<T>, and Jakarta constraint annotations ConceptValidator, CommandValidator and ModelValidator rules
TypeScript Literal, unconditional ruleFor chains, including rules on direct concept fields when, unless, must, mustAsync

A .NET rule inside When or Unless can be copied without its condition, and the browser then applies it every time. With a .NET or TypeScript backend, both name rules reach the browser from the concept. On the JVM the concept validator above runs on the server only, and browser rules come from a FluentModelValidator<T> or Jakarta annotations on the command’s members. Arc for TypeScript reports a diagnostic for each rule it keeps on the server.

In the browser, Arc’s CommandForm runs the generated validator and shows the message under the field. The cratis template, the starting application that dotnet new cratis or cratis new scaffolds (see Cratis: from script to stage, and the long run), comes with a register dialog that keeps its confirm button disabled while a rule fails. Cratis Components, the React layer walks through that dialog. Anyone can call the route without the browser, so the server runs every rule again. Arc gotchas lists the other ways the copy can drift from the original.

Arc runs a command through authorization, validation, Provide() and Handle(), in that order. In all three implementations, authorization is decided before any validator runs, so a caller who isn’t allowed gets a 403 (401 when authentication itself fails) with no validation detail, even when the command is also invalid. In .NET the body says isAuthorized: false and carries an empty validationResults list.

Validators can take constructor dependencies from the command’s scope. In .NET, validation runs as two filters, one for data annotations and one for FluentValidation, which covers the command validator and the walk over concept validators. Every rule in the walk runs, so a form gets all of a command’s problems in one response. A filter that fails stops the chain, and the next filter doesn’t run.

Data annotations have a catch for forms. Arc reports their member as the C# property name, Name, and the form matches members exactly against the field’s name, name. A data-annotation failure from the server therefore doesn’t appear under the field, and in the cratis template’s dialog it doesn’t appear at all: the request returns 400 and the dialog stays open without a message. The same goes for a validation result you build yourself with nameof(Name), because Arc sends members you name as written (Cratis/Arc#2945). Concept and command validators report name.

Each result has a severity. Errors block by default, and warnings and information don’t. In .NET, a caller can move that line with the X-Allowed-Severity header, and it moves both ways: a header value of 3 removes error results too, so a command whose only problems are errors runs, unless the command carries [BlockOnValidationSeverity], which sets a floor the header can’t lower. Arc for TypeScript accepts the header too but caps it at warning, so errors always block there. A validator therefore holds for callers that send the request the way the form does. A rule that has to hold for every caller belongs where the change is stored. For the author name, that’s Chronicle.

Every command also gets a /validate route, which a form can call before the user confirms. It runs authorization and validation and stops. Provide() and Handle() don’t run, nothing is appended, and so no constraint is checked. Posting a taken name to the cratis template’s /validate route returns 200 with isValid: true. A passing check reserves nothing, and the real call can still fail.

Some rules need data the command has to fetch first. Provide() runs after validation, fetches or creates what Handle() needs, and can stop the command with a validation result instead. Arc’s own example is a loan assessment that needs a credit score:

[Command]
public record AssessLoan(LoanId LoanId, ApplicantId Applicant)
{
public Result<CreditScore, ValidationResult> Provide(ICreditBureau bureau)
{
var score = bureau.GetScore(Applicant);
return score is null
? ValidationResult.Error("No credit history", ["applicant"])
: score;
}
public LoanAssessment Handle(CreditScore creditScore) => new(LoanId, creditScore);
}

The member is the string applicant in every tab, because that’s the field name the form matches.

Handle() can reject too, when the decision itself finds the problem. In C# it returns Result<AuthorRegistered, ValidationResult>, and with the Chronicle integration the event is appended only when the result is a success. In Arc for Kotlin and Java, handle can return a ValidationResult, on its own or as the value an ArcOneOf selects, and Arc treats it as a rejection. It can also throw an exception that implements ValidationFailure, and Arc turns that into validation results without exposing the exception’s message. Arc for TypeScript returns rejected(...) for a 400 or denied(...) for a 403. Any other exception is a 500. In .NET, a result from Provide() goes through the severity filter, and a ValidationResult returned from Handle() doesn’t.

A rule about current state reads a read model, and Arc can hand one to a validator, Provide() or Handle(), found by the command’s key. What arrives is a snapshot. Another request can append between the read and the write, so two registrations of the same name can both read “not taken” and both pass. The uniqueness rule isn’t a validator for that reason.

In .NET, [ProtectedDecision] on a command changes what a DecisionRead<T> parameter in Provide() or Handle() means. The read is enrolled in the command’s Chronicle unit of work, and if an event that would change it is appended for the same key before the command commits, the command fails with a concurrencyViolation result and appends nothing. Arc doesn’t retry it. A protected command refuses validators that take constructor dependencies, because their reads aren’t part of the guard, and only the projection shapes Chronicle admits for decision reads can be protected. Rules that hold when you write covers decision reads and their limits. Arc for Kotlin and Java and Arc for TypeScript have no protected decisions, so a read model there is always a snapshot.

Chronicle checks every append, whichever client or command sends it.

The first check is the shape. The kernel validates the event’s content against the JSON schema registered for that event type and generation. A mismatch comes back as a constraint violation of type Schema, named SchemaValidation, with the path of the property in its details. Through an Arc command it reaches the client as a 400 with reason: "constraintViolation", reasonDetail: "SchemaValidation", no member, and a message that names the property. A null in an event property that isn’t nullable gets past every Arc layer when no validator checks it, and the append refuses it here. The schema holds properties and their types, so the 200-character limit, which lives in a validator, isn’t part of it. A client also can’t change the schema of a generation that’s already stored. Registering an incompatible one fails, and a new shape needs a new generation, as Year two describes. The Elixir client builds the schema from the struct’s field defaults, and a field without a typed default can make an append fail with expected string but got number.

The second is the constraints. The uniqueness rule is one attribute on the event:

[EventType]
public record AuthorRegistered(
[property: Unique(name: "UniqueAuthorName", message: "That name is already registered.")] AuthorName Name);
[Command]
public record RegisterAuthor(AuthorId Id, AuthorName Name)
{
public AuthorRegistered Handle() => new(Name);
}

The constraints you declare come in two kinds, a unique property and one event of a type per event source, and [RemoveConstraint] releases a value when an author is removed. A rule that isn’t about uniqueness can’t be a constraint. Rules that hold when you write covers both kinds, narrower scopes and the concurrency scopes that guard the facts behind a single decision.

The kernel’s own message names the event type and the member and leaves the value out, because a value worth keeping unique usually identifies someone. The .NET, Kotlin, Java and TypeScript clients replace it with the declared message, and fill {PropertyName} and {PropertyValue} from the violation’s details. The Elixir client accepts message:, and its append still returns the kernel’s message, inside {:error, {:constraint_violations, violations}}.

Two more checks need nothing on the event. An append into an event stream that’s been closed fails with a StreamClosed violation, and closing a stream is a call in the .NET client. An append that carries a concurrency scope fails when matching events were appended after the read it was decided on, and by default the first append into a scope isn’t checked at all.

Every layer on the server answers with the same command result. Authorization failures get 403, validation results of any kind get 400, and exceptions get 500, and when a result carries more than one, 403 wins over 400 and 400 over 500. The taken name comes back as a 400 with this body, trimmed to the parts that matter here:

{
"isSuccess": false,
"isAuthorized": true,
"isValid": false,
"validationResults": [
{
"severity": 3,
"message": "That name is already registered.",
"members": ["name"],
"reason": "constraintViolation",
"reasonDetail": "UniqueAuthorName"
}
]
}

The member is the constrained property’s name in camel case, and reasonDetail is the constraint’s name. All three Arc implementations map a violation this way. The message is a diagnostic that’s free to change, so code that has to know which rule failed compares reason and reasonDetail. The other reasons are rule for an authored rule, concurrencyViolation, validatorFailed when a validator threw, dependencyUnavailable when something the command needed couldn’t be resolved, and malformedRequest for a body that couldn’t be read.

A form shows a result under a field when one of its members is the field’s name, or the name followed by a dot and more. For the author name that works because RegisterAuthor.Name and AuthorRegistered.Name share a name, and Cratis Components, the React layer follows a taken name from the append back to that field, step by step. Plenty of rejections have no field to go to.

A diagram titled Which rejections reach a field. Two columns. The left column, headed Lands under a field: browser rule, name; validators, name; unique property, name; a result you build, if its member is name. The right column, headed No field to land on: authorization, 403; schema, no member; unique event type; concurrency, retry; exception, 500; annotations and nameof, member Name, not name. A band along the bottom reads: Branch on reason and reasonDetail, not the message.

Which rejections find a field, and which need handling of their own.

CommandForm shows field errors, and a form-level panel for exceptions with a generic sentence in place of the exception text. The rest reach the form’s onValidationFailure callback, or a toast through toastCommandResult, which lists the validation messages. A concurrency violation means the state moved on, so the useful response is to read again and resubmit.

Most layers have a test that reaches them, and Testing with Arc and Chronicle has the mechanics. In .NET and on the JVM a concept validator is a plain object, so a spec can call it with a value. A command scenario runs authorization, validation, Provide() and Handle() in one process. In .NET, ShouldHaveValidationErrors() refuses to pass when the only rejection was a dependency that couldn’t be resolved, ShouldHaveValidationErrorBecauseOf(...) pins the reason, and ShouldHaveConstraintViolationFor("UniqueAuthorName") pins the constraint by name on a command result. In Arc for TypeScript, scenario.validate(...) proves a rule answers on /validate. A constraint spec puts the first author in the event log, which is where the constraint looks, and a seeded read model leaves the constraint unexercised.

Rule Where it lives Why there
A name isn’t empty and has at most 200 characters AuthorNameValidator on the concept, copied to the browser by the .NET and TypeScript generators It’s true of the value wherever it appears
Only a signed-in user registers an author Authorization on RegisterAuthor, which the snippets above leave out It’s decided before anything reads the values
A name is unique within the organization [Unique] on AuthorRegistered, checked in the tenant’s namespace Only the append sees every writer

This part was checked against Arc v22.41.1 and Chronicle v19.25.1; other parts retain the versions they were checked against.

To test the name rule, put the first registration in the event log and try the duplicate through the command. A seeded read model won’t exercise the constraint.