· 15 min read
The Domain Model, and the Only Rule That Matters
code · kisztof/ddd-hexagonal-symfony-lending @ part-1 →
Series: DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 1
I have reviewed codebases that pass a hexagonal architecture review on sight. Three folders: Domain, Application, Infrastructure. Interfaces named ...RepositoryInterface. A diagram in the README with the hexagon drawn in the right colours. Then someone asks to move one aggregate off Doctrine, and the estimate comes back in weeks. The domain service type-hints EntityManagerInterface. The repository returns a QueryBuilder so the caller can "just add a filter". An entity has a setStatus() that anything can call, and four places do.
The folders were never the architecture. The architecture is which way the arrows point, and nothing in that codebase was checking.
This is not another explanation of what a port is. I have written that one, and so has everyone else. This is the wiring and the bill: what the model looks like in PHP 8.5, what it costs to keep Doctrine out of it, and which of the two ways to persist an aggregate you should actually pick.
This article covers the first block of a working lending application. I'll walk through modelling money and a Loan aggregate so the invariants have nowhere to leak, expressing the dependency rule as a build step instead of a convention, choosing between Doctrine ORM and DBAL with both costs stated, wiring the port to an adapter through Symfony's container without letting the container into the domain, and running one contract test against three adapters so the in-memory fake cannot drift from the real one.

