Architect
Standards
ReferenceNormative

The operation

One class per decision, answering a contract the compiler checks — with the caller's identity supplied by the surface, never accepted from the body.

Verified 2026-08-14 · Language-independent principles; the reference expression is Java.

The use case is the only place a decision is made, and its shape should be a type rather than a habit. Everything on this page is a consequence of one question: what can the compiler hold, and what has to be checked some other way?

The contract is a type, not a convention

State the operation's shape as an interface every operation implements:

public interface UseCase<I, O> {
  O execute(I input);
}

An annotation cannot do this job. @UseCase is metadata the compiler ignores, so "every operation is invoked through a single entry point" holds only in an architecture test — discovered minutes later, at build time, by whoever ran it. As an interface the same rule is a compile error, in the editor, before any build runs.

Adopt it for what it finds, not for how it reads. Introducing the contract into an existing codebase surfaces things no test names: a second inbound surface calling an operation everyone believed only the HTTP layer reached, and commands shared by two operations that were quietly hiding different identifier types behind one record.

One argument in, one object out is what makes the contract uniform. An operation taking an identifier from the path, a payload from the body and an actor from the security context folds all three into one command record. Every awkward signature folds the same way — a sweep with no arguments takes the day it treats as today, which also makes it exercisable without freezing a clock.

The wire type is not the command

Two records, not one:

TypeLives inCarriesDeserialized
<Operation>Requestthe inbound adapteronly what a caller may stateyes
<Operation>Inputthe core, beside its operationthe identifier, the payload, the actornever

The adapter assembles the command: request.toInput(id, actor).

This is not ceremony. A single record serving as both request body and command must carry the identifier — so a client can address one resource in the path and name a different one in the body. It must also carry the actor, so a client can claim to be someone else. The usual patch for that is a rule saying the actor is a parameter, never a field — which is a workaround for the conflation, not a principle. Split the two and the actor becomes an ordinary field on a record no client can reach.

The cost is real: constraints get stated twice. The command's copy is what refuses a bad call from every caller, including ones that never touch HTTP; the request's copy is what the published API document is generated from. Both are load-bearing, so neither can be deleted — which means the drift is what you remove. Assert that the two agree on every field they share, or one will quietly publish a limit the server does not enforce.

Every operation answers

No operation returns nothing. There is no operation that knows nothing after succeeding: a state transition's answer is what changed and when — the same facts it already puts on its event — and returning them spares the caller a second read for the timestamp its next optimistic-concurrency check depends on. A bulk sweep answers with counts.

204 No Content remains perfectly legitimate: the adapter is free to discard the output. That is a decision about a status code, and it says nothing about whether the operation knows anything.

Infrastructure failure is declared, not caught

A write that loses a concurrency race is refused by the database. The core must not be the thing that recognises that refusal — catching the driver's exception type, and matching its message against index names, is the schema and the framework both leaking into the layer that is supposed to be free of them.

Split it in two:

  • The operation declares meaning. This constraint stands for that conflict.
  • The platform recognises the failure and applies the declaration.
public interface ConstraintConflicts {
  Map<String, ? extends AppError> byConstraintName();
}

Do not merge the declarations into one registry keyed by constraint name. The same constraint does not mean the same thing to everyone. Where registering a resource also enrols a derived identifier for it, a clash on the identifier's index means "that resource already exists" to the registrant — while to someone enrolling an identifier directly, the very same index means "that identifier is already taken". Both are correct. A single map has to pick one and be wrong for the other caller.

Apply it around the operation, not in the HTTP error handler. A controller advice sees HTTP handlers only, so it would give the guarantee to web callers and silently deny it to the queue consumers, CLI entry points and tool surfaces that call the very same operations.

Declining to declare must stay meaningful. An operation that declares nothing lets the failure through as the technical error it is. Translating everything into a business conflict hides real defects behind a polite answer.

Traps

  • Translating inside the persistence adapter. Frameworks that map driver exceptions to their own hierarchy typically do it as the call crosses the repository proxy. A catch inside the adapter method runs before that, sees the raw ORM exception, matches nothing — and a lost race is answered as a server error instead of a conflict. Nothing fails loudly; only a concurrency test catches it.
  • Ordering the translation inside the transaction boundary. A deferred constraint fires at commit, and the commit belongs to the transaction interceptor. Translate outside it or you run before the failure exists.
  • A rule that selects nothing. Architecture rules written against a method shape stop matching when the shape changes, and a rule matching nothing asserts nothing while still reporting green. Prefer a checker that fails on an empty selection, and re-read every shape-based rule when you change a shape.
  • Reaching for the contract to gain a dispatcher. The interface earns its place through compile-time enforcement. If the only argument for it is generic dispatch nothing currently needs, it is a command bus wearing a costume.

On this page