Domain Driven Design in Symfony Projects: Tactical Patterns
AI generated
SF
{ }
Symfony · Domain Driven Design · Value Objects · Aggregate
Domain Driven Design in Symfony Projects
Tactical patterns without Doctrine in the domain

Domain Driven Design in Symfony rarely fails because of a poor strategic understanding of the business domain and almost always fails at the tactical implementation in code. This article shows concretely how value objects, aggregate boundaries, repository interfaces and domain events are implemented in Symfony, without Doctrine attributes polluting the pure domain layer.

20 min read Value Objects · Aggregate Root · Repositories · Domain Events Symfony 7.x · PHP 8.3+ · Doctrine ORM

1. Why Domain Driven Design often fails in Symfony

Domain Driven Design gets reduced in many Symfony projects to a single detail: entities get private properties and getters instead of public attributes. The core strategic promise of Domain Driven Design, that the code directly reflects the language and rules of the business domain, usually gets lost in the process. An Order entity with twenty public setters is not an expression of Domain Driven Design, it is a Doctrine entity with encapsulation cosmetics.

The real value of Domain Driven Design in Symfony only emerges once business rules live as invariant conditions inside aggregates, instead of being scattered across controllers or services. An order that must not contain empty line items, or a discount that is only valid below a certain threshold, belongs inside the aggregate itself, not in a validation class that happens to be called in several places or, just as easily, not called at all. The following sections show the concrete tactical patterns that actually make Domain Driven Design viable in a Symfony project.

2. Value objects instead of primitive types

The most fundamental tactical pattern in Domain Driven Design is the value object. Instead of modeling an email address as a string or a monetary amount as a float, every business meaningful concept gets its own immutable class with built in validation. An EmailAddress value object guarantees that an invalid value can never exist, because validation happens in the constructor and no setters exist. This pattern moves validation logic from the edges of the application, where it is easily forgotten, into the type itself, where it always applies.

Money as a value object is another standard example in Domain Driven Design that is frequently missing in Symfony projects. A plain float amount leads to rounding errors and allows accidental mixing of different currencies. A Money value object encapsulates amount and currency together, implements operations such as add() and multiply() with built in currency checks, and makes an entire class of bugs visible immediately at compile time or run time instead of only showing up in production.


<?php

declare(strict_types=1);

namespace App\Modules\Billing\Domain\ValueObject;

use InvalidArgumentException;

// Value Object — immutable, self-validating, no setters
final readonly class Money
{
    private function __construct(
        public int $amountInCents,
        public string $currency,
    ) {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('Amount cannot be negative');
        }
        if (!in_array($currency, ['EUR', 'USD', 'CHF'], true)) {
            throw new InvalidArgumentException("Unsupported currency: {$currency}");
        }
    }

    public static function fromCents(int $cents, string $currency): self
    {
        return new self($cents, $currency);
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }

    public function isGreaterThan(self $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->amountInCents > $other->amountInCents;
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch');
        }
    }
}

3. Defining entities and aggregate boundaries

An aggregate in Domain Driven Design is a group of related entities and value objects treated as one consistency unit, with exactly one aggregate root as the only allowed access point from outside. An order together with its line items is a classic example. OrderItem objects only exist inside Order, are never loaded directly through their own repository and can only be changed through methods on the Order aggregate root, for example $order->addItem(...) instead of direct access to the internal items collection.

Drawing the boundary of an aggregate correctly in Domain Driven Design is the hardest decision in the entire domain modeling process. Aggregates that are too large lead to lock conflicts under concurrent writes and load unnecessary amounts of data. Aggregates that are too small push consistency rules into application services, where they are easily forgotten. The rule of thumb in Domain Driven Design: an aggregate should contain exactly the data that has to stay consistent within a single transaction, nothing more and nothing less.


<?php

declare(strict_types=1);

namespace App\Modules\Order\Domain\Entity;

use App\Modules\Order\Domain\Event\OrderCompletedEvent;
use App\Modules\Billing\Domain\ValueObject\Money;

// Aggregate Root — the only allowed entry point into this consistency boundary
final class Order
{
    /** @var OrderItem[] */
    private array $items = [];

    /** @var object[] recorded domain events, dispatched after persistence */
    private array $recordedEvents = [];

