DEV Community

Cover image for DDD with Symfony 7: Clean Architecture and Deptrac boundaries
Mykola Vantukh
Mykola Vantukh

Posted on

DDD with Symfony 7: Clean Architecture and Deptrac boundaries

Domain-Driven Design (DDD) in PHP projects

In recent years, I have worked on projects with high domain complexity: e-commerce platforms and ERP systems. In such systems, the traditional service-layer approach quickly reaches its limits: business logic grows uncontrollably, dependencies become tangled, and the codebase turns into a chaotic, hard-to-maintain structure.

To address this, we applied Domain-Driven Design combined with layered architecture. This approach made it possible to isolate business rules from technical concerns, maintain clear boundaries between components, and keep the project manageable over the long term.

This architecture is framework-agnostic. It fits both Symfony and Laravel, because the business logic (Domain + Application) does not depend on framework features.

Folder structure

src
├── Catalogue/                     # Catalogue Bounded Context
   ├── Application/               # Use cases (commands, queries, handlers, ports)
   ├── Contracts/                 # Published Language (DTOs, ports for other BCs)
   ├── Domain/                    # Entities, value objects, repos, exceptions
   ├── Infrastructure/            # Adapters: OHS, Doctrine, Validation
   └── Presentation/              # API controllers

├── Order/                         # Order Bounded Context
   ├── Application/               # Commands, queries, ports
   ├── Domain/                    # Pure Order model
   ├── Infrastructure/            # Doctrine, Validation
   ├── Integration/               # ACL to Catalogue
   └── Presentation/              # HTTP/CLI controllers

└── SharedKernel/                  # Cross-cutting primitives
    ├── Domain/                    # Value objects, interfaces
    ├── Http/                      # Transport-agnostic HTTP helpers
    └── Infrastructure/            # Error responders, exception mapping
Enter fullscreen mode Exit fullscreen mode

Explore full demo project: on GitHub


Architecture Layers

Each bounded context is split into four core layers.

Domain

  • Pure business logic: entities, aggregates, value objects, invariants.
  • Independent of Symfony, Doctrine, or any technical framework.
