Domain-Driven Design in Practice: Bounded Contexts and Aggregates

Philip Rehberger Apr 18, 2026 9 min read

DDD sounds great in theory but gets fuzzy fast in practice. Here's a concrete guide to bounded contexts, aggregates, and the tactical patterns that actually matter.

Domain-Driven Design in Practice: Bounded Contexts and Aggregates

Domain-Driven Design (DDD) has been around since Eric Evans published his Blue Book in 2003, yet most teams that adopt it end up with inconsistently applied patterns and the same tangled codebase they started with. The problem isn't usually understanding what bounded contexts or aggregates are in the abstract. It's knowing how to draw the lines in a real system.

This post focuses on practical application. We'll work through identifying bounded contexts, designing aggregates correctly, and connecting it all with the tactical patterns that make DDD actually useful.

Why DDD Fails in Practice

Teams often read about DDD, get excited, and start labeling folders Domain/ and Application/ without doing the hard work of modeling. You end up with a fancy directory structure wrapped around the same anemic domain model you had before.

The foundational shift DDD requires is this: your code's structure should reflect the business domain, not the database or the UI. When a product manager talks about an "order," your Order class should behave the way they mean it, enforce the rules they describe, and live in a context that makes sense to them.

Bounded Contexts: Where to Draw the Lines

A bounded context is a boundary within which a particular domain model applies. The classic example is the word "customer." In sales, a customer is a lead being converted. In billing, a customer has a payment history. In support, a customer has tickets. These are different models of the same real-world entity.

Trying to build a single Customer model that satisfies all three will produce a bloated, over-coupled mess. Instead, each context gets its own model.

Identifying Contexts

Start with these questions:

  • What are the major business capabilities of this system?
  • Which teams or departments own which concepts?
  • Where do the same words mean different things?
  • Where do workflows have clear handoffs between teams?

For an e-commerce platform, you might identify:

  • Catalog - Products, categories, pricing
  • Ordering - Cart, checkout, order lifecycle
  • Fulfillment - Picking, packing, shipping
  • Billing - Invoices, payments, refunds
  • Customer Support - Tickets, returns, escalations

Each context owns its model independently. A Product in Catalog includes rich descriptions, images, and SEO metadata. A Product in Fulfillment might just be a SKU, weight, and warehouse location.

Context Mapping

Once you have your contexts, you need to define how they interact. The context map describes the relationships:

  • Shared Kernel: Two contexts share a small subset of the model. Changes require coordination.
  • Customer/Supplier: Ordering (customer) depends on Catalog (supplier). Catalog shapes its API to serve Ordering's needs.
  • Anti-Corruption Layer (ACL): When integrating with a legacy system or third-party API, the ACL translates their model into yours so their concepts don't leak in.
  • Published Language: A well-documented shared language others integrate against (think REST APIs with versioning).
Catalog ──────(Customer/Supplier)──────► Ordering
                                              │
                                     (Published Language)
                                              │
                                              ▼
Legacy ERP ──(Anti-Corruption Layer)──► Fulfillment

The ACL is often the most important pattern for brownfield projects. Without it, the quirks of your legacy system infect every new context you build.

Aggregates: The Heart of Tactical DDD

An aggregate is a cluster of objects that are treated as a single unit for the purpose of data changes. Every aggregate has a root entity that controls access to everything inside it.

The two rules that matter most:

  1. External objects may only hold references to the aggregate root, never to internal entities.
  2. All invariants within an aggregate must be enforced by the aggregate root before any state change is committed.

Designing Aggregate Boundaries

This is where most developers go wrong. They make aggregates too large, grouping everything that seems related. The result is enormous objects that cause performance problems and contention.

The guiding question is: what must be consistent at the same time?

For an order system:

  • An Order and its LineItems must be consistent together (the total must match the items).
  • An Order and the Product it references do NOT need to be consistent together. If a product's price changes, existing orders should retain their original price.
// Correct: LineItem is inside the Order aggregate
class Order
{
    private OrderId $id;
    private CustomerId $customerId; // Reference by ID only, not the full Customer
    private array $lineItems = [];
    private OrderStatus $status;
    private Money $total;

    public function addItem(ProductId $productId, Money $unitPrice, int $quantity): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new OrderNotModifiableException('Cannot add items to a non-draft order');
        }

        $existing = $this->findLineItem($productId);

        if ($existing) {
            $existing->increaseQuantity($quantity);
        } else {
            $this->lineItems[] = new LineItem($productId, $unitPrice, $quantity);
        }

        $this->recalculateTotal();
    }

    public function place(): void
    {
        if (empty($this->lineItems)) {
            throw new EmptyOrderException('Cannot place an order with no items');
        }

        $this->status = OrderStatus::Placed;
        $this->recordEvent(new OrderPlaced($this->id, $this->customerId, $this->total));
    }

    private function recalculateTotal(): void
    {
        $this->total = array_reduce(
            $this->lineItems,
            fn (Money $carry, LineItem $item) => $carry->add($item->subtotal()),
            Money::zero('USD')
        );
    }
}

Notice that Order enforces its own invariants. You cannot place an empty order. You cannot add items to a non-draft order. These rules live in the domain, not in a service or controller.

Value Objects

Value objects are immutable, equality-by-value objects with no identity of their own. They're one of DDD's most underused patterns.

