~/blog/ddd-hexagonal-php-what-it-cost

What This Architecture Cost, Measured Rather Than Argued

What hexagonal architecture costs in Symfony 8: ports, layers and buses counted from the repository, and the thresholds below which it is not worth it. Part 6.

Krzysztof Słomka19 min readcode: kisztof/ddd-hexagonal-symfony-lending @ part-6 →

series · DDD, CQRS and Hexagonal Architecture in Symfony 86 / 6

  1. 01The Domain Model, and the Only Rule That Matters
  2. 02The Application Layer, the Transaction, and a Working API
  3. 03The Read Side Does Not Go Through the Aggregate
  4. 04Domain Events Are Not Integration Events
  5. 05Event Sourcing Behind the Port You Already Have
  6. 06What This Architecture Cost, Measured Rather Than Argued

Five parts have argued that the dependency should point inward, that the aggregate owns its invariants, that the read side goes around the model, that a domain event is not an integration event, and that a persistence swap should be an alias. Every one of those arguments came with a price attached in the same paragraph. What none of them did was add the prices up.

So here is the total, counted from the repository rather than remembered. At part-1 the application was 19 domain files and 373 lines of infrastructure. At part-5 it is 2,749 lines of src/, of which 1,616 sit in Infrastructure/, and 1,242 lines of tests. One interface of 25 lines is backed by 668 lines of adapters. Adding one endpoint costs about 100 lines across four or five files. None of that is an argument against the architecture. All of it is the invoice you hand your team lead before you start.

This article covers the measured growth of each layer across the five tags, where the lines actually accumulate and why it is not where people expect, the marginal cost of one more use case, what the architecture rule costs to run in CI and the hole it had until this week, the thresholds below which paying any of this is over-engineering, two things in this stack I would not reach for even though they fit, and how to strangle a legacy write path at the port without a rewrite.

Six vertical charcoal columns of increasing height on a dark ground, each column built from stacked slabs, the lower slabs slate blue and the upper ones amber, with a single thin horizontal cyan line crossing all six at the same level near the bottom

The cyan line is the domain. It barely moves across all six columns. Everything that grows is the part of the system that talks to something outside it.

The invoice, tag by tag#

Every number below comes from git ls-tree against the tag, not from memory and not from an estimate. Blank lines and the licence-free short files are counted the same way in every column, so the trend is honest even if the absolute totals are generous.

part-1part-2part-3part-4part-5
src/Domain19 files, 557 lines20 / 57620 / 57624 / 68925 / 733
src/Application0 / 06 / 13518 / 36519 / 39319 / 400
src/Infrastructure9 / 37316 / 72022 / 1,02329 / 1,22732 / 1,616
tests/6 / 3169 / 61411 / 88814 / 1,05816 / 1,242
migrations/2 / 433 / 694 / 1075 / 1756 / 212
Hand-written config/139 lines189198280283
Tests passing2233445159
Direct Composer requires1422222525

Two of those rows deserve a second look before anything else.

The domain grew by 176 lines in five parts. That is 32% growth while infrastructure grew 333% and tests grew 293%. The model of a consumer loan was substantially finished at part-1, and everything after it was plumbing. That is not a failure of planning. That is the shape the architecture is supposed to produce, and it is the single clearest signal that the dependency rule is holding: if Domain/ had grown in step with Infrastructure/, something in the framework would have been leaking into the model.

The hand-written config row excludes config/reference.php, which is 1,197 lines of Symfony 8 framework reference that ships with the skeleton and that nobody wrote. Counting it would have made configuration look like the dominant cost, and it would have been a lie by arithmetic.

Where the lines actually went#

Infrastructure is 59% of src/. Domain is 27%. Application is 15%.

I expected the opposite distribution before I counted, and the reason is that the literature spends most of its pages on the middle. The real split is visible in one ratio. The port Domain/Port/LoanRepository.php is 25 lines. The adapters implementing it are 212 lines of DBAL, 199 plus 121 plus 69 for the event-sourced trio, 35 for the ORM, and 32 in memory. One interface, 668 lines behind it, a ratio of about 1 to 27.

That ratio is the whole trade in one number. You are buying a 25-line surface that the rest of your system depends on, and paying for it with 668 lines that nothing depends on. Rewriting any of those 668 lines is a local decision. Rewriting the 25 is a project.

The read side has the same shape and a cheaper price. Application/Port/LoanReadModel.php is 25 lines, and Infrastructure/ReadModel/DbalLoanReadModel.php is 150. One adapter instead of four, because nobody has yet needed to answer "everything due this week" from somewhere other than PostgreSQL.