    private function __construct(
        private readonly string $id,
        private readonly int $customerId,
        private string $status = 'draft',
    ) {}

    public static function create(string $id, int $customerId): self
    {
        return new self($id, $customerId);
    }

    public function addItem(string $productId, int $quantity, Money $unitPrice): void
    {
        if ($this->status !== 'draft') {
            throw new \DomainException('Cannot modify a completed order');
        }
        if ($quantity < 1) {
            throw new \DomainException('Quantity must be at least 1');
        }

        $this->items[] = new OrderItem($productId, $quantity, $unitPrice);
    }

    public function complete(): void
    {
        if ($this->items === []) {
            throw new \DomainException('Cannot complete an order without items');
        }

        $this->status = 'completed';
        $this->recordedEvents[] = new OrderCompletedEvent($this->id, $this->customerId);
    }

    /** @return object[] */
    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];

        return $events;
    }
}

4. Repository interfaces in the domain layer

In Domain Driven Design, the domain layer defines the repository interface, while the infrastructure layer provides the concrete implementation, usually backed by Doctrine. This inversion of the dependency direction is crucial. OrderRepositoryInterface lives in Domain\Repository and knows nothing about Doctrine at all, while DoctrineOrderRepository in Infrastructure\Doctrine implements the interface and encapsulates every ORM detail. The interface methods speak exclusively the language of the domain, such as findById() or nextIdentity(), never Doctrine specific concepts like query builder objects.

This approach makes domain logic in Domain Driven Design testable independently of the concrete persistence technology. Unit tests for aggregate behavior can use an in memory repository that implements the interface, without ever opening a real database connection. That speeds up test execution considerably and simultaneously forces the domain layer to stay genuinely free of infrastructure details, which in practice is the most common violation of Domain Driven Design principles in Symfony projects.

5. Dispatching domain events from the aggregate

Domain events in Domain Driven Design are the means to communicate side effects of an aggregate state change without the aggregate itself knowing who reacts to it. When Order::complete() is called, the aggregate root records an OrderCompletedEvent without sending it directly. Only the application service, which saves the aggregate root after a successful flush(), calls releaseEvents() and dispatches the events through the Symfony Messenger event bus.

This delay between recording and sending is not an implementation detail in Domain Driven Design, it is a deliberate decision. Events should only be sent once the underlying state change has actually been durably stored in the database. An event sent prematurely about an order whose persistence subsequently fails would inform other parts of the system about a state that never really happened, a subtle but, in practice, common mistake in naive domain event implementations.

6. Keeping Doctrine cleanly out of the domain

A central goal of Domain Driven Design is that the domain layer stays independent of any concrete framework. In practice, for Symfony projects, that means no #[ORM\Entity] attributes directly on domain classes, but XML or YAML mapping files that live entirely outside the domain layer in the infrastructure. Doctrine can map entities without attributes at all, as long as the mapping configuration exists separately and private properties can be set through reflection.

This separation makes it possible in Domain Driven Design to test an aggregate such as Order completely without a Doctrine dependency and, in theory, even to swap the persistence technology without touching the domain layer. In practice hardly any project actually swaps its database, but the real benefit lies elsewhere: domain classes stay readable and free of technical annotations that would otherwise obscure the business intent of the code.


# config/doctrine/Order.orm.yaml — mapping lives outside the domain class
App\Modules\Order\Domain\Entity\Order:
  type: entity
  table: orders
  id:
    id:
      type: string
      column: id
  fields:
    customerId:
      type: integer
      column: customer_id
    status:
      type: string
      column: status
  oneToMany:
    items:
      targetEntity: App\Modules\Order\Domain\Entity\OrderItem
      mappedBy: order
      cascade: [persist, remove]
      orphanRemoval: true

7. Application services as use case orchestration

Between the controller and the domain layer sits the application layer in Domain Driven Design, usually implemented as a command handler or application service. This layer orchestrates a single use case: it loads the aggregate through the repository, calls business methods on the aggregate root, saves the result and dispatches the recorded domain events. It is important that the application layer itself contains no business rules and delegates them entirely to the domain layer.

This clear separation in Domain Driven Design prevents the most common symptom of an anemic domain model, business logic that ends up in services instead of entities. An application service that checked whether an order has line items before completing it would move that rule out of the aggregate, where it actually belongs. The command handler instead calls only $order->complete() and leaves the check entirely to the aggregate root.

