All posts

DTO-First: The Boundary Decides What the Domain Sees

October 20, 2026Michał Woliński21 min read
DTO-First: The Boundary Decides What the Domain Sees

DTO-first is usually written down as advice, and advice is what loses to a deadline. Everyone agrees the domain should not see a framework type, right up until the afternoon when the fastest way to ship one field is to hand the controller the entity. This is the same idea treated as something else instead: a doctrine — five rules, each one checked by something that fails the build.

The rules are old, and this post does not pretend otherwise. Ports and adapters, an anti-corruption layer, "do not serialise your entities" — the vocabulary for keeping a domain clean predates most of the frameworks that ignore it. What that vocabulary lacks is enforcement, and enforcement is the only thing the repository behind this article adds. A rule nothing checks is a preference, and preferences lose.

Enforcement has a weaker version that looks identical from the outside, and this article is as much about that as about the rules. A check that fires is not a check that has been proven: in the first version of this repository, three fixtures could be deleted at once and the gate still looked correct, because the test only asked whether something came back for a rule. That is fixed here, the fix is in the text below, and the fixtures that prove it include one that cannot live as a file at all because the formatter repairs it.

The demo is an invoice. One HTTP call with two versions of its payload, one cross-process event, one in-memory store, no database and no broker — because none of the five rules is about infrastructure. It runs on PHP 8.6.0RC3 and Symfony 8.2 in a pinned image, its suite is 55 tests and 118 assertions, and the gate prints this on every run:

DTO-first: the five rules rule1 Nothing from outside gets a type inside the domain. pass rule2 Validation happens once, at the entry, on the type. pass rule3 Every crossing has a named mapper. pass rule4 The wire never carries a domain type. pass rule5 Every cross-process contract carries a version. pass The gate is passing. A doctrine nobody enforces is a wish.

The code is at michalwolinski/dto-first-php-symfony. Where it cuts against its neighbours: the ETag post owns concurrency between two writers of one resource, the Dapr post owns where a guarantee lives once a sidecar sits between you and the store, and sudo mode owns step-up authentication. This one sits upstream of all three: which type is allowed to cross which line, and what the crossing costs.

The price is the reason to read past the rules. The doctrine's own machinery — the DTOs and the mappers — is about 0.5 microseconds per incoming payload. Validating the same payload costs 29 microseconds outside a request and 11 microseconds inside one: about sixty times the mapping it protects, or about twenty-five, depending on which process you measure. And the largest single line in that bill is not a design decision at all — it is a process-wide locale that the HTTP lifecycle happens to set and that a queue worker does not.

What makes a doctrine a doctrine

App\Architecture\DoctrineGate walks a source root, reads the files as text, and returns violations carrying a file, a line and the rule quoted. Pointed at src/ it is silent. That is the boring half.

The half that matters is the pairing in DoctrineGateTest: for each of the five rules, one test runs the gate against src/ and asserts no violations, and a second runs the same gate against tests/Fixtures/Violations/ — a directory of deliberate breakages — and asserts what comes back.

The first version of that second test asserted less than it appeared to. It checked that a violation came back for the rule, and that the first one named a file and a line. Any rule with more than one branch, though, is only half proven by that: as long as one fixture tripped one branch, the other branches could stop working silently. It was not a theory. Deleting LeakyController, OrphanRequest and LeaksTheFramework together left the whole suite green; disabling the controller half of rule 4 — the one place where the gate reads a signature rather than an import — changed nothing either.

So each rule is now pinned to the entire list of files and lines it has to report, with one fixture per shape the check claims to understand:

// tests/Architecture/DoctrineGateTest.php public static function violations(): iterable { yield 'rule4 — the aggregate on the wire, in every shape a signature can take' => ['rule4', [ 'Application/Contract/Http/LeakyResponse.php:7', 'Application/Contract/Http/NamesTheDomainDirectly.php:15', 'Infrastructure/Http/LeakyController.php:23', 'Infrastructure/Http/LeakyController.php:28', 'Infrastructure/Http/LeakyController.php:33', 'Infrastructure/Http/NullableAliasedController.php:22', ]]; } #[Test] #[DataProvider('violations')] public function theGateCatchesExactlyTheseViolations(string $rule, array $expected): void { $reported = \array_map( static fn (Violation $violation): string => \sprintf('%s:%d', $violation->file, $violation->line), $this->gateForTheViolationFixtures()->check($rule), ); $this->assertSame($expected, $reported, \sprintf('%s — %s', $rule, DoctrineGate::RULES[$rule])); }

