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:
- External objects may only hold references to the aggregate root, never to internal entities.
- 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
Orderand itsLineItemsmust be consistent together (the total must match the items). - An
Orderand theProductit 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 constructionPhoneNumber- normalizes format, validates country codesDateRange- enforces start before end, provides durationPercentage- validates 0-100 rangeAddress- 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