· 18 min read
The Application Layer, the Transaction, and a Working API
code · kisztof/ddd-hexagonal-symfony-lending @ part-2 →
Series: DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 2
- 1. The Domain Model, and the Only Rule That Matters
- 2. The Application Layer, the Transaction, and a Working API
A Loan aggregate that nothing can call is a very well tested class. The model from the previous part enforces every invariant it owns, and it has exactly one way in: a PHP constructor, from a test. There is no HTTP, no console, no transaction, and no answer to the question of what happens when two requests disburse the same loan at the same millisecond.
This part closes that. By the end you can start a PHP server, POST a loan, POST its disbursement, get 409 with an application/problem+json body on the second attempt, POST a repayment, get a 422 naming the outstanding balance when the repayment is too large, and run the same disbursement from bin/console with the same rules and the same failure. Nothing in Domain/ changes to make that happen, and Application/ still imports no framework at all.
This article covers the wiring between the model and the outside world. You'll see why a use case is an inbound port rather than a service, what a command bus actually buys you and what it charges, how doctrine_transaction moves the transaction boundary from every handler into one line of YAML, why the aggregate's own guard against double disbursement is worthless across two processes and what closes it, how #[MapRequestPayload] keeps validation at the edge without letting it impersonate an invariant, and how one kernel listener turns domain exceptions into RFC 9457 responses so no controller ever writes a catch.