class Order {
      public static function create(string $id, Money $amountToPay, OrderLine ...$lines): self
    {
        $amountToPayValue = $amountToPay->toMinor();
        if ($amountToPayValue <= 0) {
            throw new NonPositiveOrderAmountException("Order amount must be greater than zero.");
        }

        $order = new self();
        $order->id = $id;
        $order->amountToPay = $amountToPayValue;
        $order->status = OrderStatus::PENDING->value;
        $order->createdAt = new \DateTime();

        foreach ($lines as $l) {
            $order->items[] = OrderItem::create($order, $l->productId(), $l->getName(), $l->quantity(), $l->price()->toMinor());
        }

        return $order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Application

  • Defines the system’s use cases through CommandHandler and QueryHandler.
  • A Handler is the single entry point for executing a use case:
    • CommandHandler — for state changes (create, update, delete).
    • QueryHandler — for retrieving data within the business context.
  • All commands and queries go through a Handler, which prevents bypassing business rules and ensures controlled execution paths.
  • A Handler coordinates domain objects and repositories, performs validation, and runs everything inside a transaction.
  • Because validation happens inside the Handler, data is always verified regardless of the entry point (HTTP, CLI, queue), ensuring consistent behavior across the system.
class CreateProductCommandHandler
{
    public function __construct(
        private ProductRepositoryInterface $productRepository,
        private CommandValidatorInterface $commandValidator,
        private TransactionRunnerInterface $transactionRunner
    ) {
    }

    public function __invoke(CreateProductCommand $command): Product
    {
        $this->commandValidator->assert($command);
        return $this->transactionRunner->run(function () use ($command) {
            $product = Product::create($command->id, $command->name, Money::fromMinor($command->price), $command->onHand);
            $this->productRepository->add($product);

            return $product;
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

Infrastructure

  • Technical details: Doctrine mappings, repository implementations, framework glue.
  • Implements ports defined by Application.
class ProductRepository implements ProductRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function get(string $productId): ?Product
    {
        return $this->entityManager->getRepository(Product::class)->findOneBy(['id' => $productId]);
    }

Enter fullscreen mode Exit fullscreen mode

Presentation

  • Entry points: HTTP controllers, CLI commands.
  • Receives requests, validates input, builds a Command/Query, and calls a Handler.

Bounded Contexts Architecture

Order BC
Owns orders, statuses, and reservation workflow. Source of truth for Order and OrderItem. Integrates with Catalogue only via Contracts (OHS/ACL).

Catalogue BC
Owns products and stock. Source of truth for Product and Stock. Exposes APIs for stock reservation and fulfillment.

Data & Persistence Rules

  • Foreign keys only inside one BC. Cross-BC references are stored as IDs/values, never relational FKs.
  • Clear ownership. Only the owning BC mutates its data. Others consume via Contracts or projections.
  • No cross-BC transactions. An Order transaction commits first, then calls Catalogue OHS (HTTP/queue).

This ensures strict boundaries, stable contracts, and scalable microservice extraction.


Bounded Contexts Communication

Open Host Service (OHS)

The Catalogue BC exposes its functionality via an Open Host Service (OHS) - a public API for other contexts.

  • Defined in Contracts as interfaces and DTOs (Published Language).
  • Ensures stability of external integration even if Catalogue internals change.
  • Implemented in Infrastructure/Ohs.

Contract example:

namespace App\Catalogue\Contracts\Reservation;

interface CatalogueStockReservationPort
{
    public function reserve(CatalogueReserveStockRequest $request): CatalogueReservationResult;
}
Enter fullscreen mode Exit fullscreen mode

Implementation (OHS Adapter):

namespace App\Catalogue\Infrastructure\Ohs;

use App\Catalogue\Application\Command\Handler\ReserveStockCommandHandler;
use App\Catalogue\Application\Command\ReserveStockCommand;
use App\Catalogue\Contracts\Reservation\CatalogueReservationResult;
use App\Catalogue\Contracts\Reservation\CatalogueStockReservationPort;
use App\Catalogue\Contracts\Reservation\CatalogueReserveStockRequest;

class CatalogueStockReservationService implements CatalogueStockReservationPort
{
    public function __construct(private ReserveStockCommandHandler $handler)
    {
    }

    public function reserve(CatalogueReserveStockRequest $request): CatalogueReservationResult
    {
        try {
            $command = new ReserveStockCommand($request->items);
            ($this->handler)($command);
            return CatalogueReservationResult::ok();
        } catch (\Throwable $e) {
            return CatalogueReservationResult::fail($e->getMessage());
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Anti-Corruption Layer (ACL)

On the consumer side, Order BC integrates with Catalogue via an ACL:

  • Implements its own ports (StockReservationPort, etc.).
  • Delegates to Catalogue’s OHS Contracts.
  • Adapts responses into its own model without leaking foreign domain details.

namespace App\Order\Integration\Catalogue;

use App\Catalogue\Contracts\Reservation\CatalogueReserveStockRequest;
use App\Catalogue\Contracts\Reservation\CatalogueStockReservationPort;
use App\Order\Application\Port\Dto\ReservationRequest;
use App\Order\Application\Port\Dto\ReservationResult;
use App\Order\Application\Port\StockReservationPort;

readonly class StockReservationAdapter implements StockReservationPort
{
    public function __construct(private CatalogueStockReservationPort $reservation)
    {
    }

    public function reserve(ReservationRequest $request): ReservationResult
    {
        $catalogueRequest = new CatalogueReserveStockRequest(
            array_map(fn($i) => ['product_id' => $i['product_id'], 'quantity' => $i['quantity']], $request->items),
            ['order_id' => $request->orderId]
        );
        $reservationResult = $this->reservation->reserve($catalogueRequest);
        return $reservationResult->success ? ReservationResult::ok() : ReservationResult::fail($reservationResult->reason);
    }
}
Enter fullscreen mode Exit fullscreen mode

Microservice-ready design

The Catalogue BC can be extracted into a standalone microservice with minimal changes:

  • Contracts stay stable — DTOs and ports define the published language, reused by both sides.
  • Domain & Application unchanged — business logic and use-cases move as-is.
  • Infrastructure adapters switch — OHS becomes an HTTP/queue endpoint in Catalogue; ACL in Order becomes an HTTP/queue client.

This design ensures you can scale from monolith to microservices by replacing only the transport layer, not rewriting core logic.


Deptrac: Enforcing architectural boundaries

In complex systems it’s easy to break DDD principles - for example, accidentally pulling Symfony or Doctrine into the Domain, or letting Presentation call repositories directly.
To prevent this, I use Deptrac as an automatic architectural linter.

Purpose of Deptrac

  • Protect the Domain and Application: no dependencies on Symfony, Doctrine, or infrastructure code.
  • Explicit layer boundaries: Presentation can only see Application, Application can only see Domain and Shared, and Domain depends exclusively on SharedDomain.
  • Control between bounded contexts: interaction between Order and Catalogue goes strictly through Contracts and ACL, never directly.
  • Documented rules: the deptrac.yml file serves as living documentation of the architecture.

Usage

  • Locally: vendor/bin/deptrac analyze deptrac.yml
  • In CI: every pull request is checked for boundary violations. If Presentation tries to access Domain directly, the build fails.
  • Result: the team always gets immediate feedback when someone attempts to bypass the architecture.

Full deptrac.yml: on GitHub


DDD Testing

In these projects I deliberately structure the test pyramid around business logic, keeping it isolated from frameworks and infrastructure. The main layers are:

  • Domain tests
    • Focus on invariants and business rules.
    • Executed completely isolated from the database, Symfony, or any technical dependencies.
    • Examples: an Order cannot be created with a zero amount; a Product cannot have negative stock.
public function test_create_order_ok(): void
{
        $lines = [
            ['p1', 'Product 1', 1, 500],
            ['p2', 'Product 2', 2, 500],
        ];

        $order = Order::create(
            'ord-1',
            Money::fromMinor(1500),
            ...array_map(fn($l) => $this->line(...$l), $lines),
        );

        self::assertSame('ord-1', $order->getId());
        self::assertSame(1500, $order->getAmountToPay());

        $items = [...$order->getItems()];
        self::assertCount(count($lines), $items);

        foreach ($lines as $i => [$pid, , $qty, $price]) {
            $item = $items[$i];
            self::assertSame($pid, $item->getProductId());
            self::assertSame($qty, $item->getQuantity());
            self::assertTrue(Money::fromMinor($price)->equals($item->getPrice()));
        }
}
Enter fullscreen mode Exit fullscreen mode
  • Application tests
    • Verify Handlers as use cases - the single entry points into business logic.
    • Cover: validation through the CommandValidatorInterface contract, execution within a transaction, repository interactions, and reaction to domain events or exceptions.
    • Use in-memory adapters for repositories and transactions to stay independent of infrastructure.
    • Ensure business scenarios remain consistent regardless of the runtime environment.

Framework and technical concerns (Symfony, Doctrine, transport adapters) are covered only by a thin layer of integration tests - limited to critical HTTP scenarios that verify correct request → command → response mapping.

Full tests: on GitHub

Summary

  • Pure Domain — business logic lives in clean entities and value objects, completely free from Symfony or Doctrine.
  • Application as a gateway — every use case runs through a Handler, ensuring validation, transactions, and orchestration are always consistent.
  • Clear boundaries — Bounded Contexts talk only via Contracts (OHS/ACL). No shortcuts, no leaking of internals.
  • Enforced architecture — Deptrac acts as an architectural linter, keeping the codebase aligned with DDD principles over time.
  • Layered testing — Domain and Application are tested in isolation, without frameworks, while only critical scenarios are verified end-to-end.

By combining these practices, the project stays maintainable under high domain complexity, and the architecture itself becomes part of the product’s quality and business value.


Explore full demo project: on GitHub


📬 Let’s Connect

If you’re looking for a senior developer to solve complex architecture challenges or lead critical parts of your product — let’s talk.

👉 Connect with me on LinkedIn

Top comments (6)

Collapse
 
xwero profile image
david duymelinck

While I get it is a post about DDD, there is hardly a mention of Symfony.

When I see ACL i think of access control list, so that could be confusing. As I understand it from your post is seems to be more of a transport layer than having something to do with security.

There were a few things I discovered in the repository:

  • SymfonyCommandValidator and CommandValidatorInterface are repeated in both modules. I think those can be moved to the Sharedkernel
  • Why are there xml mapping files?
  • I saw the validator config is in the config directory. Should the rules not be in the domains?
  • I also saw you only use JsonResponse in your controllers. I'm curious who you are handling html responses.

Maybe points for your next post?

Collapse
 
mykola_vantukh profile image
Mykola Vantukh • Edited

Thanks a lot for the review and remarks. My answers:
1.The project is fully built on Symfony (see GitHub). In the article I focused on DDD concepts since Domain/Application are framework-agnostic. If I had also shown more Symfony examples, the post would have become too large and harder to follow.
2.ACL – here ACL means Anti-Corruption Layer (DDD pattern), not Access Control List. I’ll update the naming to avoid confusion.
3.CommandValidator – you are right, the contract should be placed in SharedKernel\Application, with concrete implementations in each BC’s Infrastructure.
4.XML mapping – I use XML simply because it’s more convenient for me than YAML, and it keeps Domain free from Doctrine attributes.
5.Validation – Symfony Validator is only used to check input data in commands/DTOs, so it belongs to Infrastructure. Business invariants remain inside entities and value objects.
6.JsonResponse – the demo only shows API endpoints. For HTML responses I would add controllers in Presentation/Web

Collapse
 
xwero profile image
david duymelinck

1 Yes that was my guess. But because the title is DDD in Symfony I was hoping for a little more Symfony related content.

2 I just think it is a badly named pattern, and the abbreviation doesn't help.

4 It has been so long I used separate ORM configuration I forgot it existed.

5 You are right it is infrastructure validation.

5 and 6 The reason i asked the question was also about your stance on changing the framework to have a more discoverable DDD structure. For example a config directory or the views in Infrastructure/views in each bounded context.

Thread Thread
 
mykola_vantukh profile image
Mykola Vantukh

1.Yes, makes sense. To clarify — the whole Infrastructure in the Github demo is built with Symfony (controllers, DI config, validation, etc.). I kept the post framework-agnostic to highlight the DDD boundaries, but the implementation is fully Symfony.
4.On ORM config — true, it’s not used often these days. Out of curiosity, how do you usually separate ORM mapping from the domain layer in your projects?

Thread Thread
 
xwero profile image
david duymelinck • Edited

I moved to native query languages in the infrastructure layer. I don't want to think about two different types of models in one project.
Letting the ORM loose I also feel more free to use multiple database types.

Thread Thread
 
mykola_vantukh profile image
Mykola Vantukh

That’s a very interesting approach — thanks for sharing. Can we connect on Linkedin or somewhere else?