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_AggregateAggregates 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;
}
}
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();
}
Can they have commands?
Yes. And they should.
You are not supposed to do:
$car->getEngine()->start();
But rather:
$car->start();
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();
}
});
}
}
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 (6)
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. 😄
I think they get along pretty well. There is like a 5 minute process to add a domain folder that is autoloaded and you are pretty much ready to go. There are packages that helps you get even further very easily. Personally I really like github.com/lunarstorm/laravel-ddd - It's a very lightweight DDD helper that is not that opinionated and does not make many assumptions on how you architect you application.
That lib look dope.
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!
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 ?Thanks for your feedback 🤗 Be sure to come back to share your experience on that matter with us 👍