DEV Community

Benjamin Delespierre
Benjamin Delespierre

Posted on • Updated on

Domain Driven Design Aggregates with Laravel

Lately, I've been focusing on finding ways to bring Laravel and Domain Driven Design closer together. Because I love ๐Ÿ˜ Laravel, but its architecture sucks ๐Ÿคฎ.

So today, we're going to look at how to implement aggregates using Laravel & Eloquent.

Let's get started!

What's an aggregate?

A DDD aggregate is a cluster of domain objects that can be treated as a single unit. An example may be an order and its line-items, these will be separate objects, but it's useful to treat the order (together with its line items) as a single aggregate.
โ€” Martin Fowler, DDD_Aggregate

Aggregates are not just clusters of data and behavior. The primary reason for the pattern is to protect business invariants for a single aggregate instance in a single transaction.
โ€” Vaughn Vernon

What's an invariant?

Invariants are generally business rules/enforcements/requirements that you impose to maintain the integrity of an object at any given time.
โ€” Nikesh Shetty, Designing the DDD way โ€” Introduction

Example

Let's implement an OrderAggregate that embodies the following invariants:

  • customers make oders,
  • an order consists of a collection of items,
  • an order is uniquely identified,
  • an order must have a creation timestamp,
  • an order must have a shipment address,
  • it must be possible to calculate the total of an order, which is the sum of its items,
  • all items should have the same currency,
namespace Domain\Model;

use App\Models\Address;
use App\Models\Amount;
use App\Models\Currency;
use App\Models\CustomerId;
use App\Models\LineItem;
use App\Models\Order;

class OrderAggregate
{
    /** @param LineItem[] $lineItems */
    public function __construct(
        private Order $root,
        private CustomerId $customerId,
        private Address $shipmentAddress,
        private Currency $currency,
        private array $lineItems = []
    ) {
        $this->root = $root;
        $this->customerId = $customerId;
        $this->shipmentAddress = $shipmentAddress;
        $this->curreny = $currency;
        $this->lineItems = $lineItems;
    }

    public function getRoot(): Order
    {
        return $this->order;
    }

    public function createdAt(): \DateTime
    {
        return $this->root->created_at;
    }

    public function getCustomerId(): CustomerId
    {
        return $this->customerId;
    }

    public function getShipmentAddress(): Address
    {
        return $this->shipmentAddress;
    }

    public function getTotal(): Amount
    {
        $total = 0;

        foreach ($this->getLineItems() as $item) {
            $total += $item->unit_price->getValue() * $item->quantity;
        }

        return new Amount($total, $this->currency);
    }

    /** @return LineItem[] */
    public function getLineItems(): array
    {
        return $this->lineItems;
    }