8. Using the ubiquitous language consistently in code

The ubiquitous language is just as important in Domain Driven Design as any tactical pattern, but it is often neglected in the technical implementation. When domain experts talk about an order being completed, the method in the code should also be called complete(), not setStatus('completed'). This match between spoken domain language and method names in code is not a cosmetic detail, it measurably reduces misunderstandings between the business department and the development team.

In a Symfony project with consistent Domain Driven Design, the ubiquitous language also shows up in the module and class names themselves. Instead of a generic StatusService, there are specific methods like Order::cancel(), Order::complete() or Order::refund(), each with its own invariants. This explicitness makes the code readable for new team members without requiring them to understand the technical implementation in detail, the method names alone already tell the story of the business domain.

9. Tactical DDD patterns compared

Not every Symfony project needs all tactical patterns of Domain Driven Design at once. The following table ranks the most important patterns by their benefit and the effort required to implement them correctly.

Pattern Solves which problem Implementation effort Benefit
Value objects Primitive obsession, missing validation Low Very high
Aggregate root Scattered invariant checks Medium High
Repository interface Domain coupled to Doctrine Low High
Domain events Direct coupling to side effects Medium Medium to high
Application service Anemic domain model Low Very high

In practice, value objects and application services in Domain Driven Design deliver the best benefit per effort and should be introduced first in any Symfony project with non trivial business logic. Aggregate root and domain events pay off especially where several entities share common invariants.

Mironsoft

Domain Driven Design, Symfony architecture and business domain modeling

Anemic domain model instead of real Domain Driven Design?

We model aggregate boundaries together with your domain experts, implement value objects and repository interfaces, and separate the domain layer cleanly from Doctrine, so Domain Driven Design actually works in your Symfony project.

Domain modeling

Work out aggregate boundaries and invariants together with domain experts

Refactoring

Turn anemic entities step by step into real aggregate roots

Doctrine separation

Extract mapping out of the domain without interrupting the running application

10. Summary

Domain Driven Design in Symfony projects only becomes effective once tactical patterns are applied consistently: value objects instead of primitive types, aggregate roots as the only consistency boundary, repository interfaces in the domain layer with a Doctrine implementation in the infrastructure, and domain events that decouple side effects instead of wiring them directly. Application services orchestrate use cases without containing business rules themselves, which stay entirely inside the domain layer.

The biggest mistake when introducing Domain Driven Design is applying every pattern everywhere at once. Value objects and application services can be introduced almost anywhere with little effort, while aggregate root and domain events should be applied deliberately where genuinely complex business rules actually exist. This pragmatic prioritization makes Domain Driven Design workable in real Symfony projects instead of failing on academic completeness.

Domain Driven Design in Symfony — The Key Takeaways

Value objects

Immutable, self validating classes instead of primitive types for business meaningful concepts.

Aggregate root

The only consistency boundary and only allowed access point for related entities.

Repository interface

Interface in the domain, Doctrine implementation in the infrastructure layer.

Domain events

Recorded in the aggregate, sent only after successful persistence.

11. FAQ: Domain Driven Design in Symfony Projects

1Strategic vs. tactical DDD?
Strategic covers bounded contexts and domain splitting, tactical covers concrete code patterns within a bounded context.
2Do I have to avoid Doctrine attributes?
Not necessarily, but XML or YAML mapping outside the entity keeps the domain free of ORM annotations.
3How large should an aggregate be?
As large as needed for a transaction's invariants, no larger, otherwise lock conflicts appear.
4Where are domain events sent?
In the application service after successful persistence, the aggregate itself only records them.
5What is an anemic domain model?
An entity with only getters and setters whose business rules live externally in services rather than the aggregate.
6Does every field need a value object?
No, only fields with their own business meaning or validation, plain technical IDs usually do not.
7How do I test an aggregate without a database?
Through unit tests instantiating the aggregate directly, plus in memory repository implementations for application service tests.
8Does DDD fit small projects too?
Value objects and application services pay off almost always, aggregate root only with real business complexity.
9How does CQRS relate to this?
CQRS fits well but is not a mandatory part of Domain Driven Design.
10How do you introduce DDD into legacy projects?
Step by step, starting with value objects, then moving logic from services into entities, full aggregate boundaries later.