Skip to main content
Home
HexagonalGuru
Software Architecture

What Is Domain-Driven Design? A Practical Guide with a Real Example

HexagonalGuru's avatar

HexagonalGuru

What Is Domain-Driven Design? A Practical Guide with a Real Example

In a meeting, the business says "order", "customer" and "invoice". In your code, those same things are called OrderDTO, UserEntity and InvoiceRow. Every conversation, every ticket and every new hire pays a translation tax — and the business rules end up scattered across services nobody dares to touch.

Domain-Driven Design (DDD) is Eric Evans' answer to that problem, laid out in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software. It's not an architecture or a framework: it's a way of building software where the code speaks the language of the business and the rules live in a single place. It's the natural companion of hexagonal architecture: that one tells you where to put the domain; DDD tells you how to model it.

The Problem: The Anemic Model

Most codebases we audit share the same symptom: entities that are bags of getters and setters, with all the business logic piled into 2,000-line Service classes. It's what Martin Fowler called the Anemic Domain Model.

The consequences are familiar:

  • Rules get duplicated. "A confirmed order cannot be modified" is validated in three different services... and not in a fourth one.
  • Nobody knows where each rule lives. Changing a discount policy requires archaeology across half a dozen files.
  • The code lies. Names say one thing and the business means another. Every new developer spends months learning the local dialect.

Strategic DDD: The Map Comes First

Before writing a single class, DDD offers two tools for understanding the territory.

Ubiquitous language

If the business says "confirm an order", the method is called confirm() — not updateStatus(2). The ubiquitous language is the shared vocabulary that developers and domain experts use alike: in meetings, in documentation and in code. When a conversation reveals that "customer" and "user" are the same thing, one of the two words disappears from the codebase.

Bounded contexts

Here is DDD's most counterintuitive idea: there is no single correct model for the whole company. A "customer" for Sales is a history of opportunities; for Billing it's a fiscal address; for Support it's a queue of tickets. Forcing one Customer class with 40 fields is the recipe for coupling.

A bounded context is an explicit boundary within which a model (and its language) has a precise meaning:

Three bounded contexts — Sales, Billing and Support — each with its own Customer model

Each context evolves on its own, and the relationships between them are mapped explicitly (a context map). This, by the way, is what outlines the natural microservice candidates when the system justifies them.

Tactical DDD: The Building Blocks

Inside a context, DDD provides a handful of patterns for precise modeling:

  • Entities. Objects with their own identity that persists over time: an Order is the same order even if its lines or its status change.
  • Value objects. Immutable values with no identity, defined by their attributes: Money, Email, Address. Two €20 bills are interchangeable; two orders are not.
  • Aggregates. A cluster of entities and value objects with a single root, which is the consistency boundary: from the outside you only touch the aggregate through its root.
  • Repositories. One per aggregate, not per table. You persist and retrieve whole aggregates.
  • Domain events. Something that happened in the business: OrderConfirmed, InvoiceIssued. Other contexts (or adapters) react to them.
Anatomy of an aggregate: Order as the root, order lines inside, value objects, and the repository touching only the root

A Real Example: The Order

Let's stay in the universe of our previous article. First, a value object that makes adding euros to dollars impossible:

final class Money
{
    private function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {}

    public static function euros(float $amount): self
    {
        return new self((int) round($amount * 100), 'EUR');
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new IncompatibleCurrencies();
        }

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

The aggregate protects its invariants — it is impossible to leave it in an invalid state:

final class Order
{
    private array $lines = [];
    private array $events = [];

    private function __construct(
        private OrderId $id,
        private CustomerId $customerId,
        private OrderStatus $status,
    ) {}

    public static function create(OrderId $id, CustomerId $customerId): self
    {
        return new self($id, $customerId, OrderStatus::Draft);
    }

    public function addLine(ProductId $productId, Money $price, int $quantity): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new OrderNotModifiable($this->id);
        }

