loading...

Domain Driven Design with PHP and Symfony

ludofleury profile image Ludovic Fleury Updated on ・11 min read

Personal take on Domain Driven Design with PHP and Symfony framework (2.x to 5.x)

This article was written in french here

Meaning is everything

DDD is gaining momentum in the Symfony & PHP community. I'm committed into a quest for "meaning" and so I'm a DDD evangelist.

Definition

DDD is a paradigm, an approach, a way.
Take a moment to reflect on the acronym because in order to get the pure value of DDD, you only need to understand these 3 words together: "Domain" "Driven Design" If you need to remember only one thing from DDD, it should be its name.

DDD isn't an software architecture or architectural style. DDD isn't about Entity, Value Object or Aggregate Root. They are convenient, and misleading, technicality in the object-oriented world.

DDD in essence is a shared understanding of the business domain crystallised in a base code by developers.

In practice

If business people would take the time to read a DDD code repository, they should understand 80% of it. Actually, high level language like PHP could be considered like a really poor subset of the english language. With DDD, the PHP code describe business rules, requirements, invariants in an "object-oriented english".

How does my code look like? Business domain processes described with a poor english:

$cashier->checkout($cart);
$cashier->charge($card);
$accountant->write($payment);
$compliance->verify($payment);

How does my classes look like? Business concepts written in a poor english:

namespace Legal\Company\France;

/**
 * French unique registration number for a specific establishments of a company
 * issued by INSEE
 * SIRET: "Système d'Identification du Répertoire des ETablissements"
 * 
 * @see https://www.economie.gouv.fr/entreprises/numeros-siren-siret
 */
Class Siret implements RegistrationNumber
{
    private Siren $siren;
    private Nic $nic;

    public function __construct(string $number)
    {
        try {
            $this->siren = new Siren(mb_substr($number, 0, 9));
            $this->nic = new Nic(mb_substr($number, 9));
        } catch (\Exception $exception) {
            throw new \DomainException('Invalid SIRET number');
        }
    }

    public function getNumber(): string
    {
        return $this->siren->getNumber() . $this->nic->getNumber()
    }
}
namespace Legal\Company\France;

/**
 * Unique registration number for a french company
 * Issued by INSEE, composing the first part of SIRET
 * Système d'Identification du Répertoire des ENtreprises
 *
 * @see https://bpifrance-creation.fr/encyclopedie/formalites-creation-dune-entreprise/formalites-generalites/numeros-didentification
 */
class Siren
{
    private string $number;

    public function __construct(string $number)
    {
         if (preg_match('/^[0-9]{9}$/', $number) !== 1) {
            throw new \DomainException('Invalid SIREN number');
         }

         $this->number = $number;
    }

    public function getNumber(): string
    {
        return $this->number;
    }
}

French developers might be familiar with the concepts examplified. However, if you aren't, it shouldn't take you too long to get a clear idea about the rules governing the french company registration numbers... simply by reading the code.

The direct benefits

More than readable code, we have expressive code translating literraly the business concepts & domain.

The code is also "immutable".
I didn't follow a best practice or a specific pattern, I just followed the french registration number rules. These numbers are immutable, so my code is immutable... and so these classes naturally fit the Value Object definition. It wasn't the goal of the implementation, it's a side effect of it.

Free your mind

Forget about "best practice", "design pattern", "fluent interface", "event dispatching", , MVC, SoC, SRP, SOA, RAD, CRUD, DRY...
Forget tech, Domain drives your design, domain comes first. Use your language (here PHP) to model the domain in the simplest possible way. Like a business expert will try to teach a toddler about a process, the devs are trying to make a machine process it while keeping the meaning explicit.

If you are using Entities with setter & getter, some Value Object in the /ValueObject/ namespace, you are missing the essence of DDD.

Again, DDD is not about respecting a specific way to implement stuff. DDD is about sharing the language between the code and the business domain.

Another value of DDD: the code has value at runtime AND at rest. The code is self-documenting a business process, acting as knowledge center for any human interacting with it.

DDD and Symfony

I started to read & implement DDD around 2014.
It took me a full year reading & implementing before I start to feel comfortable with my tooling (PHP/Symfony/Doctrine) and DDD. As Evans said:

"Don't fight your framework".

-- Domain-driven Design: Tackling Complexity in the Heart of Software