Three things did not grow at all across the five tags, and their flatness is the return on the investment. No controller ever learned SQL. No handler ever learned HTTP. No domain class ever imported Symfony\ or Doctrine\, and that last one is not a claim about discipline, it is checked on every run.

What one more endpoint costs#

The invoice above is a one-off. The number your team actually feels is the marginal one, and it is easy to measure because the repository contains two representative examples.

Recording a repayment, end to end, is four files:

Application/Command/RecordRepayment.php               15 lines
Application/Handler/RecordRepaymentHandler.php        36 lines
Infrastructure/Http/Controller/RecordRepaymentController.php   27 lines
Infrastructure/Http/Dto/RecordRepaymentRequest.php    19 lines
                                                      97 lines, 4 files

Asking which installments fall due on a date is five:

Application/Query/GetInstallmentsDue.php              14 lines
Application/Handler/Query/GetInstallmentsDueHandler.php   24 lines
Infrastructure/Http/Controller/InstallmentsDueController.php   26 lines
Infrastructure/Http/Dto/InstallmentsDueRequest.php    20 lines
Application/ReadModel/DueInstallment.php              18 lines
                                                     102 lines, 5 files

Neither total includes the aggregate method or the SQL, because you write those in any architecture. What the layering adds is the DTO, the handler, the request object and the file boundaries between them. Call it 60 to 70 lines of pure structural overhead per endpoint, spread across four or five files that a junior developer has to open in order to follow one HTTP request.

A fat controller does the same job in roughly 30 lines in one file. That version is genuinely faster to write and genuinely faster to read, right up to the fourth caller. The second caller in this codebase was the console: bin/console loan:disburse runs DisburseLoanHandler with no HTTP anywhere in the stack. The third was the scheduler. If your use case will only ever have one caller and one shape of input, the 60 lines buy you nothing, and you should not spend them.

That claim has a scope, and it is narrower than it sounds. The structural overhead pays off when a use case acquires a second driving adapter, or when its invariants outlive its current API shape. It does not pay off because the code is "cleaner", it does not pay off on a codebase that one person will maintain for six months, and it does not pay off at all if the team writes the layers and then bypasses them under deadline. An architecture that is routed around is worse than no architecture, because you paid for it twice.

The rule costs 0.10 seconds#

The dependency direction has been stated as an executable rule since part 1. phparkitect.php asserts that App\Domain may not depend on App\Application, App\Infrastructure, Doctrine, Symfony or PSR interfaces, and that App\Application may not depend on App\Infrastructure, Doctrine or Symfony.

Over 74 classes it runs in 0.10 seconds of wall clock, of which it reports 0.04 as analysis. The test suite is 59 tests and 194 assertions in 0.64 seconds. That is six times slower, on a suite this small, and both numbers are from a warm local machine rather than a cold CI runner. Since the rule is faster than the suite and fails on entirely different grounds, it goes first:

      # The architecture rule runs before the tests. A dependency pointing the wrong way is
      # a design failure, and a design failure should not wait behind a green suite.
      - run: vendor/bin/phparkitect check

      - run: vendor/bin/phpunit

Two things about .github/workflows/ci.yml are worth naming. It runs PostgreSQL 16 as a service on the same published port the local compose.yaml uses, so the DBAL, ORM and event-sourced contract tests exercise real SQL rather than a mock. And the tool is phparkitect rather than the more common deptrac, because deptrac 2.0.4 bundles a scoped nikic/php-parser that cannot parse PHP 8.5 asymmetric visibility, and the aggregate uses public private(set) on every property. The scoping makes the parser un-upgradable from outside. That is a version-specific fact with a shelf life, and it will stop being true the moment deptrac ships a newer parser.

The hole the rule had for five parts#

Both rules are written as "classes that reside in namespace X must not depend on Y". They say nothing about a class that resides somewhere else, which I had not thought about once in five parts. So I ran the command every Symfony tutorial runs first:

bin/console make:entity ProductConfiguration

It wrote src/Entity/ProductConfiguration.php and src/Repository/ProductConfigurationRepository.php. Then the rule ran against a codebase containing both of them:

$ vendor/bin/phparkitect check
analyze class set /.../ddd-hexagonal-symfony-lending/src
 76/76 [============================] 100%

✅ No violations detected

Five parts of architecture, a rule in CI, and the framework had just written a Doctrine-coupled entity and a repository extending ServiceEntityRepository straight into src/, past every guard in the file.

The generated entity is worth reading, because it is the exact inverse of everything the series argued for:

namespace App\Entity;

#[ORM\Entity(repositoryClass: ProductConfigurationRepository::class)]
class ProductConfiguration
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    private ?int $id = null;

    #[ORM\Column]
    private ?int $maxAmountMinor = null;

    public function setMaxAmountMinor(int $maxAmountMinor): static
    {
        $this->maxAmountMinor = $maxAmountMinor;
        return $this;
    }
}

