Dive Deeper with DDD & Hexagonal Architecture in Symfony!

Jakub Skowron (skowron.dev)
5 min readSep 26, 2023

--

Photo by Alexandros Giannakakis on Unsplash

Hello and welcome back! Remember when we skimmed the surface of DDD and Hexagonal Architecture in one of my last article? Well, now we’re delving deeper. Here, I present a treasure trove of hands-on practices, guiding you through every step of integrating these principles into your Symfony projects. Ready for some action?

DDD in Symfony

1. Domain Modeling

In building an e-commerce application, entities like products, orders, and customers need to be encapsulated. Rather than defining them as simple data structures, DDD suggests modeling them as rich domain objects.

namespace App\Domain\Model;

class Product
{
private ProductId $id;
private ProductName $name;
private Price $price;

public function __construct(ProductId $id, ProductName $name, Price $price)
{
$this->id = $id;
$this->name = $name;
$this->price = $price;
}
// ... accessor methods and business logic
}

Here, each attribute of the product is represented by a dedicated object instead of a primitive type. This helps in encapsulating validation logic and behavior.

2. Value Objects

Value Objects are immutable entities that represent descriptive aspects of the domain.

namespace App\Domain\ValueObject;

class ProductName
{
private string $value;

public function __construct(string $value)
{
if (empty($value)) {
throw new \InvalidArgumentException('Product name cannot be empty.');
}
$this->value = $value;
}

public function getValue(): string
{
return $this->value;
}
}


class Price
{
private float $value;

public function __construct(float $value)
{
if ($value < 0) {
throw new \InvalidArgumentException('Price cannot be negative.');
}
$this->value = $value;
}

public function getValue(): float
{
return $this->value;
}
}

3. Data Transfer Objects (DTO) in Domain

DTOs facilitate data transmission between different layers of an application. In DDD, DTOs help in segregating the domain from external layers like UI or databases.

namespace App\Application\DTO;

class ProductDTO
{
public $id;
public $name;
public $price;
}

Using the assembler pattern, we can transform domain objects to DTOs and vice-versa.

namespace App\Application\Assembler;

use App\Domain\Model\Product;
use App\Application\DTO\ProductDTO;
class ProductAssembler
{
public function toDTO(Product $product): ProductDTO
{
$dto = new ProductDTO();
$dto->id = $product->getId()->getValue();
$dto->name = $product->getName()->getValue();
$dto->price = $product->getPrice()->getValue();
return $dto;
}

public function toEntity(ProductDTO $dto): Product
{
return new Product(
new ProductId($dto->id),
new ProductName($dto->name),
new Price($dto->price)
);
}
}

Application Layer

The application layer bridges the gap between the presentation and the domain. It orchestrates and coordinates operations. In the context of Symfony, this is where services, controllers, and commands & queries come into play.

1. Commands

Commands represent actions that can be executed in the system. For instance, adding a new product.

namespace App\Application\Command;

class CreateProductCommand
{
public $name;
public $price;

public function __construct(string $name, float $price)
{
$this->name = $name;
$this->price = $price;
}
}

2. Command Handlers

Command handlers are responsible for executing operations in response to a command.

namespace App\Application\Handler;

use App\Application\Command\CreateProductCommand;
use App\Application\Assembler\ProductAssembler;
use App\Application\Port\ProductRepository;
use App\Domain\Model\Product;
use App\Domain\ValueObject\ProductName;
use App\Domain\ValueObject\Price;

class CreateProductHandler
{
private ProductRepository $productRepository;
private ProductAssembler $productAssembler;

public function __construct(ProductRepository $productRepository, ProductAssembler $productAssembler)
{
$this->productRepository = $productRepository;
$this->productAssembler = $productAssembler;
}

public function handle(CreateProductCommand $command): void
{
$product = new Product(
new ProductName($command->name),
new Price($command->price)
);
$this->productRepository->save($product);
}
}

3. Queries

Queries are meant to retrieve data from the system.

namespace App\Application\Query;

class GetProductByIdQuery
{
public $id;

public function __construct(string $id)
{
$this->id = $id;
}
}

4. Query Handlers

Query handlers fetch data based on a given query.

namespace App\Application\Handler;

use App\Application\Query\GetProductByIdQuery;
use App\Application\Port\ProductRepository;

class GetProductByIdHandler
{
private ProductRepository $productRepository;
public function __construct(ProductRepository $productRepository)
{
$this->productRepository = $productRepository;
}

public function handle(GetProductByIdQuery $query)
{
return $this->productRepository->findById($query->id);
}
}

5. Messenger Configuration for Commands and Queries

To handle Command/Handler and Query/Handler separately in Symfony’s Messenger:

  • Namespaces Definition in Configuration