A gate that has never failed is a gate nobody should trust, and this is the difference between "it has failed" and "every way it can fail has been made to". That is worth separating from the case where never failing is the goal. A check guarding a rare, catastrophic event — a restore, a kill switch — is at its most trustworthy when it has never fired, and the way to trust it is to force the failure on a schedule: a drill, with an owner and a date. An architectural rule is the opposite kind of check. It should fire regularly across a codebase's history, and a rule that has never fired even once is likelier to be broken than obeyed. The fixture directory removes that ambiguity.

One of those fixtures is not a file. An import with a leading backslash — use \Symfony\...; — is a rule-1 violation, and it is also something the style preset's no_leading_import_slash rewrites into compliance, so pint repairs the fixture and leaves the rule looking proven by a file that no longer breaks it. That happened here: the fixture was written, silently normalised, and noticed only because the expected-violations assertion went red. The shape is now written by the test that needs it, in a temporary tree where nothing formats it. Worth carrying to the next repository: a formatter will happily fix the thing your fixture exists to break.

One limit belongs in the same breath. The gate runs in the same command as the tests, so the doctrine is enforced exactly as far as that command runs. With no job in CI invoking the suite, the rules are not enforced — they are asserted, which is the failure this article is about, one level up.

Rule 1: nothing from outside gets a type inside the domain

The forbidden shape is the shortest path:

// forbidden — the domain now depends on the HTTP kernel final readonly class Invoice { public function __construct(public Request $request) {} }

The checked shape is the same class speaking its own language:

// src/Domain/Invoice/Invoice.php final class Invoice { private function __construct( private readonly InvoiceId $id, private readonly string $customerId, private readonly Money $amount, private readonly DateTimeImmutable $dueDate, ) { } }

The check has two halves. The first reads imports: no file under src/Domain may import from Symfony\, Doctrine\, Psr\, or either of the application's own outer layers. Built-in PHP types are not on that list, deliberately — DateTimeImmutable, DateInterval and DomainException are the language, not a framework, and pretending otherwise means writing a date class before you can write an invoice.

The second half exists because the first one was easy to walk past. A leading backslash, a grouped import spread over three lines, an alias — all legal PHP, all invisible to a search that only understood use X; on one line, and all of them the same dependency. So the import reader expands the shapes PHP allows, and a second scan looks for the dependency written out in full in a signature, with no use statement anywhere near it:

// src/Architecture/DoctrineGate.php private const array OUTSIDE_NAMESPACES = [ 'Symfony\\' => 'the framework', 'Doctrine\\' => 'an ORM', 'Psr\\' => 'a PSR interface', 'App\\Application' => 'another application layer', 'App\\Infrastructure' => 'infrastructure', ]; // The same dependencies, written in the body instead of imported at the top: // `public function __construct(public \Symfony\...\Request $request)` is the // doctrine's own forbidden example with the `use` statement left out. private const array OUTSIDE_MARKERS = [ '\\Symfony\\' => 'the framework', '\\Doctrine\\' => 'an ORM', '\\Psr\\' => 'a PSR interface', '\\App\\Application\\' => 'another application layer', '\\App\\Infrastructure\\' => 'infrastructure', ];

Rule 1 is also what makes the rest of the doctrine affordable, and the money type is the clearest case. When the domain owns Money, "12.50" cannot be four different things in four layers, because exactly one type can hold it:

// src/Domain/Invoice/Money.php final readonly class Money { private function __construct( private int $minorUnits, private string $currency, ) { if ($minorUnits <= 0) { throw new InvalidArgumentException('An invoice amount must be greater than zero.'); } if (3 !== strlen($currency)) { throw new InvalidArgumentException(\sprintf('"%s" is not a three-letter currency code.', $currency)); } } public static function fromDecimalString(string $amount, string $currency): self { // Fifteen digits, not "any number of digits". The multiply by 100 below leaves the int range // at seventeen digits, and fifteen is the margin that keeps two decimals and the largest // amount on the safe side of it: without a bound, a long enough input produces a float and a // TypeError instead of an invoice, which turns a rejected payload into a 500. if (1 !== preg_match('/^\d{1,15}(\.\d{1,2})?$/', $amount)) { throw new InvalidArgumentException(\sprintf('"%s" is not a decimal amount with at most fifteen digits and two decimals.', $amount)); } [$whole, $fraction] = array_pad(explode('.', $amount, 2), 2, '0'); return new self((int) $whole * 100 + (int) str_pad($fraction, 2, '0'), $currency); } }

Integer minor units, parsed by string surgery rather than float arithmetic: that is why 0.29 survives, and the data provider says so by name. It also carries a case whose name used to be a lie — an amount whose minor units exceed what a double can hold exactly is 90071992547409.93, because 9 007 199 254 740 993 is past 253; the value that was there before, 99999999999.99, is not: a double represents it, and multiplying it by 100 gives the right answer, so the test proved nothing about floats. The bound is tested from both sides, and the arithmetic is stated rather than implied: the largest amount it allows, an amount past it, and the seventeen digits at which the multiply by 100 genuinely leaves the int range. Fifteen is a margin, not the overflow point.

Rule 2: validation happens once, at the entry, on the type

Two shapes, and the difference is who is asking which question. A payload rule answers is this a well-formed request. An invariant answers is this a legal invoice. Collapsing them is how an aggregate ends up importing the framework validator and checking the same field at three layers:

// forbidden — the aggregate validating a payload final readonly class Invoice { #[Assert\NotBlank] public string $customerId; }

And the one place a payload constraint is allowed to live:

// src/Application/Contract/Http/V1/CreateInvoiceRequest.php final readonly class CreateInvoiceRequest { public function __construct( #[Assert\NotBlank] #[Assert\Uuid] public string $customerId = '', #[Assert\NotNull] #[Assert\Positive] public int $amountMinor = 0, #[Assert\NotBlank] #[Assert\Currency] public string $currency = '', ) { } }

The check has two halves and both are textual: no Symfony\Component\Validator import under src/Domain, and every class whose filename ends in Request.php under Application/Contract/Http must carry at least one #[Assert\…] attribute. A request DTO with no constraint is a payload nobody validated — that fixture is a class which is otherwise perfectly well behaved and connected to nothing, because a missing constraint leaves no trace anywhere else.

What the rule deliberately does not do is move invariants out of the domain. That is the version of "validate at the edge" that produces anaemic models. A blank customer is still refused inside the aggregate, by the aggregate's own exception, in the domain's own vocabulary:

// src/Domain/Invoice/Invoice.php public static function issue( InvoiceId $id, string $customerId, Money $amount, DateTimeImmutable $issuedAt, ?DateTimeImmutable $dueDate = null, ): self { if ('' === \trim($customerId)) { throw new DomainException('An invoice needs a customer.'); } $invoice = new self( $id, $customerId, $amount, $dueDate ?? $issuedAt->add(new DateInterval('P'.self::DEFAULT_PAYMENT_TERM_DAYS.'D')), ); $invoice->status = InvoiceStatus::Issued; $invoice->events[] = new InvoiceIssued($id, $amount, $issuedAt); return $invoice; }

That last constructor argument hides a third decision in the same method: the payment term is a domain policy, fourteen days unless the caller has an opinion. Version 1 of the payload has no due-date field and does not need one — the boundary omits it and the domain decides, which is why the boundary test freezes the clock instead of asserting against "now".

Rule 3: every crossing has a named mapper

The failure this rule exists for is not a wrong translation but an orphan: a boundary type that is validated, well named and connected to nothing. The fixture is exactly that, and it is the only orphan the rule reports — which took a second file. Without a mapper in the fixtures directory every contract there is an orphan, so a rule 3 that "catches a violation" would be catching all of them and proving nothing about the fixture meant to be the orphan.

