· Péhápkaři Prague meetup #25 · Prague · given in Czech

I tried DDD, and this is how it turned out

Zkusil jsem DDD a takhle to dopadlo… Not a DDD tutorial. A list of the mistakes I made the first time I used Domain-Driven Design, on a big production project, with the profiler screenshots to prove it.

Update, 2026The enum section below predates native enums in PHP 8.1 (2021). Today you don’t need a userland enum library like marc-mabe/php-enum for this.

Why DDD at all

Domain-Driven Design is a way to structure an application around its business domain: entities, value objects and aggregates that model the real problem, with the framework and the database pushed to the edges. The project was Entrydo, our event ticketing SaaS. My reasons for trying it:

  1. You write code that doesn’t depend on the framework.
  2. It forces you to write clean code.
  3. I don’t need a database.
  4. It suits big projects.
  5. Because I like it.

The rest of the talk was about the negative experiences and missteps.

Mixed getter naming

firstName(): string or getFirstName(): string? It doesn’t really matter. What matters is picking one convention and keeping it across the whole project. We had both.

A value object for everything

A value object is a small immutable object defined by its value, like an e-mail address or a price. I took that idea a bit too far. Creating a person looked like this:

$person = new Person(
    new Email('info@odbav.to'),
    new Name(
        new FirstName('John'),
        new LastName('Doe')
    ),
    new Phone(null, '603123123'),
    new Address(
        new Street('Slezska 123'),
        new City('Prague'),
        new PostalCode('11000'),
        new Country(CountryCode::CZ())
    ),
    new Company(
        new CompanyName('odbav.to'),
        new CompanyId('28994124'),
        new CompanyVatId(null)
    )
);

The don’ts: classes like class FirstName extends SimpleString {}, which are empty. They add no rules, only one more object. The dos: value objects that actually guard something, like a latitude that must be a number between −90 and 90:

class Latitude extends Floating
{
    protected function validate(?float $value): ?float
    {
        Assertion::numeric($value);
        Assertion::range($value, -90, 90);

        return $value;
    }
}

Longitude is the same with −180 and 180.

All those objects have a cost, because Doctrine has to build every one of them when it loads data:

Blackfire profile with 40.4 s wall time. The function list shows a Doctrine type convertToPHPValue called 83,399 times, AbstractType::createObject 80,121 times, SimpleString::__construct and SimpleString::setValue 71,775 times each, ReflectionEmbeddedProperty::setValue 42,963 times and MabeEnum Enum::byValue 24,524 times.
One request, 40 seconds, and tens of thousands of value objects built along the way.

Own value objects for things that already exist

We had our own Money value object made of a Price and a Currency, next to the existing \Money\Money from the moneyphp library, with factory methods to convert between them:

public static function fromMoney(\Money\Money $money): self
{
    return static::fromNative($money->getAmount(), $money->getCurrency()->getCode());
}

Binary UUIDs

The identifiers were UUIDs from ramsey/uuid, wrapped in our own UuidIdentifier and stored as binary. A Blackfire comparison on this slide shows how much time went into it: after the change, tens of thousands fewer calls to UUID encoding and decoding functions, −29% wall time, −22% I/O and −29% CPU, for 8.38% more memory.

Enums and serialization

The combination of marc-mabe/php-enum and jms/serializer was a disaster.

Namespaces and directory structure

Our src mixed DDD layers and features at the same level: Application, Domain, Infrastructure next to Invoicing, Mailing, Schemes, Security, Templating, Translations and Utils.

All logic in entities

Putting behaviour into entities is the DDD way. Putting all of it there is not. This counts orders with a given status by loading all the orders and filtering them in PHP:

public function ordersWithStatusCount(OrderStatus $status): int
{
    return $this->orders->filter(function(Order $order) use ($status) {
        return $order->paymentStatus()->equals($status);
    })->count();
}

And paymentStatus() itself, marked @TODO remove, computes the status on the fly from the cancellation date, the payment, its status and the payment method. In the ticket export, one method, getInputValues, took 20.9 seconds, 72.6% of the request. Another profile shows a request taking 14.8 seconds and 266 MB of memory.

XML mapping?

The entities were mapped to the database with Doctrine XML mapping files, including single-table inheritance for events and sub-events and a long list of fields with custom types.

An exception for every exception

Every domain rule had its own exception class: EventNotReadyException, NoAvailableTicketsOfVariant, TicketVariantCancelledException, DiscountCodeCancelledException, DiscountDateNotValidException and more. Then a wall of catch blocks translated each of them into an OrderCannotBeCreatedException with a message, and the API layer turned that into an error response:

if ($e instanceof ApplicationException) {
    return $this->createErrorResponse([
        'title' => $e->title(),
        'detail' => $e->detail(),
    ], $e->additionalResponses());
}

Aggregate roots?

“What is that?” was the whole slide. For context: an aggregate root is the one entity through which you change a group of related objects, which keeps their rules consistent.

Watch out for overexcitement

The slide showed the book Domain-Driven Design in PHP by Carlos Buenosvinos, Christian Soronellas and Keyvan Akbary. It’s easy to get carried away by it.

And the last point: using DDD for the first time, and on a big project. That combination is where most of these mistakes came from. The details are in the recording.

All 22 slides
  1. Slide 1: Title slide: "Zkusil jsem DDD" (I tried DDD) and how it went, Jan Mikeš, 8 Feb 2018
  2. Slide 2: Why DDD: framework-independent code, forces clean code, no database needed, fits big projects, I like it
  3. Slide 3: What this talk covers: sharing negative experiences and stumbles
  4. Slide 4: Mixed getter naming: firstName() vs getFirstName(); either works, but keep one convention
  5. Slide 5: Value objects for everything: a Person built from nested Email, Name, Phone, Address and Company objects
  6. Slide 6: Value objects for everything: the same nested Person code next to a "WTF bro" meme
  7. Slide 7: Value objects for everything: don't subclass strings for names; do wrap validated latitude and longitude
  8. Slide 8: Value objects for everything: profiler shows 83,399 calls to Doctrine convertToPHPValue
  9. Slide 9: Custom value objects for existing things: a Money class wrapping the moneyphp library
  10. Slide 10: Binary UUID: profiler comparison showing about 29% faster after dropping Ramsey UUID codec overhead
  11. Slide 11: Enums plus serialization: combining marc-mabe/php-enum with jms/serializer is a pain
  12. Slide 12: Namespaces and folder structure: src with Application, Domain, Infrastructure and other folders
  13. Slide 13: All logic in entities: order status computed by filtering collections inside the entity
  14. Slide 14: All logic in entities: profiler call graph dominated by Doctrine hydration
  15. Slide 15: All logic in entities: profiler showing 266 MB memory use from PDO fetches
  16. Slide 16: XML mapping? A long Doctrine XML entity mapping file
  17. Slide 17: Exception on exception: a single generic ApplicationException handler
  18. Slide 18: Exception on exception: a long chain of catch blocks rethrowing order exceptions
  19. Slide 19: Aggregate roots? Cat meme: "What is that"
  20. Slide 20: Beware of overexcitement: cover of the book Domain-Driven Design in PHP
  21. Slide 21: Lesson: first DDD attempt was on a big project
  22. Slide 22: Thank you, questions? Homer Simpson, with email address

Related talks