Three adapters, one core. Every connector runs inward, and the detached plate changes nothing about the piece in the middle.
Dependency direction is a build constraint#
Hexagonal architecture makes one claim, and everything else is a consequence of it. The domain depends on nothing. Adapters depend on the domain. There is no arrow back.
The port is the detail people get wrong. LoanRepository is not an interface that Doctrine implements as a favour to the domain. It is a statement the domain makes about what it needs, written in the domain's own vocabulary, and the adapters are the ones doing the accommodating.
That distinction survives exactly as long as something enforces it. Left to code review, layering decays inside two quarters, because the violation always arrives as a small pragmatic shortcut in a pull request that is otherwise fine. So the rule is a build step:
// phparkitect.php
$domainDependsOnNothing = Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\Domain'))
->should(new NotDependsOnTheseNamespaces([
'App\Application',
'App\Infrastructure',
'Doctrine',
'Symfony',
'Psr',
]))
->because('the domain is the centre of the hexagon: everything points at it, it points at nothing');The whole file is phparkitect.php, and two things about that rule are worth knowing before you trust it. It reads dependencies, not imports: an unused use Symfony\Component\Uid\Uuid; at the top of a domain class passes, because nothing depends on it. And it fails on the line that makes the call, not on the file. I checked both by breaking it on purpose, which is the only way to know a fitness function works. A green check nobody has watched go red is decoration.
One note on tooling, since the obvious choice does not work here. Deptrac is the better-known option and it is what I reached for first. Version 2.0.4, released in November 2024, bundles a namespace-scoped copy of nikic/php-parser that cannot parse PHP 8.5 asymmetric visibility, so it fatals on the aggregate below. The scoping means you cannot upgrade the parser from outside. PHPArkitect 1.3 depends on nikic/php-parser ~5 unscoped, resolves to 5.8, and parses the file. If you are on PHP 8.3 this is not a decision you have to make.
The model: invariants live where the state does#
Money first, because a fintech reader will judge everything after it on this one type. Money is an integer count of minor units and a currency, never a float, and never a bare int passed around with a comment saying what it means:
final readonly class Money
{
private function __construct(
public int $minorUnits,
public Currency $currency,
) {
}
public static function of(int $minorUnits, Currency $currency): self
{
return new self($minorUnits, $currency);
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw CurrencyMismatch::between($this->currency, $other->currency);
}
}
}The private constructor is doing more work than it looks like. readonly stops mutation after construction; it does nothing about construction itself, so new Money(-1, ...) would still be legal from anywhere. Routing every instance through named factories means the class has one door, and every rule about what a valid Money is sits behind it. assertSameCurrency is the same idea applied to arithmetic: adding 100 PLN to 100 EUR is not a rounding problem to solve later, it is a bug to refuse now.
The aggregate, Loan.php, is where asymmetric visibility, added in PHP 8.4, changes what this code looks like. Before it, protecting a mutable property meant a private property plus a public getter, and a Loan with five of those is thirty lines of ceremony that exist to say "read yes, write no":
final class Loan
{
public private(set) LoanStatus $status;
public private(set) Money $outstanding;
public private(set) ?\DateTimeImmutable $disbursedAt;
public private(set) ?RepaymentSchedule $schedule;
private function __construct(
public readonly LoanId $id,
public readonly BorrowerId $borrowerId,
public readonly Money $principal,
public readonly InterestRate $rate,
public readonly int $termMonths,
) {
$this->status = LoanStatus::Approved;
$this->outstanding = Money::zero($principal->currency);
$this->disbursedAt = null;
$this->schedule = null;
}public private(set) reads publicly and writes privately, which is the exact shape of an aggregate's state. The property list now describes the loan rather than hiding it, and there is no getter in the file. That matters beyond aesthetics: a class with twelve getters invites a caller to assemble its own version of the truth, and the getters are what make an anemic model comfortable to write.
State changes are behaviour methods, and each one starts by refusing the calls that would break the rule it owns:
public function disburse(\DateTimeImmutable $at): void
{
if (LoanStatus::Approved !== $this->status) {
throw LoanAlreadyDisbursed::withId($this->id);
}
$totalOwed = $this->principal->add($this->rate->interestOn($this->principal, $this->termMonths));
$this->status = LoanStatus::Disbursed;
$this->disbursedAt = $at;
$this->outstanding = $totalOwed;
$this->schedule = RepaymentSchedule::equalInstallments(
$totalOwed,
$this->termMonths,
$at->modify('+1 month'),
);
}Read what is not there. No LoanService, no LoanValidator, no check performed by the caller before calling. Disbursement sets four fields together, and no sequence of public calls can leave three of them set and one stale, because there is no public way to set any of them individually. That is the whole argument for an aggregate, and it costs one keyword per property to get.
Two limits on this, both real. The status guard makes double disbursement impossible inside one process; it does nothing about two processes loading the same loan at the same time. That is a database problem, it needs a constraint rather than an if, and it belongs to the application layer. And disburse() takes the time as an argument instead of calling new \DateTimeImmutable(), which is not stylistic. A domain that reads the clock is a domain that cannot be tested at a chosen instant, and Symfony already defines the port for this in symfony/clock, so the application layer passes the time in.
Interest arithmetic is where money models quietly go wrong, so InterestRate keeps basis points and rounds explicitly:
public function interestOn(Money $principal, int $termMonths): Money
{
$numerator = $principal->minorUnits * $this->basisPoints * $termMonths;
$denominator = 10_000 * 12;
return Money::of(intdiv($numerator + intdiv($denominator, 2), $denominator), $principal->currency);
}That is integer arithmetic with half-up rounding, done once, in the one class allowed to have an opinion about it. RepaymentSchedule::equalInstallments() does the matching thing on the other side: it divides the total into equal installments and puts the remainder in the last one, so the schedule sums to the outstanding balance exactly. Off by one minor unit per loan is not a rounding artefact at portfolio scale, it is a reconciliation ticket every month.
ORM or DBAL: which fight do you want#
Doctrine ORM's default mapping is attributes, and attributes on the aggregate put Doctrine\ORM\Mapping inside Domain/. Most teams shrug at this. Judge it by the rule and not by the annoyance: it is a compile-time dependency from the centre of the hexagon on a persistence library, and the fitness function above rejects it on sight. You can keep the domain clean with XML or PHP mapping instead, which is the route this repository takes for its ORM adapter:
<entity name="App\Domain\Loan\Loan" table="loans">
<id name="id" type="loan_id" column="id">
<generator strategy="NONE"/>
</id>
<field name="status" type="string" column="status" length="16" enum-type="App\Domain\Loan\LoanStatus"/>
<field name="disbursedAt" type="datetimetz_immutable" column="disbursed_at" nullable="true"/>
<embedded name="principal" class="App\Domain\Shared\Money" column-prefix="principal_"/>
<embedded name="outstanding" class="App\Domain\Shared\Money" column-prefix="outstanding_"/>
</entity>The mapping works, and it works against private constructors and private(set) properties, because Doctrine hydrates through reflection rather than through your API. Three things it cost me in this small model. Value objects need custom DBAL types, which is three more classes and three lines in doctrine.yaml. Two Money embeddables cannot share a currency column, so the prefixes are load-bearing and the migration has four money columns rather than three. And the ORM reached into the domain for one method: LoanId needed a __toString(), because the identity map keys by string.
That last one is the leak in miniature. It is one small method, the fitness function does not catch it because __toString() imports nothing, and it exists only because of how one adapter works. Multiply that across an aggregate with collections and you get a model shaped by the persistence layer while every file in it still passes the dependency check.
The DBAL route makes the mapping explicit code instead:
public function save(Loan $loan): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO loans (
id, borrower_id, principal_minor, principal_currency, rate_bps, term_months,
status, outstanding_minor, outstanding_currency, disbursed_at, schedule
) VALUES (
:id, :borrower_id, :principal_minor, :principal_currency, :rate_bps, :term_months,
:status, :outstanding_minor, :outstanding_currency, :disbursed_at, :schedule
)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
outstanding_minor = EXCLUDED.outstanding_minor,
outstanding_currency = EXCLUDED.outstanding_currency,
disbursed_at = EXCLUDED.disbursed_at,
schedule = EXCLUDED.schedule
SQL,
[
'id' => $loan->id->value,
'principal_minor' => $loan->principal->minorUnits,
'status' => $loan->status->value,
'outstanding_minor' => $loan->outstanding->minorUnits,
'disbursed_at' => $loan->disbursedAt?->format('Y-m-d H:i:s.uP'),
'schedule' => null === $loan->schedule ? null : json_encode($this->scheduleToArray($loan->schedule), JSON_THROW_ON_ERROR),
],
);
}Notice what the upsert does not update: borrower_id, principal_minor, rate_bps, term_months. Those are readonly on the aggregate, so writing them on conflict would be code defending against a state the type system already forbids. The aggregate is written whole, in one statement, and the boundary of that statement is the boundary of the aggregate. Nothing cascades, because there is nothing to cascade through.
Here is the comparison with the costs attached rather than the advantages:
| Doctrine ORM (XML mapping) | Doctrine DBAL | |
|---|---|---|
Framework code in Domain/ | none, at the price of a second file per class | none, with nothing to violate |
| Mapping | declarative, reflection-hydrated | hand-written in the adapter, roughly 90 lines here |
| Writes | dirty checking, flush() decides | you write the aggregate, always all of it |
| Aggregate boundary | implied by mapping and cascades | the SQL statement, visibly |
| Migrations | diffable from the mapping | hand-written |
| Cost per new aggregate | low | noticeably higher |
| Surprises | identity map, lazy proxies, closed EntityManager | none of those, and no help either |
My recommendation, with its scope. For an aggregate that actually has invariants, DBAL, because the mapping cost buys an explicit write boundary and the ORM's conveniences are the exact features that blur it. For the CRUD subdomain next door, the one with a table of product configurations and no rules worth defending, the ORM, and hand-writing that mapping would be a waste of a working afternoon. This is not a claim that the ORM cannot be used with DDD. It plainly can, this repository does it, and the contract test passes on both. It is a claim that the two tools ask you to spend your effort in different places, and you should know which bill you are signing before the second aggregate.
Both adapters live in the repository at part-1, DbalLoanRepository next to OrmLoanRepository, so you can read them side by side rather than take my word for it.
Keeping Symfony out of the domain, with Symfony#
The container is the mechanism that makes an adapter swappable, so it is worth configuring deliberately rather than leaving on autowiring defaults:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Domain/'
- '../src/Kernel.php'
App\Domain\Port\LoanRepository: '@App\Infrastructure\Dbal\DbalLoanRepository'
when@test:
services:
App\Domain\Port\LoanRepository: '@App\Infrastructure\InMemory\InMemoryLoanRepository'Three lines are doing the architectural work. exclude keeps Domain/ out of the container entirely, which is correct rather than tidy: an aggregate is not a service, a value object is not a service, and a domain that is registered is a domain someone will eventually inject something into. The alias binds the port to one adapter, and repointing it at OrmLoanRepository is the entire swap. The when@test block picks the in-memory adapter without a single conditional in application code.
Symfony supplies two more driven ports before you write any. symfony/uid gives UUIDv7, which is time-ordered, so index locality holds as the table grows and inserts do not scatter across the B-tree the way UUIDv4 does. symfony/clock gives ClockInterface and MockClock, which is why the aggregate takes a \DateTimeImmutable argument rather than reading the system clock. The framework has already defined the port; the domain does not need its own.
Run bin/console lint:container after the wiring and you get a compile-time check that every constructor is satisfiable, which is a cheaper way to find a broken alias than a failing endpoint.
One contract, three adapters#
The in-memory adapter is the one that quietly ruins this design. It exists so domain tests run without a database, it starts as a faithful array-backed copy, and six months later it accepts something the real adapter rejects. Your test suite is then green about a system that does not exist.
The fix is to stop treating the fake as a test helper and start treating it as an implementation of the same contract, verified by the same suite, LoanRepositoryContract:
abstract class LoanRepositoryContract extends TestCase
{
abstract protected function repository(): LoanRepository;
public function testSavingTwiceUpdatesRatherThanDuplicates(): void
{
$repository = $this->repository();
$loan = $this->approvedLoan($repository);
$loan->disburse(new \DateTimeImmutable('2026-01-15 10:00:00'));
$repository->save($loan);
$loan->recordRepayment(Money::of(1_060_00, Currency::PLN));
$repository->save($loan);
$loaded = $repository->get($loan->id);
self::assertSame(LoanStatus::Settled, $loaded->status);
self::assertTrue($loaded->outstanding->isZero());
}
}The abstract case knows the port and nothing else. InMemoryLoanRepositoryTest, DbalLoanRepositoryTest and OrmLoanRepositoryTest each supply an adapter and inherit every assertion, so one suite runs three times. That single test above is the one that catches the most: it proves saving an aggregate twice updates it, which the array-backed fake gets right by accident and an adapter without ON CONFLICT gets wrong on the second call.
The contract also fixes the failure vocabulary. get() on an unknown id throws LoanNotFound, a domain exception, from all three. Not null from one and an exception from another. Not a Doctrine exception leaking through the port with a Doctrine class name on it. The application layer gets to handle one thing.
At part-1 the suite is 22 tests and 72 assertions, of which 7 are pure domain tests that never boot the kernel and never open a connection. The scope of that number is small on purpose: this part has no HTTP layer, no concurrency test, and no assertion about behaviour under two simultaneous writers. All three need the application layer, which this part does not have yet.
One rule#
Everything above compresses into a single question, and it is the one I ask in review when someone tells me a codebase is hexagonal:
Point at any file in Domain/. Can you delete Symfony, Doctrine and the HTTP layer from the project and still run it?
If the answer needs a qualification, the qualification is where your coupling lives, and that is the thing to go and read. Not the folders. Not the interface names. Not the diagram in the README.
The useful part of that question is that a machine can answer it, which is why the fitness function goes in before the second aggregate rather than after the eighteenth. Everything else in this series, the buses, the read models, the event store, is a variation on which adapter sits on the other side of a port. This rule is what keeps the swap cheap.
This is the first part of a longer series that builds the same lending application up in blocks: the application layer and a working HTTP API, read models, events on a queue, an event-sourced repository swapped in by one alias, and a closing accounting of what all of it cost. Each part is one tag in the example repository, so the diff between two tags is exactly what that part added. This part is part-1.
References#
- Using Hexagonal Architecture and DDD Together and Hexagonal Architecture with NestJS for the concepts this part assumes
- Domain Model, Value Object and Entity from the DDD series
- Mastering Transactions: The Power of Aggregates in DDD for the boundary argument this part implements
- How to Deal With a Closed Entity Manager in Doctrine, one of the surprises the DBAL adapter does not have
- PHP 8.4 asymmetric visibility RFC
- Symfony Clock and Symfony Uid
- PHPArkitect
$ related posts
5 min read
Using Hexagonal Architecture and DDD together for robust software design
Hexagonal architecture, also known as “ports and adapters” architecture, is a design pattern that emphasizes separation of concerns…
3 min read
How to Deal with a Closed Entity Manager in Doctrine
Understand why Doctrine's Entity Manager closes after failed transactions and how to reset it safely in Symfony applications without compromising data integrity.
3 min read
Symfony’s Workflow Component and Saga Pattern: A Comprehensive Guide to Managing Complex Business…
Explore Symfony’s Workflow Component and the Saga Pattern to manage complex business processes with ease in modern web development