Three defects in nine lines of body. Every field is nullable, so the type system stops being able to tell you that an amount is present. There is no constructor, so the class has a valid state it can never be in. And a public setter for a money field means the invariant, whatever it is, now lives in whoever calls the setter, which is nobody in particular. The generated repository is the same story: it extends a Doctrine base class, which makes it an adapter wearing the word "repository", the exact confusion part 1 spent a section separating.

The fix is a third rule, and it is four lines:

$everyClassPicksALayer = Rule::allClasses()
    ->except('App\Kernel')
    ->that(new ResideInOneOfTheseNamespaces('App'))
    ->should(new ResideInOneOfTheseNamespaces(
        'App\Domain',
        'App\Application',
        'App\Infrastructure',
    ))
    ->because('the first two rules only guard the namespaces they name, and make:entity writes to App\Entity');

With it in place, the same two generated files produce exactly 2 violations. Then I reverted the experiment, so the only file the part-6 tag changes in src/ is the rule itself.

The lesson generalises past this tool. A fitness function that only names the namespaces you already respect is a test that only covers the code you already wrote correctly. Write the rule that says where code is allowed to exist, not only the rule that says what it is allowed to import.

MakerBundle, and the other thing I would not reach for#

That experiment answers a question the series has been circling. MakerBundle is excellent at the thing it is for, which is scaffolding a Symfony application whose model is its schema. In this codebase make:entity is not a shortcut, it is a different architecture arriving through a generator, and the generated code has to be substantially rewritten before it can live in Domain/. Use make:migration freely. Use make:entity on a project where the ORM owns the model and be honest that you have chosen that.

The second one I will state without having installed it, which is a weaker claim and I want that on the record. symfony/workflow is the obvious component for a loan that moves through pending, disbursed and settled, and I would not put it there. Loan::disburse() and Loan::recordRepayment() already refuse illegal transitions, in the domain, with domain exceptions that carry the reason, and that refusal is the invariant rather than a decoration on top of it. Moving the transition table into config/packages/workflow.yaml moves it out of the layer that the architecture rule protects and into a file that the model may not read. You would gain a visualiser and lose the guarantee.

Where Workflow does earn its place is coordination across aggregates or services, which is a different problem with a different failure mode. The deep dive on Symfony's Workflow component and the saga pattern is about that problem, and nothing here contradicts it. One aggregate's state machine belongs in the aggregate. A distributed transaction across four of them does not.

I did not install the component to test this, so treat it as a design position rather than a measured result. The measured half is only the MakerBundle experiment.

Where the line actually falls#

The series has now spent five parts and roughly 2,700 lines demonstrating a pattern, so the useful question is not whether it works. It is which projects should pay for it.

Two variables decide it, and neither is team size.

Pay in fullPorts only, skip CQRSFramework CRUD is correctModel it, skip the busesPayments ledgerContent publishingReporting exportAdmin panelConsumer lending bookRules live in the schemaRules live in the conversationChanges yearlyChanges weeklyWhich projects should pay for the layers

The horizontal axis is the one people get wrong. It is not how big the domain is, it is whether a domain expert would recognise the rules from reading the schema.

The x-axis is domain complexity, and the only definition of it I trust is behavioural. Sit a domain expert in front of your database schema. If they can read the rules of the business off it, your rules live in the schema, and an ORM entity with public setters is an honest representation of your system. If they start explaining things the schema cannot show, an amortisation convention, a rounding rule that favours the borrower on the last installment, a status that is legal only on certain days, then those rules are going to live somewhere, and "somewhere" defaults to scattered across service classes.

The y-axis is change rate, and it decides the layers rather than the model. A complex domain that changes yearly needs the model and barely needs the ports, since you will not be swapping anything. A simple domain that changes weekly needs good tests and fast deploys, not an aggregate.

That framing is not new to this series, and the two earlier posts that argue it at length are DDD is not a one-size-fits-all solution and who should consider using DDD. What this repository adds is the price tag: 2,749 lines of src/, 1,242 lines of tests, 25 direct dependencies against the 14 the skeleton starts with, 101 installed packages, 41 MB of vendor/. Below the line, that is the cost of a decision you did not need to make.

The staged version is what I actually recommend, because almost nobody needs all six parts at once:

StageWhat you takeWhat it costsTake it when
1Value objects and an aggregate with real invariantsAbout 550 lines, no infrastructure changeThe schema stops explaining the rules
2A driven port and one adapter25 lines of interface, 212 of adapterYou need to test the model without a database
3Commands and handlers on a busAbout 60 lines per use caseA use case acquires a second caller
4A separate read port and read DTOsAbout 100 lines per queryA screen needs a shape the aggregate refuses
5Domain events and an async transportAbout 200 lines plus a broker to runSomething outside the transaction must react
6An event-sourced adapter389 lines and a one-way migrationHistory is the product, not a log

Stop at the stage that hurts. Stages 1 and 2 are the ones I would defend on almost any project with real rules, and stages 5 and 6 are the ones I have seen adopted for reasons that turned out to be fashion.

Strangling a legacy write path at the port#

The invoice above assumes a new application. Most of the time it is not, and the honest question is whether any of this is reachable from a five-year-old Symfony codebase with a 90-column loans table and business logic in a 2,000-line service class.

It is, and the port is the seam that makes it reachable. The sequence is not a rewrite:

  1. Write the aggregate and its value objects next to the existing code, with no persistence at all. Nothing in production calls it. This is stage 1 of the table above and it is the only step that requires real domain work.
  2. Write the port and one adapter, and have that adapter read and write the existing 90-column table. The adapter is where the ugliness goes: the columns nobody can explain, the two status fields that must stay in sync, the nullable dates. It is a translation layer, it is allowed to be tedious, and it is the only file that knows about the legacy schema.
  3. Move one use case, the highest value one with the fewest callers, from the service class into a handler behind that port. Leave every other path on the old code. Both write the same table.
  4. Delete the branch of the service class the handler replaced. Repeat with the next use case.

The property that makes this safe is that step 2 does not require the legacy schema to change. Your adapter can be a fifty-line pile of column mapping and the aggregate never learns about it. That is the same forward compatibility part 5 demonstrated in the other direction, when an event-sourced adapter slid behind a port designed before anyone thought about event sourcing.

Two failure modes to name, because both are common. The first is starting at step 3, which produces handlers that call the legacy service class and therefore inherit everything wrong with it. The second is running two write paths against one table for longer than a quarter: both are correct, both are maintained, and the second one exists only because nobody scheduled step 4. Put the deletion in the same sprint as the migration, or it does not happen.

There is one thing this sequence does not give you, and it is worth stating plainly. It does not give you a clean domain model, because the aggregate you extract in step 1 is shaped by what the legacy table can represent. You will get a better model than the service class, not the model you would have designed from nothing. That is usually the correct trade and it is never the one people expect.

Questions I ask in review#

These are the questions I actually ask when someone brings a pull request into a codebase shaped like this one. Each is a proxy for a specific failure I have watched happen.

Does anything in Domain/ import a framework namespace? If the answer requires reading the file rather than running a command, the rule is missing and the answer will be wrong within two sprints. This is the question phparkitect check exists to make boring.

Can a class under App\ sit outside the three layers? Ask it explicitly, because the obvious rules do not cover it, and one make:entity puts a Doctrine entity in src/Entity/ past every guard you thought you had.

Does this handler have a second caller, or an argument that it will get one? If the honest answer is no, the command DTO and the handler are 50 lines of ceremony around one controller method, and a controller method is the right shape.

Which port does this new interface belong to, and who calls it? An interface the domain calls goes in Domain/Port/. An interface only the application calls goes in Application/Port/. An interface that nothing outside Infrastructure/ calls is not a port, it is a class with an extra file, and it should be deleted.

Does this event carry a domain type across a transport? A Money object on a queue is a versioning problem that surfaces in six months, in production, on a message you cannot replay. Part 4 is entirely about this boundary.

What happens when this message is delivered twice? Every asynchronous handler in this repository is at-least-once, including the ones I wrote, and the consumer here still has no deduplication key. If the answer is "it would insert two rows", the fix is a unique constraint or a dedup table, and the post on idempotent Messenger handlers is the long version.

How does this get reverted? Not the code, the data. Switching the persistence alias forward is an edit and switching it back is a migration, and the same asymmetry applies to every projection you add. If nobody can answer this in one sentence, the change is bigger than the diff.

What did this cost? Not in hours. In files a new developer has to open to follow one request. If that number went up and nothing else did, the change added structure without adding capability. That is what over-engineering looks like from inside a codebase that has it.

The companion repository is tagged part-1 through part-6, with a README that maps every one of the 74 classes to its role, driving adapter or driven adapter, port or aggregate or projection, and to the part that explains it. The diff between any two tags is exactly what that part added, which was the point of building it this way rather than writing about it. The whole series is one diff, part-1...part-6.

Five parts of architecture, 176 lines of domain growth, and one rule that runs in 0.10 seconds. The architecture was never the expensive part. Deciding, correctly, that you needed it is.

$ related posts