The check compares fully qualified names, and that detail is not pedantry either, because this demo is the collision it protects against. Two versions of the API both call their request DTO CreateInvoiceRequest, in different namespaces. A short-name search is satisfied by the mapper for V1, so deleting the mapper for V2 leaves rule 3 passing — the check now resolves the class name from the file's own namespace, and the missing mapper is a violation. The same idea applies from the other end: the name has to end where it ends, or …\Http\Problem would be vouched for by …\Http\ProblemMapper.

The rule also caught something in its own demo that no rule was looking for. The payloads used to carry an idempotencyKey: required by the DTO, copied into the command by both mappers, validated, and read by nobody — two requests with the same key produced two invoices. That is the orphan of rule 3 one level down, at the level of fields rather than types, and it is gone rather than documented as a known gap. A doctrine about what is allowed to cross a boundary cannot keep a field that crosses and goes nowhere.

Part of the reason for the rule is testability, and part of it is where the knowledge is allowed to live:

// src/Application/Mapper/Http/InvoiceResponseMapper.php final readonly class InvoiceResponseMapper { public function map(Invoice $invoice): InvoiceResponse { return new InvoiceResponse( id: $invoice->id()->toString(), customerId: $invoice->customerId(), amountMinor: $invoice->amount()->minorUnits(), currency: $invoice->amount()->currency(), status: $invoice->status()->value, dueDate: $invoice->dueDate()->format(DATE_ATOM), ); } }

An InvoiceResponse::fromInvoice() shaped like this would be shorter and would put the knowledge of both types inside the DTO, where rule 3 cannot see it and no test has to name it. The mapper is a named thing with one direction and a test, and the difference is not style: it is whether the translation has an owner.

The honest limit of the check belongs here rather than in a footnote. It is a search over the mappers' source, not a call graph, so a mapper that mentioned a class only in a docblock would satisfy it. Rule 3 makes the translation named; it does not prove the name is called.

Rule 4: the wire never carries a domain type

The forbidden shape is the response that is the aggregate:

// forbidden — every refactor inside the domain is a breaking change on the wire final readonly class InvoiceResponse { public function __construct(public Invoice $invoice) {} }

"The wire" means the types that leave the process: Contract/Http and Contract/Message. It does not mean every boundary, and that exception is deliberate. The command stays inside the process and is supposed to carry validated domain values, because the alternative is every layer growing its own Money — which is how a codebase ends up with four ways to say 12.50:

// src/Application/Contract/Command/CreateInvoiceCommand.php final readonly class CreateInvoiceCommand { public function __construct( public string $customerId, public Money $amount, public ?DateTimeImmutable $dueDate, ) { } }

Three checks enforce the rule, and the third is the only place in the gate where reading text was not enough. The first is an import scan of the two wire directories. The second reads those same files for the domain type written out in full, because public \App\Domain\Invoice\Invoice $invoice is the forbidden response with the use statement left out — and rule 1 had that blind spot before rule 4 did. The third inspects controller actions, where a domain type can leave through a return type without ever being imported into a response class. The shapes a signature can take are the subject of their own fixtures:

// tests/Fixtures/Violations/Infrastructure/Http/LeakyController.php public function show(): Invoice { throw new LogicException('never executed: the gate reads this file, it does not run it'); } public function store(#[Assert\NotBlank(message: 'never executed')] string $customerId): Invoice { throw new LogicException('never executed: the gate reads this file, it does not run it'); } public static function all(): Invoice { throw new LogicException('never executed: the gate reads this file, it does not run it'); }

The second method in that file is there because an attribute with arguments puts parentheses inside the parameter list, and a search that assumes the first ) closes that list runs past it and finds nothing. The third is public static function, which a search anchored on public function walks straight past — and which is why the search reads the modifiers and skips the private ones, since a private helper returning the aggregate is not a leak at all. Two more shapes live next door: ?Invoice, and a domain type the file renamed with as, both of which have to be resolved through the file's own imports before anything can be said about them. A third pair cannot live as fixtures at all — a leading backslash on an import, and a return type written out in full — because the style preset rewrites one into compliance and the other into the alias the file already has. Those two are written instead by the tests that need them, in a temporary tree.

What the rule cannot see is the value. return $this->json($invoice); hands the aggregate to the client without naming a domain type in any signature, and no amount of text reading will find it. That is the honest boundary of this check, and it is the most likely way a real application breaks rule 4 while the gate stays green.