What is my "pragmatic" implementation of DDD with Symfony?
It really depends of the project & the complexity. But when I'm bootstrapping a new project or coaching a new dev, I usually rely on these simple guidelines:

  • I heavily rely on the command pattern, (Symfony Messenger is a bliss

  • I do not use CQRS

  • I do not use ES

  • I use Behat & BDD at the Command/Handler level, full coverage.

  • I rarely use unit testing in project, 20-30% code coverage usually.

Why no CQRS & ES?

CQRS & ES are costly. They are extremely powerful solutions but you should use them only when it makes sense cost-wise & business-wise.

ES

Not so many php devs are used to Event Sourcing, and you won't find a lot able to design or use an Event Store properly. If you do not have a good model maturity remember that your events store directly reflects the history of your mistake, which might drastically increase the maintainability cost.

CQRS

CQRS truly shines at scale. This usually brings eventual consistency specificity between the write & read models. Do you really need this complexity and cost?

I implemented both patterns in different projects with PHP/Symfony, sometimes Doctrine, SQL & MongoDB

src structure

  • src/Controller/: Symfony controller
  • src/Command/: Symfony CLI commands

  • src/Action/: Command + CommandHandler

  • src/Domain/: AR, Entity, VO, Repository Interface

  • src/*: Infrastructure, other stuff.

Having something called /Domain/ sucks. But it also works.
Usually I replace "Domain" by the actual Domain we are tackling like /Ecommerce/.

Multiple domains issue

The problem starts when you have multiple domains. Opinions are quite strong about Core & supporting domain. Me? I don't care. I put everything in /Domain/ and then split if needed without qualifying as "Core" or "Supporting":

  • /Domain/Payment
  • /Domain/Compliance
  • /Domain/Delivery

It's simple & it's consistent.

What's in the "Domain“ ?

Inside the src/Domain/, I put every class that implement a business domain concern/concept. Some are persisted classes, others aren't, we also have the domain service there.

Since src/Entity doesn't exist anymore, Doctrine configuration is customised to scan src/Domain. Annotations are used to configure the persistence mapping, because "locality" is great.

Obviously when the project gets bigger, I do reorganize the /Domain/ in order to reflect the business domain:

/Domain/Legal/Company/France/RegistrationNumber/Siret.php
/Domain/Legal/Company/France/RegistrationNumber/Siren.php
/Domain/Legal/Company/France/RegistrationNumber/Nic.php

Command to conquer

The good

I love the command pattern:

  • command handlers play well with hexagonal architecture

  • command handlers bring consistency to manipulate my model layer, they are the unique entrypoints.

  • command handlers bring consistency to the application service layer, as they are the application service

  • command handlers define the transactionnal boundaries of a business process/action

  • command handlers help to deal & design with Aggregate Root

The bad and the ugly

I do abuse command handlers in some circumstances:

  • When facing unknown specs, lack of understanding, lack of available knowledge, when I'm lazy or in a rush: I implement in the command handler code that would be moved later into domain services or domain objects.

  • When I need to synchronize several business processes together, I use handler instead of the SAGA hell

  • When an AR implementation is really tricky or costly with Doctrine/Symfony. I do leak the implementation in the handler (it's bad, don't do this at home)

It sounds probably terrible for purist. I'm really sorry and really okay with that as it worked for me.

Naming the command

Commands are named against the process, the business action or behavior they
implement. I do adapt to reflect the specific business needs:

src/Action/Checkout.php
src/Action/CheckoutHandler.php

src/Action/FraudulentPaymentDeclaration.php
src/Action/FraudulentPaymentDeclarationHandler.php

src/Action/Compliance/Registration.php
src/Action/Compliance/RegistrationHandler.php
src/Action/Compliance/FrenchEstablishmentRegistration.php
src/Action/Compliance/FrenchEstablishmentRegistrationHandler.php

Maybe I would have used an imperative form for the Command: Register instead of Registration

-- Florian Klein

Business process, domain process or domain action are usually called by a unique name. It is important to tak the time to identify exactly the noun used to qualify this process. It's not always possible.

A verb might be a symptom of a weak domain understanding, example:

  • MarkAsRead
  • ValidateStatus

It's an advice, a best effort. In reality, in my repo I do have a lot of command phrased with a verb.

Implementation details

Commands are expression of (domain) intents. They are designed as simple DTO accepting only primitives

easing hexagonal architecture & [port/adapter](port/adapter implementation. Commands are the boundary between primitives and domain concepts in my application:

namespace Action\Compliance;

use Domain\Geo\Address;
use Domain\Geo\Country;

class Registration
{
  private string $userId;
  private string $name;
  private string $addressLine1;
  private string $addressLine2;
  private string $addressZip;
  private string $addressLocality;
  private string $addressCountry;

  public function __construct(
    string $userId,
    string $name, 
    string $addressLine1, 
    string $addressLine2,
    string $addressZip,
    string $addressLocality,
    string $addressCountry
  ) {
    $this->userId = $userId
    // ... boring stuff
  }

  public function getUserId(): Uuid
  {
    return new Uuid($this->userId);
  }

  public function getName(): string
  {
    return $this->name;
  }

  public function getAddress(): Address
  {
    return new Address(
      $this->addressLine1,
      $this->addressLine2,
      $this->addressZip,
      $this->addressLocality,
      new Country($this->addressCountry)
    );
  }
}

I do add Symfony validation with annotation to my command in order to pre-validate and dispatch to the user any input errors. Yet when it comes to enforce business rules: my domain concept (mostly Value Object) carry the implementation and any violation raise exception.

Implementing commands this way, ease hexagonal:

  • commands can be used as a Symfony/Form model you know: the "data-class".

  • commands can be composed from arguments of a Symfony/Console CLI command.

  • commands can be composed by a RabbitMQ worker/consumer from a unserialized message

  • command can be composed by an event processed from a Symfony/EvenetListener

  • from web socket, etc...

Once my command is passed to its handler, we enter the domain kingdom. Only dmain object are used over there (VO, Entity). A domain object provides strong guarantees:

  • its state is consistent, always valid (according to the business rules)

  • its state is mostly immutable

  • its public API allows only expected states change, following the domain specification

Using these domain object is a delight and a relief: the cognitive load is really low and the confidence in the system is very high.

Few tips:

  • I do not use typed ID for each AR/Entity (UserId, BookId). It's a pain to maintain with Doctrine.

  • When to use a VO (domain object) over a primitive? If the concept is important/relevant for the domain, if the concept follows rules from the domain: it's a VO. Otherwise, primitives are good enough. We're still in PHP, not in Java.

Command handlers ?

Command handlers coordinate the domain concept altogether (VO, Entity, AR).

It usually relies on a repository to fetch the concerned AR.

The handler then calls methods on the domain object (AR, Entity). Since Doctrine is following data-mapping, the handler manage the persistence cycle. Somtimes it coordinate other infrastructure details, usually anything related to IO required to compose a correct state in order to perform a business action.

Symfony Messenger does the heavy lifting. You just need to dispatch the command into the bus. from your CLI-command, HTTP controller, RabbitMQ consumer and voila, your handler will be invoked with the command.

Since I do not use CQRS, most of my commands are synchronous over HTTP. Using Symfony/Messenger to get the last result of a command execution, I just need to add some serialization to get a REST representation for the HTTP response.

Bounded Contexts (BC)

I consider one git repo as one bounded context. My understanding might & will change later. I could split BC's later.

A lot of BC's might mean:

  • domain misunderstanding
  • unknown about the domain
  • high complexity

Organizing BC's

Microservice are a pain to orchestrate. If you've got a devops army good for you. If not, locality works again:

src/BC1/Controller/
src/BC1/Command/
src/BC1/Domain/

src/BC2/Controller/
src/BC2/Command/
src/BC2/Domain/

Why? Because splitting BC is risky. Synchronising BC is costly. Your understanding will develop and will require a lot of refactoring & iteration: moving code from "BC's" and "domains" back and forth. Once you reach model maturity, split your repo, do your microservice show.

Conclusion

These are tricks or compromises that I found efficient to standardise a DDD approach with PHP, Symfony & Doctrine. These are not what you should focus on.

These are what you might wanna use to never have to think about the "how" and just focus about the "what" & "why".

Bonus track

Can I share/re-use concepts over BC ?

First: Never ever share Entities across BC.
Second: VO sharing is tough, especially with Doctrine, you would need to add your domain via composer/vendor and add mapping via yaml.
Last: Domain sharing is so-so. Like you can share a Geo domain (ISO Country, ISO Phone, ISO Currency)... but that would be very generic supporting domain.

Why "core" domain sharing is bad? Because from 2 different BC's if a domain is exactly seen & envisioned with the same properties, rules, shapes... Well you have only one BC. Careful, it's not because 2 BC shares domain names that they mean the same thing.

Example: For HR dept(BC) and Tech management(BC), "Employee" (Domain) means something very different even if they share some similitude.

BC collaboration, synchronization?

This is where things get hairy. First of all, BC should be isolated, you shouldn't have any direct coupling.

Data-wise, either you go for 2 storages or 2 schemas if you're in SQL.
if you are cheap and share the same schema... do not make relation between your BC's tables.

The command pattern is really helpful there. Let say I have a checkout process with a BC about payment and another BC about compliance.

Lazy: I can call my 2 commands PaymentCharge & PaymentFraudAnalyze at the same level (HTTP controller, CLI-command). Pro: It guarantee the loose coupling. Cons: your controller/cli-command carry some app/service/business sync.

Best: Go for event. Use the Symfony dispatcher, don't build your own stuff. Explicitly dispatch an event from your Command Handler. Create a specific EventListener in BC2. DON'T be lazy, do not go for "command sourcing pattern". Your BC1 is emitting an event ("Mom I'm Done"), your BC2 is listening, then transforming to its own command.

You can also add static analysis to enforce coupling rules (ensure BC aren't mixed). If you keep BC isolated (Data storage) & sync BC's with event, you are building scalable BC's respecting microservice requirements. Meaning, you could quickly move and split your BCs across repo and your infra.

Posted on May 31 by:

ludofleury profile

Ludovic Fleury

@ludofleury

Born before Internet, started as "webmaster", now supporting people mastering it. I was shaped by XP methodology & DDD architectures. CI & CD evangelist. Open source enthusiast.

Discussion

markdown guide
 

Hi, thank you for a nice post. Also you can find my implementation of DDD/CQRS there
github.com/ferrius/ddd-cqrs-example