        $this->lines[] = OrderLine::create($productId, $price, $quantity);
    }

    public function confirm(): void
    {
        if (count($this->lines) === 0) {
            throw new EmptyOrder($this->id);
        }

        $this->status = OrderStatus::Confirmed;
        $this->events[] = new OrderConfirmed($this->id, $this->total());
    }

    public function total(): Money
    {
        return array_reduce(
            $this->lines,
            fn (Money $total, OrderLine $line) => $total->add($line->subtotal()),
            Money::euros(0),
        );
    }
}

Notice three things:

  1. The constructor is private. You can only be born in a valid state, through create(). A "half-built" order does not exist.
  2. The rules live inside. "A confirmed order cannot be modified" and "an empty order cannot be confirmed" live in exactly one place — and it's impossible to bypass them, because there are no setters.
  3. The code reads like the business talks. $order->confirm() is exactly what a sales rep would say.

How It Fits with Hexagonal Architecture

The two approaches complement each other: hexagonal architecture draws the house, and DDD furnishes the center. The Order aggregate lives in the core, framework-free. The OrderRepository is an output port that Doctrine implements as an adapter. The ConfirmOrder use case orchestrates: it loads the aggregate, calls confirm(), persists it and publishes the events — and a messaging adapter fans them out to Billing, to email, or wherever they belong.

The result: the rule "an empty order cannot be confirmed" is tested in milliseconds, with no database and no HTTP — and it sounds exactly the same in the test as it does in the business meeting.

What You Gain

  • Zero translation tax. Business conversations and code share a vocabulary. Requirements stop getting lost in translation.
  • Armored invariants. If an invalid state is unrepresentable, it stops being a possible bug. Fewer scattered validations, fewer defensive ifs.
  • Complexity where it matters. You invest modeling effort in the business core — what differentiates you — not in the catalog CRUD.
  • Boundaries that scale. Bounded contexts are the natural candidates for modules or microservices as the system grows.
  • Real onboarding. A new developer reads the domain and understands the business, not just the syntax.

When Not to Use It

The same honesty as always: DDD has a cost. It demands conversations with domain experts, iteration on the model and discipline. It's not worth it when:

  • The domain is simple. A catalog CRUD with forms and listings doesn't need aggregates or events — it needs a scaffold.
  • There's no access to the business. Without domain experts to build the ubiquitous language with, DDD degrades into tactical patterns applied for sport.
  • The project is disposable. In a three-week prototype, the payoff never arrives.

Rule of thumb: apply full DDD in your core domain (what makes you money) and simpler solutions in supporting contexts.

Common Mistakes

  1. DDD everywhere. Aggregates, events and repositories even for the countries CRUD. The context map exists precisely to choose where to invest.
  2. Anemic model in disguise. Entities full of setters with all the logic in OrderManager services: you've paid the cost and haven't collected the benefit.
  3. Primitive obsession. Everything is a string or an int: loose $email, $price, $currency. Value objects are cheap and eliminate entire categories of bugs.
  4. The single global model. One Customer class shared by sales, billing and support is coupling with good marketing.
  5. Domain events as an excuse for a queue. You don't need RabbitMQ on day one: an in-memory bus or Symfony Messenger publishes events perfectly well. Infrastructure arrives when the domain asks for it.

In Summary

DDD is not about writing more classes — it's about making code and business say the same thing. Model the language, protect invariants inside your aggregates, and reserve your best engineering for the domain that differentiates you. Combined with hexagonal architecture, it's the foundation of how we build software that survives years and team changes.

If your codebase suffers from anemic models, duplicated rules or fear of touching certain services, a software audit is the best starting point: we map your bounded contexts, locate the debt and deliver a prioritized plan. Request a free consultation →

Want to go deeper? Coming soon: CQRS — when to separate reads from writes (and when it's pure overengineering).

  • #ddd
  • #hexagonal-architecture
Related articles
Shall we start?

Ready to build something that grows with your business?

Tell us your goals and together we will map the design, development and technology route that takes you from vision to measurable results.