In your configuration file (config/packages/messenger.yaml), define mappings for your commands and queries:

framework:
messenger:
transports:
# ... potential transports ...
routing:
# Commands
'App\Command\*': some_transport_name
# Queries
'App\Query\*': another_transport_name
buses:
command.bus:
default_middleware: allow_no_handlers
query.bus:
default_middleware: allow_no_handlers
  • Setting Autowiring for Handlers

Instruct Symfony to auto-wire dependencies for your handlers. In config/services.yaml, add:

services:
# Handlers for Commands
App\Command\Handler\:
resource: '../src/Command/Handler/'
tags: [{ name: 'messenger.message_handler', bus: 'command.bus' }]

# Handlers for Queries
App\Query\Handler\:
resource: '../src/Query/Handler/'
tags: [{ name: 'messenger.message_handler', bus: 'query.bus' }]

With this setup, the Symfony Messenger will effectively handle Command/Handler and Query/Handler in distinct directories.

Hexagonal Architecture

The Hexagonal Architecture, also known as Ports and Adapters, decouples the application from external influences, promoting better separation of concerns and maintainability. In this architecture, the core application is surrounded by ports which get connected to external systems through adapters. This ensures the core logic remains pure and untouched by outside forces.

1. Ports

Interfaces inside the Application/ directory define the primary operations that our application needs to handle. These are called "ports", specifying what the application should do without dictating how it should be done.

For instance, a repository for products would look like:

namespace App\Application\Port;

use App\Domain\Model\Product;

interface ProductRepository
{
public function findById(ProductId $id): ?Product;
public function save(Product $product): void;
}

2. Adapters

Adapters are the concrete implementations of our ports. They dictate how something should be executed. For our product repository, we can have various implementations, such as one for Doctrine ORM or an in-memory version for testing purposes.

namespace App\Infrastructure\Repository\Doctrine;

use App\Application\Port\ProductRepository;
use App\Domain\Model\Product;
use Doctrine\ORM\EntityManagerInterface;

class DoctrineProductRepository implements ProductRepository
{
private EntityManagerInterface $entityManager;

public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}

public function findById(ProductId $id): ?Product
{
return $this->entityManager->getRepository(Product::class)->find($id->getValue());
}

public function save(Product $product): void
{
$this->entityManager->persist($product);
$this->entityManager->flush();
}
}


namespace App\Infrastructure\Repository\InMemory;

use App\Application\Port\ProductRepository;
use App\Domain\Model\Product;

class InMemoryProductRepository implements ProductRepository
{
private array $products = [];

public function findById(ProductId $id): ?Product
{
return $this->products[$id->getValue()] ?? null;
}

public function save(Product $product): void
{
$this->products[$product->getId()->getValue()] = $product;
}
}

Doctrine Configuration

For our domain model to work seamlessly with Doctrine, we must tweak our configurations and define custom types if necessary.

1. Custom Doctrine Types

For instance, let’s consider a custom type for Price:

namespace App\Infrastructure\ORM\Type;

use App\Domain\ValueObject\Price;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

class PriceType extends Type
{
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
return 'FLOAT';
}

public function convertToPHPValue($value, AbstractPlatform $platform)
{
return new Price((float) $value);
}

public function convertToDatabaseValue($value, AbstractPlatform $platform)
{
return $value->getValue();
}

public function getName()
{
return 'price';
}
}

2. Doctrine YAML Configuration

In your config/packages/doctrine.yaml file:

doctrine:
orm:
mappings:
App:
is_bundle: false
type: yml
dir: '%kernel.project_dir%/config/doctrine'
prefix: 'App\Entity'
alias: App
dbal:
types:
price: App\Infrastructure\ORM\Type\PriceType

For mapping definitions, within config/doctrine/Product.orm.yml:

App\Domain\Model\Product:
type: entity
table: products
fields:
name:
type: string
length: 255
price:
type: price

These sections provide a foundational understanding of integrating the Hexagonal Architecture concept into a Symfony application and appropriately configuring Doctrine to harmonize with our domain model.

Photo by Benjamin Davies on Unsplash

Conclusion

When appropriately implemented, DDD and Hexagonal Architecture establish a robust foundation for Symfony applications, ensuring scalability, modularity, and maintainability. Grasping the business domain and correctly mapping it into code is key, maintaining boundaries between the distinct architectural layers. The application layer plays a pivotal role, weaving all elements into a cohesive whole. Adopting such practices might demand an initial investment of time and effort, but the perks of easier expansion and application maintenance soon outweigh the costs.

--

--

Jakub Skowron (skowron.dev)

Poland based PHP/Python Web Backend dev. Love to work with Symfony and FastAPI frameworks. In spare time totally gearhead.