Two ways in, one opening, and a latch that admits the first arrival. The core is untouched by any of it.
One POST travels this path, and every box after the first is something the domain never learns about:
The application layer holds use cases, not services#
The domain owns driven ports: interfaces the model needs satisfied, like LoanRepository. Driving ports point the other way. They are what the outside world is allowed to ask for.
A driving port is a use case. DisburseLoan. RecordRepayment. Not LoanService, which is a bag with methods and no boundary, and not LoanManager, which is the same bag with a worse name. One class, one intent, one transaction.
Here is the whole of one:
// src/Application/Command/DisburseLoan.php
final readonly class DisburseLoan
{
public function __construct(public string $loanId) {}
}// src/Application/Handler/DisburseLoanHandler.php
final readonly class DisburseLoanHandler
{
public function __construct(
private LoanRepository $loans,
private ClockInterface $clock,
) {}
public function __invoke(DisburseLoan $command): void
{
$loan = $this->loans->get(LoanId::fromString($command->loanId));
$loan->disburse(\DateTimeImmutable::createFromInterface($this->clock->now()));
$this->loans->save($loan);
}
}Three details in nine lines are worth naming.
The command carries string $loanId, not LoanId. The command is the boundary, and boundaries speak in primitives because that is what arrives over HTTP, over a queue, and out of a JSON column. Converting string to LoanId is the handler's job, and it is where a malformed identifier becomes a typed failure instead of a TypeError three layers down.
The clock is injected. new \DateTimeImmutable() inside a handler is an untestable dependency on the machine's wall clock, and it is the reason so many "flaky" date tests exist. Psr\Clock\ClockInterface is a driven port that PSR already defined and Symfony already implements, so you get MockClock in tests for free and you get to skip writing a ClockInterface of your own.
There is no MessageBusInterface, no EntityManagerInterface, no attribute, and no use Symfony\... line anywhere in DisburseLoanHandler.php. That is not an aesthetic choice.
A command bus is not required, and pretending otherwise costs you#
The most common thing written about CQRS in PHP is that you dispatch commands on a bus. Nothing in domain-driven design requires one. (new DisburseLoanHandler($loans, $clock))(new DisburseLoan($id)) is a complete, correct, fully in-scope implementation of this use case. It has one stack frame instead of eleven. When it throws, the trace points at your code rather than at MiddlewareStack::next().
That version is genuinely better for a small application, and if you stop reading here and delete the bus, you have not misunderstood anything.
So the handlers in this codebase are registered as handlers by the container, not by themselves, in config/services.yaml:
# config/services.yaml
App\Application\Handler\:
resource: '../src/Application/Handler/'
tags:
- { name: messenger.message_handler, bus: command.bus }The idiomatic Symfony alternative is #[AsMessageHandler] on the class. It is less YAML and it is the documented path. It also puts use Symfony\Component\Messenger\Attribute\AsMessageHandler at the top of a file in Application/, and once that import exists, "you can call the handler directly" becomes a claim rather than a fact. The tag keeps it a fact.
This is enforced, not remembered. The dependency rule from the previous part is a phparkitect check, and it now covers the second layer:
$applicationKnowsOnlyTheDomain = Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\Application'))
->should(new NotDependsOnTheseNamespaces([
'App\Infrastructure',
'Doctrine',
'Symfony',
]))
->because('use cases talk to ports, never to the adapters behind them, and a handler that imports the bus can no longer be called without one');Adding the attribute back and running the check produces this, which is the only proof that a fitness function is doing anything:
App\Application\Handler\DisburseLoanHandler has 1 violation
should not depend on Symfony because use cases talk to ports, never to the
adapters behind them, and a handler that imports the bus can no longer be
called without oneThe cost of the tag is real and small: one YAML block, and no editor autocompletion telling you a class is a handler. The benefit is a test that cannot lie:
public function testTheHandlerNeedsNoBusToRun(): void
{
$handler = new DisburseLoanHandler($this->loans, new MockClock('2026-01-15 10:00:00'));
$handler(new DisburseLoan($this->approvedLoanId));
self::assertSame(LoanStatus::Disbursed, $this->loans->get(...)->status);
}What the bus buys, once you want it, is a place to put things that apply to every use case without every use case knowing: the transaction, a retry policy, an audit log, routing to a worker. That is worth eleven stack frames when the list is longer than one item. This codebase has one item on the list, and the transaction is a good enough reason on its own.
The transaction boundary belongs in configuration#
Put a transaction in a handler and you have made every future handler's correctness depend on someone remembering. Put it in a controller and you have made it depend on someone remembering, in a class that also parses JSON.
The boundary is a property of "a use case ran", so it lives where use cases run, in config/packages/messenger.yaml:
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- doctrine_transaction
query.bus: ~
event.bus:
default_middleware:
enabled: true
allow_no_handlers: trueThree buses, because the three have different needs. Commands change state and take the transaction. Queries take neither a transaction nor a transport. Events allow zero handlers, since publishing something nobody listens to yet is normal and should not be an error.
Middleware you list is appended after the default stack, so the compiled order on the command bus is this:
add_default_stamps
add_bus_name_stamp
reject_redelivered_message
dispatch_after_current_bus
decode_failed_message
failed_message_processing
command.bus.middleware.doctrine_transaction
command.bus.middleware.send_message
command.bus.middleware.handle_messagedoctrine_transaction sits immediately before handle_message, which is what you want: the transaction wraps the handler and nothing else. Serialisation, routing decisions and retry bookkeeping all happen outside it, so a slow deserialisation never holds a row lock.
There is a claim buried in that YAML that deserves checking rather than believing. The primary repository in this codebase is DBAL. It writes raw SQL through an autowired Doctrine\DBAL\Connection. doctrine_transaction wraps the EntityManager's transaction. Are those the same connection?
You can settle it in about a minute. Drop a temporary probe into the adapter:
private function insert(Loan $loan): void
{
dump('tx active: '.var_export($this->connection->isTransactionActive(), true));
// ...
}With the middleware configured, dispatching through the bus prints tx active: true. Comment out the two YAML lines and the same dispatch prints tx active: false. Same connection, and the middleware covers the DBAL adapter even though no entity is involved. Delete the probe afterwards.
Do not skip that check on a codebase you did not write. A second connection configured for reporting, a dbal.connections block with two entries, or an adapter that calls DriverManager::getConnection() itself will all silently give you a handler that looks transactional and is not.
An aggregate's guard is a single-process guarantee#
The Loan aggregate already refuses to be disbursed twice:
public function disburse(\DateTimeImmutable $on): void
{
if (LoanStatus::Approved !== $this->status) {
throw LoanAlreadyDisbursed::withId($this->id);
}
// ...
}That check is correct and it is not enough. It reads state that was loaded, in this process, at some point in the past. Two PHP-FPM workers each load the same approved loan, each see Approved, each pass the guard, and each write. The money moves twice.
The transaction alone does not close it either. Under PostgreSQL's default READ COMMITTED, a read followed by a write in two concurrent transactions is not serialised. Both SELECTs succeed, both UPDATEs succeed, and the second overwrites the first. You get one row and two disbursements, which is the worst combination available: the database looks consistent and the ledger does not.
What closes it is a version column and a conditional update:
private function update(Loan $loan, int $expectedVersion): void
{
$affected = $this->connection->executeStatement(
'UPDATE loans SET status = :status, outstanding_minor = :outstanding_minor,
disbursed_at = :disbursed_at, schedule = :schedule,
version = version + 1
WHERE id = :id AND version = :expected_version',
[...$this->columns($loan), 'expected_version' => $expectedVersion],
);
if (0 === $affected) {
throw ConcurrentModification::ofLoan($loan->id);
}
}Zero rows affected means the row moved under you. The database, not the application, decided who won, and it decided atomically.
The interesting question is where $expectedVersion lives. Putting a version property on the Loan aggregate is the obvious answer and it is wrong: optimistic locking is a persistence concern, and an aggregate that carries a row version has persistence in it. So the version stays in the adapter, keyed by the instance it came from:
/** @var \WeakMap<Loan, int> */
private \WeakMap $loadedVersions;
public function get(LoanId $id): Loan
{
// ...
$loan = $this->toLoan($row);
$this->loadedVersions[$loan] = (int) $row['version'];
return $loan;
}A \WeakMap rather than an array, for a reason that only shows up in production. An array keyed by loan id goes stale: a long-running Messenger worker handles a message, caches version 3, handles another message an hour later, reloads the row, and now holds two versions of the truth. An array keyed by spl_object_id leaks, because ids are reused after garbage collection. A \WeakMap entry dies with the aggregate it describes. No staleness, no growth, no bookkeeping.
Two tests against two real PostgreSQL connections prove the behaviour rather than describing it. The first asserts that the second writer gets ConcurrentModification. The second is the one that matters:
$seenByA->disburse(new \DateTimeImmutable('2026-01-15 10:00:00'));
$repositoryA->save($seenByA);
$seenByB->disburse(new \DateTimeImmutable('2026-03-01 10:00:00'));
try {
$repositoryB->save($seenByB);
} catch (ConcurrentModification) {
}
$stored = $repositoryA->get($id);
self::assertSame('2026-01-15', $stored->disbursedAt?->format('Y-m-d'));
self::assertSame(2, $this->first->fetchOne('SELECT version FROM loans WHERE id = ?', [$id->value]));The row keeps January's date and lands on version 2, not 3. The loser's write is gone, not merged.
Two scope statements go with this, and both matter more than the mechanism does.
This protects the row, not the disbursement. If your handler moves money through an external payment rail and then saves, the lock arrives too late and you have a real payment with no record of it. Order the effects so the database write is the thing that commits the decision, and treat the rail as a separate delivery problem with its own idempotency key. I've written about the producer side of that gap separately, and it does not fit inside a version column.
It also does not survive being deployed on the ORM adapter as written. Doctrine ORM has its own optimistic locking through #[Version], which needs a mapped property on the entity. The three interchangeable adapters from the previous part are still interchangeable for the contract tests; they are not equally safe under concurrency, and pretending otherwise would be the kind of claim this series is trying to avoid.
HTTP is an adapter, and it stays thin#
With the use cases in place, a controller has almost nothing left to do:
final readonly class RecordRepaymentController
{
public function __construct(private MessageBusInterface $commandBus) {}
#[Route('/loans/{id}/repayments', methods: ['POST'])]
public function __invoke(string $id, #[MapRequestPayload] RecordRepaymentRequest $request): Response
{
$this->commandBus->dispatch(new RecordRepayment($id, $request->amountMinor, $request->currency->value));
return new Response(status: Response::HTTP_NO_CONTENT);
}
}#[MapRequestPayload] deserialises the JSON body into a typed DTO and runs the Validator over it before your code runs. A malformed body never reaches the handler.
The DTO is deliberately dull:
final readonly class RecordRepaymentRequest
{
public function __construct(
#[Assert\Positive]
public int $amountMinor = 0,
public Currency $currency = Currency::PLN,
) {}
}Currency is typed as the domain's backed enum, so the serialiser rejects "XYZ" on its own and you need no Assert\Choice listing the valid codes in a second place. Adding a Currency::codes() method to satisfy a constraint would push a presentation concern back into the domain, which is exactly the leak the previous part spent a section closing.
The line to hold here: the Validator checks that the request is well formed, never that the operation is allowed. Assert\Positive on amountMinor says "this is not a number I can work with". "This repayment exceeds the outstanding balance" is an invariant, it depends on the aggregate's state, and it belongs in Loan::recordRepayment(). A constraint that queries the database to decide whether something is permitted has moved your business rules into an annotation, where nothing can test them without a kernel.
There is an anti-pattern hiding behind all of this, and it is worth naming plainly. If every use case exists because an HTTP endpoint needed one, and each is dispatched from exactly one controller and nowhere else, you have not built an application layer. You have built a controller with extra steps, plus a DTO, plus a dispatch, plus a stack trace nobody enjoys reading. The layer earns its cost when a use case has more than one caller, or when the thing wrapping it is worth wrapping.
The cheapest way to prove that here is a second driving adapter:
#[AsCommand(name: 'loan:disburse', description: 'Disburse an approved loan.')]
final class DisburseLoanCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$this->commandBus->dispatch(new DisburseLoan((string) $input->getArgument('id')));
} catch (HandlerFailedException $failure) {
if (!$failure->getPrevious() instanceof DomainException) {
throw $failure;
}
$io->error($failure->getPrevious()->getMessage());
return Command::FAILURE;
}
// ...
}
}Same command, same handler, same transaction, same rules. The console adapter is fourteen lines because the use case was not shaped like a controller method.
Domain failures become status codes exactly once#
Note the getPrevious() in that console command. Messenger wraps whatever a handler throws in HandlerFailedException, so every driving adapter has to unwrap before it can recognise anything.
Doing that in each controller means a catch block per endpoint, and a status code decision duplicated per endpoint, and a divergence the first time someone adds an endpoint on a Friday. One kernel listener, ProblemDetailsListener, does it once:
#[AsEventListener]
final readonly class ProblemDetailsListener
{
public function __invoke(ExceptionEvent $event): void
{
if (null === $domain = $this->domainExceptionIn($event->getThrowable())) {
return;
}
[$status, $type, $title] = match ($domain::class) {
LoanNotFound::class => [Response::HTTP_NOT_FOUND, 'loan-not-found', 'The loan does not exist.'],
LoanAlreadyDisbursed::class => [Response::HTTP_CONFLICT, 'loan-already-disbursed', 'The loan has already been disbursed.'],
ConcurrentModification::class => [Response::HTTP_CONFLICT, 'concurrent-modification', 'The loan changed while the request was in flight.'],
default => [Response::HTTP_UNPROCESSABLE_ENTITY, 'rule-violated', 'The request is not allowed for this loan.'],
};
$event->setResponse($this->problem($status, $type, $title, $domain->getMessage()));
}
}domainExceptionIn() walks the getPrevious() chain rather than checking the top-level type, so it finds the domain exception whether the request came through the bus, through a nested bus dispatch, or directly.
The default arm is the load-bearing one. A new domain exception added six months from now gets 422 and a well-formed body without anyone touching this file. It gets the wrong specific status until someone adds an arm, and that is the right failure mode: too generic beats an unhandled 500 with a stack trace in the response.
The output is RFC 9457 Problem Details, application/problem+json, which is the closest thing to a standard error shape HTTP has, and it costs nothing over inventing your own {"error": "..."} envelope.
No API Platform here, deliberately. API Platform is a good fit when your resources map closely to your persistence and you want filtering, pagination, OpenAPI and HATEOAS generated. This API has three write endpoints, no resource-shaped reads, and commands that are not CRUD on the aggregate. The generated machinery would be scaffolding around three dispatch() calls.
The whole thing, from a terminal#
php -S 127.0.0.1:8000 -t public public/index.phpEvery line below is copied from a shell where it ran, against PostgreSQL, at part-2:
$ curl -sS -X POST localhost:8000/loans -H 'Content-Type: application/json' \
-d '{"borrowerId":"0197f3a0-2222-7000-8000-000000000002","principalMinor":100000,
"currency":"PLN","rateBasisPoints":600,"termMonths":12}'
{"id":"01a024f0-4ec1-71e0-9895-c18005f4abe2"}
$ curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
localhost:8000/loans/01a024f0-4ec1-71e0-9895-c18005f4abe2/disbursement
204
$ curl -sS -X POST localhost:8000/loans/01a024f0-4ec1-71e0-9895-c18005f4abe2/disbursement
{"type":"https://lending.example/problems/loan-already-disbursed",
"title":"The loan has already been disbursed.","status":409,
"detail":"Loan 01a024f0-4ec1-71e0-9895-c18005f4abe2 has already been disbursed."}
$ curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
localhost:8000/loans/01a024f0-4ec1-71e0-9895-c18005f4abe2/repayments \
-H 'Content-Type: application/json' -d '{"amountMinor":40000,"currency":"PLN"}'
204
$ curl -sS -X POST localhost:8000/loans/01a024f0-4ec1-71e0-9895-c18005f4abe2/repayments \
-H 'Content-Type: application/json' -d '{"amountMinor":900000,"currency":"PLN"}'
{"type":"https://lending.example/problems/rule-violated",
"title":"The request is not allowed for this loan.","status":422,
"detail":"Repayment of 900000 PLN exceeds the 66000 PLN outstanding"}That last body is the one to look at twice. 422, and a message written by the aggregate, surfaced verbatim through a listener that has never heard of repayments.
Malformed input fails at the edge instead, before any handler runs:
$ curl -sS -X POST localhost:8000/loans -H 'Content-Type: application/json' \
-d '{"borrowerId":"nope","principalMinor":-5,"currency":"PLN",
"rateBasisPoints":600,"termMonths":12}'
{"type":"https://lending.example/problems/invalid-request",
"title":"The request body failed validation.","status":422,
"detail":"One or more fields are not acceptable.",
"violations":[{"field":"borrowerId","message":"This is not a valid UUID."},
{"field":"principalMinor","message":"This value should be positive."}]}And the console adapter, running the same use case:
$ bin/console loan:disburse 01a024f0-b48a-7f5a-90c7-fc5494c14a11
[OK] Loan 01a024f0-b48a-7f5a-90c7-fc5494c14a11 disbursed.
$ echo $?
0
$ bin/console loan:disburse 01a024f0-4ec1-71e0-9895-c18005f4abe2
[ERROR] Loan 01a024f0-4ec1-71e0-9895-c18005f4abe2 has already been disbursed.
$ echo $?
1The test suite, up from 22 tests at the previous tag:
$ vendor/bin/phpunit
OK (33 tests, 97 assertions)
$ vendor/bin/phparkitect check
✅ No violations detectedWhat this API still cannot survive#
Six things this API does not do, in rough order of how likely they are to bite you.
The functional API tests do not exercise the transaction. when@test swaps the repository for the in-memory adapter and strips doctrine_transaction, because wrapping a non-transactional adapter in a Doctrine transaction proves only that PostgreSQL is reachable. So the fast suite proves the HTTP wiring, routing, serialisation, validation and error mapping. The transaction is proven by the probe above, and the lock by tests/Concurrency against real PostgreSQL. Two suites, two claims, neither pretending to be the other. If you copy this split, say so in your README, because a green suite that silently skips your transaction boundary is worse than no suite.
Those tests need $client->disableReboot(). Symfony's KernelBrowser reboots the kernel between requests, which discards the in-memory adapter's state, and the symptom is three tests failing with 404 on a resource created two lines earlier. That is not a bug in the fixture. It is the in-memory adapter honestly telling you it only exists inside one kernel.
A retry will re-run the whole use case. Nothing here is idempotent. doctrine_transaction rolls back a failed handler, and Messenger's retry then dispatches the same command again from the beginning. For a synchronous HTTP request that is fine, because the client saw the failure. Move a command onto an async transport and at-least-once delivery becomes your problem, not the bus's.
The optimistic lock produces a 409 and nothing more. A real client wants "reload and try again", and a real system wants that retried automatically for the operations where retrying is safe. Deciding which operations those are is domain work, not middleware configuration.
There are no reads. The curl transcript shows state through command responses and one direct SELECT, because the only way to see a loan right now is to load the aggregate, and serialising an aggregate to JSON is how a model with careful invariants turns back into a DTO with getters. That deserves its own answer rather than a GET bolted onto this controller.
WeakMap version tracking is per adapter instance, not per request. In this container the repository is a shared service, so a single request loading the same loan twice gets the same Loan object only if the adapter caches identity, which it does not. Two get() calls give two objects with two independent version entries, and the second save() in one request will lose to the first. Load once per use case. The transaction boundary makes that natural, which is a reasonable argument for the boundary being where it is.
The code for this part is tagged part-2. The diff from part-1, which is part-1...part-2, is exactly the application layer, the two driving adapters, the migration that adds loans.version, and the two test suites that keep its claims apart.
References#
$ related posts
8 min read
Hexagonal Architecture and Domain-Driven Design, Together
Hexagonal architecture is a rule about dependency direction, not a folder layout. What ports and adapters buy you, where DDD attaches, what it costs.
21 min read
Building Idempotent Message Handlers in Symfony Messenger
A production guide to idempotent Symfony Messenger handlers using SQS, Redis locks, PostgreSQL deduplication, and the Outbox Pattern.
10 min read
The EntityManager Is Closed: Causes, Reset, and Recovery
Why Doctrine closes the EntityManager after a failed flush, what resetManager() restores and what it does not, and how to keep workers alive.