An aggregate is a class that decides to record events based on past events. To know more about their general purpose and the idea behind them, read this section on using aggregates to make decisions-based-on-the-past.
##Creating an aggregate
The easiest way to create an aggregate root would be to use the make:aggregate
command:
php artisan make:aggregate AccountAggregate
This will create a class like this:
namespace App\Aggregates;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
class AccountAggregate extends AggregateRoot
{
}
##Recording events
You can add any methods or variables you need on the aggregate. To get you familiar with event modelling using aggregates let's implement a small piece of the Larabank example app. We are going to add methods to record the AccountCreated
, MoneyAdded
and the MoneySubtracted
events.
First, let's add a createAccount
method to our aggregate that will record the AccountCreated
event.
namespace App\Aggregates;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
class AccountAggregate extends AggregateRoot
{
public function createAccount(string $name, string $userId)
{
$this->recordThat(new AccountCreated($name, $userId));
return $this;
}
public function addMoney(int $amount)
{
$this->recordThat(new MoneyAdded($amount));
return $this;
}
public function subtractAmount(int $amount)
{
$this->recordThat(new MoneySubtracted($amount));
return $this;
}
public function deleteAccount()
{
$this->recordThat(new AccountDeleted());
return $this;
}
}
The recordThat
function will not persist the events to the database. It will simply hold them in memory. The events will get written to the database when the aggregate itself is persisted.
There are two things to notice. First, the method names are written in the present tense, not the past tense. We're trying to do something, and for the rest of our application it hasn't happened yet until the actual AccountCreated
is saved. This will only happen when the AccountAggregate
gets persisted.
The second thing to note is that nor the method and the event contain an uuid. The aggregate itself is aware of the uuid to use because it is passed to the retrieve method (AccountAggregate::retrieve($uuid)
, we'll get to this in a bit). When persisting the aggregateroot, it will save the recorded events along with the uuid.
With this in place you can use the aggregate like this:
AccountAggregate::retrieve($uuid)
->createAccount('my account', auth()->user()->id)
->persist();
AccountAggregate::retrieve($uuid)
->addMoney(123)
->persist();
AccountAggregate::retrieve($uuid)
->subtractMoney(456)
->persist();
When persisting an aggregate all newly recorded events inside aggregate root will be saved to the database. The newly recorded events will also get passed to all projectors and reactors that listen for them.
In our demo app we retrieve and persist the aggregate in the AccountsController
. The package has no opinion on where you should interact with aggregates. Do whatever you wish.
##Implementing our first business rule
Let's now implement the rule that an account cannot go below -$5000. Here's the thing to keep in mind: when retrieving an aggregate all events for the given uuid will be retrieved and will be passed to methods named apply<className>
on the aggregate.
So for our aggregate to receive all past MoneyAdded
and MoneySubtracted
events we need to add applyMoneyAdded
andapplyMoneySubtracted
methods to our aggregate. Because those events are all fed to the same instance of the aggregate, we can simply add an instance variable to hold the calculated balance.
private $balance = 0;
public function applyMoneyAdded(MoneyAdded $event)
{
$this->balance += $event->amount;
}
public function applyMoneySubtracted(MoneySubtracted $event)
{
$this->balance -= $event->amount;
}
Now that we have the balance of the account in memory, we can add a simple check to subtractAmount
to prevents an event from being recorded.
public function subtractAmount(int $amount)
{
if (! $this->hasSufficientFundsToSubtractAmount($amount) {
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
private function hasSufficientFundsToSubtractAmount(int $amount): bool
{
return $this->balance - $amount >= $this->accountLimit;
}
##Implementing another business rule
We can take this one step further. You could also record the event that the account limit was hit.
public function subtractAmount(int $amount)
{
if (! $this->hasSufficientFundsToSubtractAmount($amount) {
$this->recordThat(new AccountLimitHit($amount));
$this->persist();
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
Let's now add a new business rule. Whenever somebody hits the limit three times a loan proposal should be sent. We can implement that as such.
private $accountLimitHitCount = 0;
public function applyAccountLimitHit()
{
$this->accountLimitHitCount++;
}
public function subtractAmount(int $amount)
{
if (! $this->hasSufficientFundsToSubtractAmount($amount) {
$this->recordThat(new AccountLimitHit($amount));
if ($this->accountLimitHitCount === 3) {
$this->recordThat(new LoanProposed());
}
$this->persist();
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
When the limit is hit three times, we record another event LoanProposed
. We could set up a reactor that listens for that event and sends the actual mail.
If you want to toy around with this example, clone the Larabank with aggregates example.
##Want to know more?
Aggregate roots are a crucial part in large applications. Our course, Event Sourcing in Laravel covers them in depth:
-
- Aggregate Roots
-
- State Management in Aggregate Roots
-
- Multi-Entity Aggregate Roots
-
- State Machines with Aggregate Entities