    /** @throws \DomainException If $item currency is not compatible with this order */
    public function addLineItem(LineItem $item): void
    {
        if (! $this->curreny->isEqualTo($item->unit_price->currency)) {
            throw new  \DomainException(
                "Unable to add item: invalid currency",
            );
        }

        $this->lineItems[] = $item;
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: if you need help with value objects and Eloquent I wrote an article on the topic

What can we put in an aggregate?

An aggregate will have one of its component objects be the aggregate root. Any references from outside the aggregate should only go to the aggregate root. The root can thus ensure the integrity of the aggregate as a whole.
โ€” Martin Fowler

Beyond that, they may contain:

  • Entities
  • Collections, Lists, Sets, etc.
  • Value objects
  • Value-typed properties (integers, strings, booleans etc.)

You may think of it as a document holding ALL the data necessary to a given transaction (or use case.)

Tip: Eloquent makes it easy to implement lazy-loading in your aggregates. In the above example, we could restructure the getLineItems method so that it loads when it's used:

public function getLineItems(): array
{
    return $this->getRoot()->items()->get()->toArray();
}
Enter fullscreen mode Exit fullscreen mode

Can they have commands?

Yes. And they should.

You are not supposed to do:

$car->getEngine()->start();
Enter fullscreen mode Exit fullscreen mode

But rather:

$car->start();
Enter fullscreen mode Exit fullscreen mode

Forcing the exposure of aggregate's internal structure is bad design ๐Ÿ‘Ž

How do I persist/retrieve them?

You're going to use the Repository Pattern:

namespace App\Repositories;

use App\Models\Order;
use App\Models\OrderAggregate;

class OrderRepository
{
    public function find(int $id): OrderAggregate
    {
        $model = Order::with('items')->findOrFail($id);

        return new OrderAggregate(
            $model,
            $model->customer_id,
            $model->shipment_address,
            $model->currency,
            $model->items->toArray()
        );
    }

    public function store(OrderAggregate $order): void
    {
        \DB::transaction(function () use ($order) {
            $order->getRoot()
                ->fill([
                    'customer_id' => $order->getCustomerId(),
                    'shipment_address' => $order->getShipmentAddress(),
                    'currency' => $order->getCurrency(),
                ])
                ->save();

            foreach ($order->getLineItems() as $item) {
                $item->order()->associate($order->getRoot())->save();
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Rules for making your aggregates pretty ๐Ÿ’…

From the awesome article series by Vaughn Vernon ๐Ÿคฉ

Rule #1: Keep them small. It is tempting to cram one giant aggregate with anything every use case present and future might need. But it's a terrible design. You're going to run into performances and concurrency issues (when several people are working on the same aggregate at the same time).

It's better to have several representations of order, depending on the broader context, than one. For instance, an order from a cart display page's point-of-view is not the same as from a billing system.

If we are going to design small aggregates, what does โ€œsmallโ€ mean? The extreme would be an aggregate with only its globally unique identity and one additional attribute, which is not what's being recommended [...].
Rather, limit the aggregate to just the root entity and a minimal number of attributes and/or value-typed properties. The correct minimum is the ones necessary, and no more.
Smaller aggregates not only perform and scale better, they are also biased toward transactional success, meaning that conflicts preventing [an update] are rare. This makes a system more usable.
โ€” Vaughn Vernon

Rule #2: Model true invariants in consistency boundaries. It sounds barbaric, but it's pretty simple; it means that, within a single transaction, there is no way one could break the aggregate consistency (its compliance to business rules.)

In other words, it should be impossible to create a bugged version of an aggregate from calling its methods.

One implication of this rule is that a transaction should only commit a single aggregate, since it's not possible by design to guarantee the consistency of several aggregates at once.

A properly designed aggregate is one that can be modified in any way required by the business with its invariants completely consistent within a single transaction.
And a properly designed bounded context modifies only one aggregate instance per transaction in all cases. What is more, we cannot correctly reason on aggregate design without applying transactional analysis.
Limiting the modification of one aggregate instance per transaction may sound overly strict. However, it is a rule of thumb and should be the goal in most cases. It addresses the very reason to use aggregates.
โ€” Vaughn Vernon

Rule #3: Don't Trust Every Use Case. Don't blindly assemble your aggregates based on what the use case specification dictates. They may contain elements that contradict the existing model or force you into committing several aggregates in a single transaction or worse, to model a giant aggregate that fits in a single transaction.

Apply your judgment here and keep in mind that sometimes, the business goal can be achieved using eventual consistency.

The team should critically examine the use cases and challenge their assumptions, especially when following them as written would lead to unwieldy designs.
The team may have to rewrite the use case (or at least re-imagine it if they face an uncooperative business analyst).
The new use case would specify eventual consistency and the acceptable update delay.
โ€” Vaughn Vernon

Conclusion

Murphy's law states:

Anything that can possibly go wrong, does.

An aggregate is a tactic to mitigates that problem. Within its well-designed boundaries, nothing can go wrong. You can say goodbye to those pesky ifs laying around in your code, handling those cases that are not supposed to happen but happen anyway.

Don't allow your model to grow beyond your control. Stop using raw data, POPOs, and unguarded Laravel models whose state is uncertain everywhere in your application. Use aggregates instead ๐Ÿ‘ and connect your model to the actual business your app is supposed to carry.


Thanks for reading

I hope you enjoyed reading this article! If so, please leave a โค๏ธ or a ๐Ÿฆ„ and consider subscribing! I write posts on PHP, architecture, and Laravel monthly.

A huge thanks to Vaughn Vernon for his review and his articles on DDD ๐Ÿ™

Top comments (4)

Collapse
 
etshy profile image
Etshy

Really interesting article.
Though I have a question about the Repository code example.
I don't understand this line in the find method : $model = Order::with('items')->findOrFail($id);

You call a Domain model method to get it, so I guess the findOrFail method is calling a repo to get the Order data from DB ? Or am I missing somehting there ?

Collapse
 
chuniversiteit profile image
Chun Fei Lung

Laravel and domain-driven design both are good in their own way, but they donโ€™t get along very well unless you do some very un-Laravel-y things.

Good to see that there are people who try (and manage) to make it happen anyway. ๐Ÿ˜„

Collapse
 
bdelespierre profile image
Benjamin Delespierre

It's true it's not easy to use DDD in Laravel without rebuilding a lot of systems from the ground up. I'm tryring to find a middle ground here so we can use DDD & UML and bring some consistency to the hot mess Laravel apps tend to become after a few years ๐Ÿ˜…

Speaking of which, Laravel has native support for value objects in Eloquent models, check it out!

Collapse
 
bdelespierre profile image
Benjamin Delespierre

Thanks for your feedback ๐Ÿค— Be sure to come back to share your experience on that matter with us ๐Ÿ‘