The outbound direction has a test worth more than the rule it guards, and it had to move to earn that. The response carries exactly six fields:

// tests/Boundary/InvoiceBoundaryTest.php $this->assertSame( self::RESPONSE_FIELDS, \array_keys($body), 'adding a method to InvoiceResponse must not be able to add a field to the response by accident', );

It is asserted on the HTTP response and not on the DTO, and the difference is not academic. The same assertion used to run at the mapper level, against json_encode of the DTO — and it passed while the promise was already false, because the controller serialises through Symfony, which picks up getter methods as well as properties. Adding a method to InvoiceResponse was enough to put a new field on the wire with every test green. A promise about the wire has to be tested on the wire.

Rule 5: every cross-process contract carries a version

The domain event has no version, because it never leaves the process. The message does, because a consumer has to be able to tell which shape it received without guessing from the presence of fields:

// src/Application/Contract/Message/InvoiceIssuedMessage.php final readonly class InvoiceIssuedMessage { public const int VERSION = 1; public function __construct( public int $version, public string $invoiceId, public int $amountMinor, public string $currency, public string $occurredAt, ) { } }

The check is the shortest in the gate: every class under Contract/Message must contain const int VERSION. What the rule buys is narrower than "the consumer can tell which shape it received", and the tests are written to show exactly where the line is. A payload that adds a field keeps the old shape readable, and the version is visible on the object that comes back:

// tests/Boundary/MessageContractTest.php $message = $serializer->deserialize($fromTheFuture, InvoiceIssuedMessage::class, 'json'); $this->assertGreaterThan(InvoiceIssuedMessage::VERSION, $message->version); $this->assertSame('9f1c4a7e-1b2d-4f3a-8c5e-6d7b8a9c0d1e', $message->invoiceId, 'the fields this consumer knows are still readable');

A payload that renames one never gets that far, and that is the point the second test makes:

// tests/Boundary/MessageContractTest.php // `amount` where version 1 says `amountMinor`. The version is right there in the JSON and // nothing in this demo gets to read it: the serializer fails on the missing constructor // argument first. A consumer that had to answer "unsupported version 2" instead of this // would read the version out of the envelope before deserialising — which is the sentence // this test exists to keep honest. $this->expectException(MissingConstructorArgumentsException::class); $serializer->deserialize(self::BREAKING_V2, InvoiceIssuedMessage::class, 'json');

So the version field protects the changes that did not need protecting. It makes an additive change visible, and against a breaking change it is decoration — a test asserting the version would have failed on a payload the serializer cannot even build. The fix is not another rule: it is reading the version out of the envelope before the payload is deserialised, deciding there, and only then handing the body to the serializer. That is a consumer's decision about its own compatibility policy, this demo has no consumer to make it, and the limit is pinned by a test rather than left as an assumption.

The gate reads text, not the AST

DoctrineGate uses file contents and regular expressions. That is a trade rather than an oversight, and one argument in its favour is easy to miss: nikic/php-parser is already in the dependency tree, but only as a development dependency of PHPUnit, while DoctrineGate lives in src/. Building the gate on the AST would move a parser into the production path of an application that otherwise needs nothing of the sort. Reading text keeps the gate dependency-free and short enough to audit in one sitting — which matters, because a gate nobody reads is a gate somebody disables.

What it cannot see is values and semantics. Imports, return types and attribute names are visible; return $this->json($invoice) is not, and a class that obeys all five rules while leaking its implementation through a badly chosen field name is invisible. The gate says as much in its own docblock instead of implying completeness. Its job is to make the rules non-optional, not to make them sufficient.

The design pays one visible price for being a text reader, and the same feature is what makes it testable: the constructor takes a source root. Pointed at src/ it must stay silent; pointed at a temporary tree or at tests/Fixtures/Violations/ it must speak. Without that seam, proving the gate catches something would mean keeping a broken copy of the repository next to the good one.

If the requirement is more than a smoke detector, the tools predate this one and do more: Deptrac and PHPArkitect both let you declare layers and allowed dependencies in configuration and fail a build on a breach. Reach for them when the question is whether the architecture holds. This gate answers a smaller one — whether these five sentences are still true — and it exists because the smaller question is asked more often.

