objects namespaces and application architecture

Domain-Driven Design Orientation

Domain-driven design is an approach to software design that puts the business domain at the centre of the code. The goal is to model important business concepts clearly, using language the team and domain experts recognise.

DDD is not a requirement for every PHP project. It is most useful when the business rules are complex enough that an anemic pile of arrays, controllers, and database rows becomes hard to reason about.

As a junior developer, you do not need to master every DDD book pattern immediately. You do need to understand the vocabulary you will see in mature codebases: ubiquitous language, entities, value objects, aggregates, repositories, domain services, and bounded contexts.

Ubiquitous Language

Ubiquitous language means the code uses the same important words as the business.

If the business says a learner "enrolls" on a course, code named Enrollment and enroll() is clearer than UserCourseLink and createRelation(). If the business says an invoice can be "voided", code should not call the same operation deleteInvoice() unless it really deletes it.

Good names reduce translation mistakes.

PHP example
<?php

declare(strict_types=1);

final class Enrollment
{
    public function __construct(
        public int $userId,
        public int $courseId,
        public bool $active = true,
    ) {
    }

    public function withdraw(): void
    {
        $this->active = false;
    }
}

$enrollment = new Enrollment(userId: 10, courseId: 55);
$enrollment->withdraw();

echo $enrollment->active ? 'active' : 'withdrawn';
echo PHP_EOL;

// Prints:
// withdrawn

The class name and method name should match the domain, not just database mechanics.

Entities And Value Objects

An entity has identity. Two users with the same email address are still separate users if they have different IDs. An order remains the same order as its status changes.

A value object is defined by its value. Two Money objects with 1000 and GBP mean the same amount.

PHP example
<?php

declare(strict_types=1);

final readonly class Money
{
    public function __construct(
        public int $amountPence,
        public string $currency,
    ) {
        if ($amountPence < 0) {
            throw new InvalidArgumentException('Amount cannot be negative.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currencies must match.');
        }

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

$total = (new Money(1200, 'GBP'))->add(new Money(800, 'GBP'));

echo $total->amountPence . ' ' . $total->currency . PHP_EOL;

// Prints:
// 2000 GBP

Value objects are useful because they protect rules at the edge of a concept. Instead of passing loose integers and strings everywhere, the code passes a meaningful type.

Aggregates

An aggregate is a cluster of domain objects that should be changed together through one root object. The aggregate root protects the rules for the cluster.

For example, an Order may be the aggregate root for its order lines. Code outside the aggregate should not freely edit line totals and order status in separate places.

PHP example
<?php

declare(strict_types=1);

final readonly class OrderLine
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public int $unitPricePence,
    ) {
        if ($quantity <= 0) {
            throw new InvalidArgumentException('Quantity must be positive.');
        }
    }

    public function totalPence(): int
    {
        return $this->quantity * $this->unitPricePence;
    }
}

final class Order
{
    /** @var list<OrderLine> */
    private array $lines = [];

    public function addLine(string $sku, int $quantity, int $unitPricePence): void
    {
        $this->lines[] = new OrderLine($sku, $quantity, $unitPricePence);
    }

    public function totalPence(): int
    {
        $total = 0;

        foreach ($this->lines as $line) {
            $total += $line->totalPence();
        }

        return $total;
    }
}

$order = new Order();
$order->addLine('BOOK', 2, 1500);
$order->addLine('PEN', 1, 250);

echo $order->totalPence() . PHP_EOL;

// Prints:
// 3250

The order controls how lines are added and how totals are calculated. That is more expressive than building nested arrays in a controller.

Repositories In DDD

Repositories load and save aggregates or important domain objects. They should present methods that match domain needs, while infrastructure classes handle database details.

PHP example
<?php

declare(strict_types=1);

interface OrderRepository
{
    public function findById(int $id): ?Order;

    public function save(Order $order): void;
}

The interface belongs near the domain or application layer because it describes what the domain needs. The concrete implementation belongs in infrastructure because it knows about SQL, ORM models, or external storage.

Bounded Contexts

A bounded context is a boundary where a model and its language are consistent.

The word "course" might mean different things in different parts of a system. In a sales context, a course may be a product with a price. In a learning context, a course may be lessons, progress, assessments, and completion rules. Forcing one class to represent every meaning can create confusion.

Bounded contexts let each part of the business model its concepts accurately.

Sales context:
  CourseProduct
  Price
  Discount

Learning context:
  Course
  Lesson
  Enrollment
  Progress

In a smaller app, contexts may be folders or namespaces. In a larger organisation, they may become modules or separate services.

Domain Services

Some rules do not naturally belong to one entity or value object. A domain service can hold those rules.

For example, a rule comparing two different subscriptions or calculating eligibility across several objects may be clearer as a service. Keep domain services focused and named after the business rule, not Helper.

Avoid DDD Theatre

DDD can be overused. Creating Entity, ValueObject, Aggregate, Factory, Repository, Specification, and DomainService folders for a simple settings page is not good design.

A useful DDD model should make business rules easier to understand. If the code becomes harder to change because every simple field needs five classes, the design has missed the point.

Start with the language and the rules. Add structure where it protects something real.

What You Should Be Able To Do

After this lesson, you should be able to identify entities, value objects, aggregates, repositories, and bounded contexts in a PHP codebase. You should be able to explain why Order::addLine() can be better than editing an order-lines array directly, and why domain names should match business language.

For junior work, the practical skill is to make business rules visible in code instead of hiding them in controllers, arrays, and scattered conditionals.

Practice

Practice: Model A Small Order Aggregate

Create a small PHP model for an order with order lines.

Task

Build:

  • an OrderLine value object
  • an Order aggregate root
  • a method for adding lines
  • a method for calculating the order total

The model should reject a line with a zero or negative quantity. Keep the rule inside the domain model, not in the calling script.

Use strict types. Keep the expected output in the PHP code block as printed lines or comments.

Check Your Work

Run cases for:

  • adding two valid lines and printing the total
  • trying to add a line with an invalid quantity

Afterward, explain which class is the aggregate root and which class is the value object.

Show solution

This solution keeps the quantity rule inside OrderLine and makes Order responsible for managing its lines.

PHP example
<?php

declare(strict_types=1);

final readonly class OrderLine
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public int $unitPricePence,
    ) {
        if ($sku === '') {
            throw new InvalidArgumentException('SKU cannot be empty.');
        }

        if ($quantity <= 0) {
            throw new InvalidArgumentException('Quantity must be positive.');
        }

        if ($unitPricePence < 0) {
            throw new InvalidArgumentException('Unit price cannot be negative.');
        }
    }

    public function totalPence(): int
    {
        return $this->quantity * $this->unitPricePence;
    }
}

final class Order
{
    /** @var list<OrderLine> */
    private array $lines = [];

    public function addLine(string $sku, int $quantity, int $unitPricePence): void
    {
        $this->lines[] = new OrderLine($sku, $quantity, $unitPricePence);
    }

    public function totalPence(): int
    {
        $total = 0;

        foreach ($this->lines as $line) {
            $total += $line->totalPence();
        }

        return $total;
    }
}

$order = new Order();
$order->addLine('BOOK', 2, 1500);
$order->addLine('PEN', 1, 250);

echo $order->totalPence() . PHP_EOL;

try {
    $order->addLine('BAD', 0, 999);
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// 3250
// Quantity must be positive.

Order is the aggregate root because outside code works through it to add lines and calculate totals. OrderLine is a value object because it represents a line's values and protects the rule that quantity must be positive.