Instead of passing raw primitives, encapsulate domain concepts:

// Instead of: float $price, string $currency
final class Money
{
    public function __construct(
        private readonly int $amountInCents,
        private readonly string $currency,
    ) {
        if ($amountInCents < 0) {
            throw new InvalidMoneyException('Amount cannot be negative');
        }

        if (!in_array($currency, ['USD', 'EUR', 'GBP'])) {
            throw new UnsupportedCurrencyException($currency);
        }
    }

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

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

    public function multiply(int $factor): self
    {
        return new self($this->amountInCents * $factor, $this->currency);
    }

    public function equals(Money $other): bool
    {
        return $this->amountInCents === $other->amountInCents
            && $this->currency === $other->currency;
    }

    public static function zero(string $currency): self
    {
        return new self(0, $currency);
    }
}

Now Money validates itself, handles arithmetic correctly, and prevents currency mismatches at the type level. Every place you use it gets this protection for free.

Other good candidates for value objects:

  • EmailAddress - validates format on construction
  • PhoneNumber - normalizes format, validates country codes
  • DateRange - enforces start before end, provides duration
  • Percentage - validates 0-100 range
  • Address - groups street, city, postal code as a unit

Domain Services

Sometimes domain logic doesn't belong to a single aggregate. When an operation involves multiple aggregates or requires external input, a domain service is the right home.

// Calculating shipping cost involves Order (size/weight) and external rate lookup
class ShippingCostCalculator
{
    public function __construct(
        private readonly ShippingRateProvider $rateProvider
    ) {}

    public function calculate(Order $order, Address $destination): Money
    {
        $weight = $order->totalWeight();
        $dimensions = $order->totalDimensions();
        $zone = $this->rateProvider->getZone($destination);

        return $this->rateProvider->getRate($weight, $dimensions, $zone);
    }
}

The key distinction: domain services contain business logic and speak the domain's language. Application services (in your controllers or command handlers) orchestrate domain objects and services but contain no business logic themselves.

Repositories

Repositories provide the illusion that your aggregates live in a collection in memory. They hide all persistence details from the domain.

interface OrderRepository
{
    public function findById(OrderId $id): ?Order;
    public function findByCustomer(CustomerId $customerId): array;
    public function save(Order $order): void;
    public function remove(Order $order): void;
}

// The Eloquent implementation lives in the infrastructure layer
class EloquentOrderRepository implements OrderRepository
{
    public function findById(OrderId $id): ?Order
    {
        $record = OrderModel::with('lineItems')->find($id->value());

        return $record ? $this->toDomain($record) : null;
    }

    public function save(Order $order): void
    {
        $data = $this->fromDomain($order);
        OrderModel::updateOrCreate(['id' => $order->id()->value()], $data['order']);

        // Sync line items
        $order->id()->value();
        LineItemModel::where('order_id', $order->id()->value())->delete();
        foreach ($data['lineItems'] as $item) {
            LineItemModel::create($item);
        }

        // Dispatch domain events
        foreach ($order->pullDomainEvents() as $event) {
            event($event);
        }
    }
}

The interface is defined in the domain layer. The implementation is infrastructure. This means you can swap Eloquent for Doctrine, or SQLite for PostgreSQL, without touching domain code.

Putting It Together: The Application Layer

Application services (often called command handlers) coordinate everything. They fetch aggregates from repositories, call domain logic, and persist results. They contain no business rules themselves.

class PlaceOrderHandler
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly ShippingCostCalculator $shippingCalculator,
    ) {}

    public function handle(PlaceOrderCommand $command): OrderId
    {
        $order = $this->orders->findById(OrderId::from($command->orderId))
            ?? throw new OrderNotFoundException($command->orderId);

        $destination = new Address(
            $command->street,
            $command->city,
            $command->postalCode,
            $command->country
        );

        $shippingCost = $this->shippingCalculator->calculate($order, $destination);
        $order->applyShipping($shippingCost, $destination);
        $order->place();

        $this->orders->save($order);

        return $order->id();
    }
}

This handler is thin. It orchestrates but does not decide. If a business rule changes (e.g., free shipping over $100), that change goes into the Order aggregate or ShippingCostCalculator, not here.

Common Pitfalls

Anemic domain model: Your entities are just data bags with getters and setters, and all logic is in services. This negates the point of DDD. Push behavior into your aggregates.

Aggregates that are too large: If your aggregate loads 10 related tables and has 50 methods, it will perform poorly and be a contention bottleneck in concurrent systems. Model smaller, tighter aggregates.

Skipping the Ubiquitous Language: DDD requires that developers and domain experts share a vocabulary. If your code uses accounts and the business team talks about clients, you have a disconnect that will cause bugs. Use their language, even when it's awkward.

Applying DDD everywhere: Not every part of your system needs tactical DDD. A settings page with CRUD operations doesn't need aggregates and repositories. Apply the patterns where the domain is complex and where the benefits justify the overhead.

When DDD Is Worth the Investment

DDD pays off when:

  • The domain is genuinely complex with intricate business rules
  • Multiple teams are working on the same system
  • The system needs to evolve for years, not months
  • Domain experts are available and willing to collaborate

For a simple CRUD application or an MVP, the overhead of full DDD is rarely justified. Start simple and introduce the patterns as complexity demands it.


Tackling complex architecture decisions? We help teams build systems that last. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.