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.
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:
- You write code that doesn’t depend on the framework.
- It forces you to write clean code.
- I don’t need a database.
- It suits big projects.
- 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:
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.




