The price of the doctrine, measured

Everything below is ns/op for the work of one incoming payload, measured inside the pinned image on PHP 8.6.0RC3, with inputs built at runtime because a benchmark with literal arguments measures the optimiser. The scripts are benchmarks/mapping-tax.php and benchmarks/validation-cost.php, both reachable through docker compose run --rm cli composer bench. Figures are medians of repeated runs on one machine, and the spread is wide enough that the ratios are the finding and the absolutes are not: one row below moved by 2.5× between runs of the same script.

First, the mapping the doctrine insists on, against the array nobody would have written by accident:

Shapens/opAgainst the doctrine
array access in place (no DTO)~12the thing the doctrine removes: no type, no validation, no version
handwritten mapper~470the doctrine's default cost — ~1.6% of the validated request below
serializer denormalise~5 300~11× the handwritten mapper
serializer serialise, the response~6 900~27× writing the same fields with json_encode (~255 ns)
handwritten mapper + validation~27 600validation is ~60× the mapping it protects
serializer denormalise + validation~34 200the same, plus the serializer's own ~6.6 µs

The second row is the answer to the objection this doctrine always gets, which is that DTOs and mappers are overhead. They are, and the overhead is under half a microsecond per payload. The cost is not the mapping. It is the validation, and the next table is where that money goes — one constraint at a time, through the same validator with the same metadata cache, exactly as validation-cost.php measures it:

What is validatedns/opAbove the floor
an object with no constraints at all~1 900the floor — the validator's own cost
NotBlank~3 400~1 500
NotNull~3 400~1 500
Regex~3 600~1 700
Positive~3 500~1 600
Date~3 800~1 900
Uuid~5 300~3 400
Currency~18 800~16 900
the whole V1 request DTO~29 500~27 600

The floor row is the one that makes the rest readable. Every row is a separate validate() call, so every row carries the validator's fixed cost; subtracting it turns the rows into the price of adding one constraint, and that is the only comparison worth making. Read that way, the six constraints a payload actually carries beyond nothing cost one and a half to three and a half microseconds each, except Currency, which costs seventeen. One constraint is more than all the others together, and it is keeping 61% of the DTO's validation bill to itself.

The reason is not the currency data, and finding that out is the most useful thing in this article. The constraint asks Symfony\Component\Intl\Currencies whether the code exists, and that read is resolved through the default locale's fallback chain. The image ships ICU's default, en_US_POSIX, which has no bundle of its own — so every call walks past a failed read before it reaches en, and a failed read is not buffered, so the miss is paid again on the next call. The last section of validation-cost.php exists to print that contrast:

Locale::getDefault() en_US_POSIX a bundle for en_US_POSIX? no Currencies::exists("EUR"), default locale 12001.31 ns/op Currencies::getName("EUR", "en") 501.55 ns/op ...and the same read once the default locale is one that has a bundle Locale::getDefault() en Currencies::exists("EUR"), default locale "en" 552.58 ns/op

Each missing locale on that chain costs about six microseconds and is never cached: en_US, also bundle-less, lands between the two, and en_US_POSIX, one step further out, at about twelve microseconds. Put the default on a locale that exists and the same read is half a microsecond — a factor of twenty-two. Pointing LANG at a regional variant is not enough to get there, because en_US is missing too.

Which of those two prices you pay depends on the process, and the framework is what decides it. LocaleListener runs on kernel.request and sets the request's default locale from kernel.default_locale; Request::setDefaultLocale() hands that same value to \Locale::setDefault() when the request has no locale of its own. Measured in the application itself, before and after a single handled request:

no kernel, no request — the process default is the image's Locale::getDefault() en_US_POSIX validate(Currency) 22990.42 ns/op validate(the whole V1 request DTO) 29187.81 ns/op after one request has been handled Locale::getDefault() en validate(Currency) 3851.01 ns/op validate(the whole V1 request DTO) 11159.35 ns/op

The same class, the same constraint, the same interpreter: 23 microseconds outside a request and 3.9 inside one for that pair, and four to six times across repeated runs — because the thing that makes the lookup cheap is process state the HTTP lifecycle happens to set. Inside a request, Currency stops being the expensive constraint at all. Outside one — a console command, a Messenger worker, a benchmark script — the process never saw a request, never got the locale, and pays four to six times more per call and eighteen microseconds more per payload. The tables at the top of this section were measured in that second world, which is the right one for a benchmark script and the wrong one for an API.

This is the part of the boundary the doctrine does not cover. Rule 2 decides where validation happens, and that decision is worth making; it says nothing about what validation costs, and the largest term in the bill turned out to be a default inherited from the base image rather than a choice about the design. Three things follow, and they are all the same thing: measure the crossing you actually run in, because a benchmark script is not a request; if a currency check is hot in a worker, give that process a locale that has a bundle (intl.default_locale=en in the image, or \Locale::setDefault() at boot — both change the ICU default for everything else in that process too) or replace Assert\Currency with a regular expression plus your own list of codes, which is cheaper and would accept XXX; and when a number looks like a property of your architecture, check whether it is a property of your container first.

Where it does not fit

The doctrine does not give you a domain model. Mappers and DTOs keep the boundary honest and say nothing about whether the aggregate inside is worth anything. A project can be perfectly DTO-first and still be an anaemic model in a clean coat: six scalars on the wire, and a bag of getters behind them. No gate in this repository can touch that, and it is the strongest argument against reading the five rules as an architecture.

What the gate cannot see is longer than the list above. It reads text, so it understands the shapes written here — and only those. A domain object handed to a serialiser inside a method body, a rule-2 violation in a request DTO that happens to be named something other than *Request.php, a dependency expressed in a way nobody anticipated: all invisible. A docblock is text like any other, and it cuts both ways — a marker inside one trips rule 1, while merely naming a class in one satisfies rule 3.

The rules have carved-outs, and they need naming. The command is exempt from rule 4 because it never leaves the process. The domain may use DateTimeImmutable and DomainException under rule 1 because they are the language, not a framework. A doctrine whose exceptions go unwritten is a doctrine that gets widened one quiet exception at a time.

Version detection is half a mechanism. Rule 5 makes an additive change visible to a consumer. Against a payload that renames a field the version is unreachable, and the test that says so is more useful than a rule that pretends otherwise. Reading the version from an envelope before deserialising is the upgrade path; until you do it, "the consumer can tell" is a claim with a caveat attached.

The error contracts cover the errors the boundary raises. Every failure this application raises leaves through one of the two problem contracts. A malformed body, or a path that matches no route, is refused by the kernel before the boundary is reached and answered by Symfony's own error path. The demo used to be worse than that in one place: a path id that was not a UUID threw inside InvoiceId::fromString() and left as a 500 with a raw InvalidArgumentException. The route now declares the same UUID v4 requirement the domain does, so a malformed id matches no route and answers 404 — the check moved to where the decision belongs rather than the handler growing a try.

The cost is not where you would guess. A team adopting this will worry about the mappers — one per crossing, and more DTOs than they have ever written. The measurement says that worry is misplaced by two orders of magnitude, and that the real cost is a validator constraint whose price depends on process state. Adopting the rules without measuring your own boundary means adopting the cost you did not look at.

And the demo is smaller than a system. No database, no broker, no authentication, no consumer — which is what makes the claims testable, and also why they are claims about a boundary rather than about an application. The store is an array, which is why the boundary tests disable the client's kernel reboot: a reboot would empty it, and that is precisely the problem a database solves in a real application.

Five sentences, one number, one caveat

Nothing from outside gets a type inside the domain; validation happens once, at the entry, on the type; every crossing has a named mapper; the wire never carries a domain type; every cross-process contract carries a version. Each of the five is checked by a gate that fails the build, and each check is pinned to the exact files and lines it has to report, so a branch that stops working turns a test red instead of staying quiet. What that buys is not cleanliness but a sentence you can no longer quietly stop believing.

What it costs is about half a microsecond of mapping per payload and about 29 microseconds of validation — or 11, if the process has handled a request, because the single largest term in that bill, a currency lookup, is four to six times cheaper inside a request than outside one for a reason that lives in a locale rather than in a design. The doctrine decides where validation happens. It does not decide what that costs.