Building a PHP CLI tool using DDD and Event Sourcing: getting started with EventSauce
Last updated: 2023-07-10 :: Published: 2023-05-18 :: [ history ]You can also subscribe to the RSS or Atom feed, or follow me on Twitter.
This post is part of the Building a PHP CLI tool using DDD and Event Sourcing series and uses Laravel Zero as a starting point, but you can also read it as an independent guide to getting started with EventSauce in any PHP application.
EventSauce is a library allowing you to implement Event Sourcing in your PHP applications, an approach to data management involving sequences of events.
I've covered Event Sourcing in a previous part of this series, where I gave the following definition:
Event Sourcing is a software design pattern whereby the state of a system is stored as a sequence of events, rather than as the latest snapshot of that state. Each event represents a change to the system's state and can be used to reconstruct that state at any point in time.
Even though this practice has been around for a while (Martin Fowler's article is from 2005, for instance), I haven't seen it much in PHP applications so far.
It's possible to implement Event Sourcing manually, but some libraries will make that easier for you by providing a base structure and essential components.
There's Spatie's Laravel-event-sourcing package, built for Laravel and that makes a lot of decisions for you. It's great to get started, but some may find it too opinionated and will prefer EventSauce, a framework-agnostic alternative that gives you more control over the components you wish to use.
This post is a step-by-step guide to setting up EventSauce and creating a simple aggregate, following a Test-Driven Development (TDD) approach with Pest.
While it's part of a wider series, no prior knowledge of past articles is required. It uses Laravel Zero as a starting point but can easily be adapted for any PHP application.
In this series
- Why?
- The domain
- The model
- Software design
- Setting up Laravel Zero
- Getting started with EventSauce ⬅️ you are here
- Distribution
In this post
The context
This article is part of a series about my journey building Dime, a PHP command-line tool to help UK taxpayers calculate their crypto-related taxes.
In previous posts, I described the domain of the application, modelled some of its essential aspects and established an implementation plan where I identified appropriate design patterns to use.
I also singled out one of the aggregates as a good starting point for the implementation.
Aggregates and other key patterns
An aggregate is a Domain-Driven Design (DDD) concept I've already talked about in the software design article:
An aggregate is a group of domain objects that are treated as a single, consistent unit within the domain. Aggregates typically consist of one or more entities and/or value objects, and are responsible for enforcing business rules (invariants) within the domain.
I also gave definitions for the entity, value object and invariant patterns:
An entity is a domain object that has a unique identity and a lifecycle that spans multiple interactions with the system. Entities are often referred to as models in non-DDD contexts.
A value object is a domain object that has no identity and is defined entirely by its properties. Unlike entities, value objects are immutable and can be freely shared and copied between different parts of the system without any side effects.
An invariant is a business rule. It's a condition or set of conditions that must always be true within a domain. Invariants are typically enforced by aggregates or aggregate roots and are checked and maintained whenever an event is applied to the system.
And here's a definition for aggregate root, another pattern we are going to use:
An aggregate root is a special type of aggregate that serves as the entry point for all operations on the aggregate. The aggregate root is responsible for ensuring the consistency of the entire aggregate, and is the only object that can modify the state of the aggregate.
Don't worry if it all sounds a bit abstract for now – there will be concrete examples illustrating these concepts throughout the article.
Non-fungible assets
The aggregate we're going to tackle today is the one covering non-fungible assets, otherwise known as NFTs.
NFTs are subject to Capital Gains Tax like any other cryptoasset, but contrary to their fungible counterparts (e.g. Bitcoin), the calculation rules are fairly straightforward.
Let me reuse an example from the domain article:
Example Bob buys an NFT on a platform for £500. Later that day, he sells it for £600. Bob made a £600 - £500 = £100
gain, which is subject to Capital Gains Tax.
Simple. But some use cases are slightly more complicated than that.
Quoting from the domain article again:
Where it gets tricky in my case is that I minted (created) some NFTs in exotic ways, for instance by burning several NFTs to create a new one. In that case, the cost basis* of the new NFT is the sum of the cost bases of the burnt NFTs.
* The cost basis is the cost of acquisition of an asset
I used the following example to illustrate this scenario:
Example Charlie buys an NFT depicting a lion for £500. He then successively buys an NFT of a goat for £300 and an NFT of a snake for £400. He burns the three NFTs to mint a new NFT, depicting the Chimera. A few days later, he sells the Chimera NFT for £2,000. Charlie made a £2,000 - (£500 + £300 + £400) = £800
gain, which is subject to Capital Gains Tax.
The work
The goal of today's post is to track everything that happens to an NFT from the moment it is acquired (bought) to the moment it is disposed of (sold), so we can determine the capital gain or loss to declare.
In other words, anything that changes its state:
- Acquiring the NFT;
- Acquiring the same NFT again, which increases its cost basis; and
- Disposing of the NFT.
We're going to keep track of these changes in an aggregate that can only be updated through an aggregate root.
But hang on – aren't NFTs supposed to be unique? How can we acquire the same NFT more than once?
I came up with this solution to the problem of burning several NFTs to create a new one.
To reuse the previous example, while creating the Chimera out of the lion, goat and snake is technically a single transaction, from an accounting perspective it can also be broken down into three successive transactions, each of them increasing the cost basis of the created NFT:
- Burn the Lion NFT ➡️ The Chimera's cost basis is
£500
; - Burn the Goat NFT ➡️ The Chimera's cost basis is now
£500 + £300 = £800
; - Burn the Snake NFT ➡️ The Chimera's cost basis is now
£800 + £400 = £1,200
.
The way Dime works is it takes a spreadsheet of transactions as input, which it then reads and processes one transaction at a time.
Reporting the three NFT burns as a single row is tricky, so I'm using this method to report one transaction per row instead and keep things clean.
Domain misrepresentation? It could be argued that splitting a single transaction into several ones is misrepresenting the domain.
While there is some truth to that, I couldn't think of another way to report these transactions that wouldn't be confusing to the end user. The selected method also happens to greatly simplify the implementation, which makes it a win from both perspectives.
For these reasons, I'm personally comfortable with this small transgression.
Initial setup
As I'm following a DDD approach to building Dime, most of the action happens in a domain
folder at the root of the project.
This requires properly declaring the domain
folder and its sub-folders in composer.json
and phpunit.xml.dist
, which I've covered in the previous post.
To make things easier though, I've created a separate GitHub repository where everything is set up for you. I invite you to fork and/or clone it on your local machine so you can complete the steps as you go:
$ git clone git@github.com:osteel/eventsauce-tutorial.git
The working branch is start
(which is also the default branch), but you can also see the final result in the finish
branch. Feel free to refer to it at any time.
Once you've cloned the project, instal the Composer dependencies:
$ cd eventsauce-tutorial
$ composer intall
They already include EventSauce and some optional test utilities optimised for Pest (projects not using Pest can import the underlying library instead).
For reference, this is how these were installed:
$ composer require eventsauce/eventsauce
$ composer require --dev eventsauce/pest-utilities
This is what the folder structure looks like at this point:
eventsauce-tutorial/
├── app/
├── bootstrap/
├── config/
├── database/
├── domain/
│ ├── src/
│ └── tests/
├── tests/
└── vendor/
Implementing the aggregate
In a typical TDD fashion, the first thing we do is write a test.
We don't know much about the implementation yet, but we know which aggregate we're starting with and what it is about.
Let's call it NonFungibleAsset
.
Base test case
EventSauce's documentation has a section about testing aggregates where it recommends creating a base test case for the aggregate root, so this is what we will do.
In the domain/tests
folder, create the Aggregates/NonFungibleAsset
folder and add the following NonFungibleAssetTestCase.php
file to it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <?php
namespace Domain\Tests\Aggregates\NonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\ValueObjects\NonFungibleAssetId;
use EventSauce\EventSourcing\AggregateRootId;
use EventSauce\EventSourcing\TestUtilities\AggregateRootTestCase;
abstract class NonFungibleAssetTestCase extends AggregateRootTestCase
{
protected function newAggregateRootId(): AggregateRootId
{
return NonFungibleAssetId::fromString('MonkeyJPEG');
}
protected function aggregateRootClassName(): string
{
return NonFungibleAsset::class;
}
public function handle(object $action)
{
}
}
|
It's basically a copy-and-paste of the example given in the documentation, with a few adjustments to match our aggregate.
We will revisit this class later, but for now, suffice it to say that the AggregateRootTestCase
class it extends will help us test the aggregate root.
First test
Let's write our first test. Create the following NonFungibleAssetTest.php
file in the same directory as NonFungibleAssetTestCase.php
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <?php
use Domain\Tests\Aggregates\NonFungibleAsset\NonFungibleAssetTestCase;
use function EventSauce\EventSourcing\PestTooling\then;
use function EventSauce\EventSourcing\PestTooling\when;
uses(NonFungibleAssetTestCase::class);
it('can acquire a non-fungible asset', function () {
when(new AcquireNonFungibleAsset(
asset: 'MonkeyJPEG',
date: '2015-10-21',
costBasis: 100,
));
then(new NonFungibleAssetAcquired(
date: '2015-10-21',
costBasis: 100,
));
});
|
Note how we imported the NonFungibleAssetTestCase
class using Pest's uses
function at the top. We're also leveraging EventSauce's Pest utilities for the test's syntax, which I will talk about in a minute.
Your folder structure should now look like this:
eventsauce-tutorial/
├── app/
├── bootstrap/
├── config/
├── database/
├── domain/
│ ├── src/
│ └── tests/
│ └── Aggregates/
│ └── NonFungibleAsset/
│ ├── NonFungibleAssetTest.php
│ └── NonFungibleAssetTestCase.php
├── tests/
└── vendor/
To explain what's going on in the test, we need to understand EventSauce's lifecycle. I invite you to read the whole section from the documentation, but the bit that concerns us right now is about performing actions on the aggregate.
We can only change an aggregate's state through its aggregate root. The way it works is we pass an action to the aggregate root, which then checks whether the action violates any of the invariants (the business rules) and records the corresponding event if it doesn't.
In the above test, AcquireNonFungibleAsset
is an action (more specifically a command, but I'll get back to that) – a class with a bunch of properties. NonFungibleAssetAcquired
is the event to be recorded following the action, provided no invariant was violated.
In other words, what we are testing here is that when a valid AcquireNonFungibleAsset
action is performed, then a NonFungibleAssetAcquired
event should be recorded:
<?php
// ...
when(new AcquireNonFungibleAsset(
asset: 'MonkeyJPEG',
date: '2015-10-21',
costBasis: 100,
));
then(new NonFungibleAssetAcquired(
date: '2015-10-21',
costBasis: 100,
));
The when... then structure (and also given, which we'll see later) comes from the Behavior-Driven Development practice (BDD) and is provided by the AggregateRootTestCase
class we mentioned in the previous section. The when
and then
functions themselves come with the Pest utilities (see the documentation for alternative syntaxes).
There'd be more to say about this but for now, let's try and run our test and proceed from there:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
It fails because of this error:
Class "Domain\Tests\Aggregates\NonFungibleAsset\NonFungibleAssetId" not found
We're indeed referencing the NonFungibleAssetId
class from the NonFungibleAssetTestCase
class, but it doesn't exist yet.
Let's create it.
Aggregate root ID
NonFungibleAssetId
is an aggregate root ID, an object EventSauce requires us to provide:
An aggregate root has an identifier. This is called the “aggregate root ID”. It’s good practice to have a unique ID class for every aggregate root.
To make our lives easier though, EventSauce comes with an AggregateRootId
interface that we can implement.
Create a new Aggregates/NonFungibleAsset/ValueObjects
folder in domain/src
and add the following NonFungibleAssetId.php
file to it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php
namespace Domain\Aggregates\NonFungibleAsset\ValueObjects;
use EventSauce\EventSourcing\AggregateRootId;
final readonly class NonFungibleAssetId implements AggregateRootId
{
private function __construct(private string $id)
{
}
public function toString(): string
{
return $this->id;
}
public static function fromString(string $aggregateRootId): static
{
return new self($aggregateRootId);
}
}
|
Your folder structure should now look like this:
eventsauce-tutorial/
├── app/
├── bootstrap/
├── config/
├── database/
├── domain/
│ ├── src/
│ │ └── Aggregates/
│ │ └── NonFungibleAsset/
│ │ └── ValueObjects/
│ │ └── NonFungibleAssetId.php
│ └── tests/
│ └── Aggregates/
│ └── NonFungibleAsset/
│ ├── NonFungibleAssetTest.php
│ └── NonFungibleAssetTestCase.php
├── tests/
└── vendor/
Aggregate root IDs are value objects, because they don't have an identity (they are defined by the properties they are made of) and are immutable (which here is enforced by the readonly
class attribute, available since PHP 8.2).
This can be a bit confusing because, on the other hand, aggregate roots are entities identified by their aggregate root ID, but the latter itself is not an entity.
The fromString
method is a static constructor that takes the NFT ID (a string) as an argument and passes it to the class' private constructor. NFT IDs are already supposed to be unique, so there is no need to use another value to identify the aggregate root.
Let's import the NonFungibleAssetId
class in NonFungibleAssetTestCase
and run the test again:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
We're now getting this error:
Class "AcquireNonFungibleAsset" not found
It's time to build our first action.
First action
A NonFungibleAsset
aggregate's lifecycle starts with the acquisition of an NFT.
The corresponding action is represented by the AcquireNonFungibleAsset
class, which we need to create in a new domain/src/Aggregates/NonFungibleAsset/Actions
folder:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <?php
namespace Domain\Aggregates\NonFungibleAsset\Actions;
final readonly class AcquireNonFungibleAsset
{
public function __construct(
public string $asset,
public string $date,
public int $costBasis,
) {
}
}
|
It doesn't contain any logic because, for the purpose of this article, we only need it to be a Data Transfer Object (DTO). The final implementation will have more to it though (more on this in the blue box below).
Action or Command? I have hinted at this before, but what I've been calling action so far is actually a command.
This term is associated with the CQRS pattern, for Command and Query Responsibility Segregation. It aims at separating read and write operations, which is a natural fit for Event Sourcing – the write operations are the events being recorded and the read operations concern data coming from projections.
The first reason why I'm sticking to action here is that in Laravel, command already describes another kind of object – the console commands handled by Artisan. This is another instance of the "do not fight the framework" principle, which I've talked about in a previous post.
The second reason is that, while actions only need to be DTOs for the scope of this article, in Dime's source code they also contain the logic to perform themselves on the aggregate. Which makes them actions, rather than commands. They do so by leveraging Laravel's command bus, to which they are dispatched as synchronous jobs.
If you want to see what this looks like in practice, go check out Dime's AcquireNonFungibleAsset
class on GitHub as well as the corresponding tests.
Let's import the new AcquireNonFungibleAsset
class in NonFungibleAssetTest
and run the test again:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
It's now complaining about the missing NonFungibleAssetAcquired
class:
Class "NonFungibleAssetAcquired" not found
Let's fix this.
First event
Now that we have the action, we need the event.
Create a new domain/src/Aggregates/NonFungibleAsset/Events
folder and add the following NonFungibleAssetAcquired.php
file to it:
1 2 3 4 5 6 7 8 9 10 11 12 | <?php
namespace Domain\Aggregates\NonFungibleAsset\Events;
final readonly class NonFungibleAssetAcquired
{
public function __construct(
public string $date,
public int $costBasis,
) {
}
}
|
It is almost identical to the action. It does not feature the asset (the NFT ID) though, because as part of EventSauce's lifecycle, events are wrapped in Message
objects that expose the aggregate root ID through the aggregateRootId
method. Since the aggregate root ID is the NFT ID itself, events are already dispatched with the latter, which is then accessible to all event consumers.
One thing to keep in mind when creating events is that they must contain the data you're willing to record. Some of this data will come from the action, and some from the aggregate root itself.
That's all there is to this event for now, so let's import it into NonFungibleAssetTest
and run the test again:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
We're now running into this error:
Domain\tests\Aggregates\NonFungibleAsset\NonFungibleAssetTest > it can acquire a non-fungible asset
expected event count doesnt match recorded event count
Failed asserting that actual size 0 matches expected size 1.
We're making progress – we're now dealing with the test's assertions themselves. The error comes from the fact that while we now have the action and its event, we're still missing the logic connecting them.
The aggregate
We perform actions on the aggregate through its aggregate root, whose role is to ensure an action doesn't violate any invariant before recording the corresponding event.
In other words, the aggregate root is where an action becomes an event. Let's create it.
Add the following NonFungibleAsset.php
file to the domain/src/Aggregates/NonFungibleAsset
folder:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <?php
namespace Domain\Aggregates\NonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\Actions\AcquireNonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\Events\NonFungibleAssetAcquired;
use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootBehaviour;
class NonFungibleAsset implements AggregateRoot
{
use AggregateRootBehaviour;
public function acquire(AcquireNonFungibleAsset $action): void
{
$this->recordThat(new NonFungibleAssetAcquired(
date: $action->date,
costBasis: $action->costBasis,
));
}
public function applyNonFungibleAssetAcquired(NonFungibleAssetAcquired $event): void
{
}
}
|
The class implements the AggregateRoot
interface, which is provided by EventSauce. There's also an AggregateRootBehaviour
trait implementing the corresponding behaviour, including a private constructor expecting an aggregate root ID.
The acquire
method allows us to perform an AcquireNonFungibleAsset
action on the aggregate. All it does at the moment is build a new NonFungibleAssetAcquired
event from the action's properties that it passes to the recordThat
method for recording. This method is provided by the AggregateRootBehaviour
trait and adds the event to the aggregate root.
It also applies the event by calling the corresponding apply
method, which must be implemented for each event.
In the case of NonFungibleAssetAcquired
, that method is applyNonFungibleAssetAcquired
. It's currently empty because we don't need it to do anything at this stage.
Something to keep in mind though is that whenever an aggregate is reconstructed from recorded events, each event's apply
method is called again. I'll get back to that.
For now, let's import the NonFungibleAsset
class in NonFungibleAssetTestCase
and run the test again:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
We're still getting the same error. That's because the acquire
method is not being called on the aggregate root just yet.
EventSauce's test utilities need a little help to know what to do with the actions. Open NonFungibleAssetTestCase
again and change its content for the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <?php
namespace Domain\Tests\Aggregates\NonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\Actions\AcquireNonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\NonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\ValueObjects\NonFungibleAssetId;
use EventSauce\EventSourcing\AggregateRootId;
use EventSauce\EventSourcing\TestUtilities\AggregateRootTestCase;
abstract class NonFungibleAssetTestCase extends AggregateRootTestCase
{
protected function newAggregateRootId(): AggregateRootId
{
return NonFungibleAssetId::fromString('MonkeyJPEG');
}
protected function aggregateRootClassName(): string
{
return NonFungibleAsset::class;
}
public function handle(object $action)
{
$nonFungibleAsset = $this->repository->retrieve($this->aggregateRootId);
if ($action instanceof AcquireNonFungibleAsset) {
$nonFungibleAsset->acquire($action);
}
$this->repository->persist($nonFungibleAsset);
}
}
|
Most of the changes are in the handle
method. The first thing we do is retrieve the aggregate root from $this->repository
, using $this->aggregateRootId
.
Both these properties come from the parent class AggregateRootTestCase
. The former is the default aggregate root repository and the latter was generated through the newAggregateRootId
method, which we've implemented in the NonFungibleAssetTestCase
class.
Aggregate root repository The aggregate root repository is another key concept of EventSauce. It's somewhat out of scope for this article, but its role is to retrieve and persist aggregate root objects.
That's all you need to know for now, but feel free to read about EventSauce's architecture for details.
Once the aggregate is successfully retrieved, we check the action's type and if it is AcquireNonFungibleAsset
, we call the acquire
method on the aggregate root and pass the action to it.
We then have the repository persist the new state of the aggregate root:
$this->repository->persist($nonFungibleAsset);
Run the test again – it is now successful!
Let's take a minute to appreciate our setup so far.
First, we can test our aggregate root and persist events, yet we haven't once mentioned the database. That's because EventSauce's test tooling records everything in memory by default, leaving storage infrastructure concerns for later.
Second, having NonFungibleAssetTestCase
forward the actions to the right aggregate root methods means we can test the aggregate in complete isolation, without having to rely on external dependencies.
On top of that, the BDD-style structure (given... when... then) makes our tests easy to write and read, especially using Pest's minimal and elegant syntax.
We now have everything we need to comfortably implement the rest of the aggregate. Let's see how we can apply event data to the aggregate root and enforce invariants.
First invariant
Recording a NonFungibleAssetAcquired
event upon a valid AcquireNonFungibleAsset
action is a great first step, but that's not the end of the story.
As stated before NFTs are unique, so it should not be possible to acquire the same NFT twice. This is our first invariant.
Let's challenge it with a new test in NonFungibleAssetTest
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?php
// ...
it('cannot acquire the same non-fungible asset more than once', function () {
given(new NonFungibleAssetAcquired(
date: '2015-10-21',
costBasis: 100,
));
when(new AcquireNonFungibleAsset(
asset: 'MonkeyJPEG',
date: '2015-10-22',
costBasis: 100,
));
expectToFail(NonFungibleAssetException::alreadyAcquired('MonkeyJPEG'));
});
|
The test introduces the given
function (that you'll need to import), allowing us to record events as preconditions for the scenario we're testing.
The expectToFail
function makes its first appearance as well (also to be imported) and allows us to specify the exception that should be thrown.
Once again the test reads very easily – given we've acquired a non-fungible asset, when we try to acquire the same asset, then we expect it to fail with a NonFungibleAssetException
exception.
But how do we know that the asset of the NonFungibleAssetAcquired
event and AcquireNonFungibleAsset
action is the same? Because the parent class NonFungibleAssetTestCase
always retrieves the same aggregate root (the one identified by the NFT ID "MonkeyJPEG"), meaning we're only operating on this aggregate root in the test class.
In other words, any event or action we pass to given
, when
and then
in the test class will concern the same aggregate root and thus, the same underlying asset.
The NonFungibleAssetException
exception class doesn't exist yet – let's create it in a new domain/src/Aggregates/NonFungibleAsset/Exceptions
folder:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?php
namespace Domain\Aggregates\NonFungibleAsset\Exceptions;
use RuntimeException;
final class NonFungibleAssetException extends RuntimeException
{
private function __construct(string $message)
{
parent::__construct($message);
}
public static function alreadyAcquired(string $asset): self
{
return new self(sprintf('Non-fungible asset %s has already been acquired', $asset));
}
}
|
It's using a static named constructor that calls a private constructor, making the building of exceptions very explicit.
Let's import this class in NonFungibleAssetTest
and run the tests again:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
It's complaining about the exception not being thrown:
Domain\tests\Aggregates\NonFungibleAsset\NonFungibleAssetTest > it cannot acquire the same non-fungible asset more than once
FailedToDetectExpectedException
Failed to detect expected exception, was expecting exception of type Domain\Aggregates\NonFungibleAsset\Exceptions\NonFungibleAssetException
We now need to guard this invariant at the aggregate root level, which implies knowing when an NFT was already acquired.
To do this, we're going to add a boolean $acquired
property to the aggregate root and set it to true
whenever the initial acquisition occurs.
Update the content of NonFungibleAsset
for the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php
namespace Domain\Aggregates\NonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\Actions\AcquireNonFungibleAsset;
use Domain\Aggregates\NonFungibleAsset\Events\NonFungibleAssetAcquired;
use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootBehaviour;
class NonFungibleAsset implements AggregateRoot
{
use AggregateRootBehaviour;
private bool $acquired = false;
public function acquire(AcquireNonFungibleAsset $action): void
{
$this->recordThat(new NonFungibleAssetAcquired(
date: $action->date,
costBasis: $action->costBasis,
));
}
public function applyNonFungibleAssetAcquired(NonFungibleAssetAcquired $event): void
{
$this->acquired = true;
}
}
|
By changing the value of $acquired
from the applyNonFungibleAssetAcquired
method, we're making sure that the property will always get the correct value upon reconstructing the aggregate root from the events.
If we did that from the acquire
method instead, the value would correctly be set the first time the action is performed, but not whenever the aggregate is reconstructed later on.
Remember – only the events are replayed, not the actions that caused them.
So how can we enforce the invariant? By updating the acquire
method:
<?php
// ...
public function acquire(AcquireNonFungibleAsset $action): void
{
throw_if(
$this->acquired,
NonFungibleAssetException::alreadyAcquired($this->aggregateRootId->toString()),
);
$this->recordThat(new NonFungibleAssetAcquired(
date: $action->date,
costBasis: $action->costBasis,
));
}
If the asset was already acquired, we throw a NonFungibleAssetException::alreadyAcquired
exception. We pass the NFT ID to it, coming from $this->aggregateRootId
, which contains the aggregate root ID.
This time around the logic is in the acquire
method because the role of the aggregate root is to block invalid actions. An event can only be recorded if the action was legitimate in the first place – there is no such thing as an invalid event.
In other words, a NonFungibleAssetAcquired
event will only be passed to the applyNonFungibleAssetAcquired
method if it's already valid, meaning invariant validation has to happen upstream, from the acquire
method.
Let's run the test again:
$ ./vendor/bin/pest --filter=NonFungibleAssetTest
It is now successful. Note that while we never check the value of the aggregate root's $acquired
property directly, we do so indirectly by testing the invariant, as an exception will only be thrown if $acquired
was correctly set to true
in the first place.
Let's talk about this for a minute.
Testing an aggregate's state
Say we now want to keep track of the NFT's cost basis at the aggregate root level.
We can do this by adding a $costBasis
property that we populate from applyNonFungibleAssetAcquired
:
<?php
// ...
private int $costBasis = 0;
// ...
public function applyNonFungibleAssetAcquired(NonFungibleAssetAcquired $event): void
{
$this->acquired = true;
$this->costBasis = $event->costBasis;
}
The cost basis is now part of the aggregate root. But how shall we test that its value is correct? $costBasis
is a private property and there is no invariant relying on its value that we can test instead.
The short answer is we don't.
This is something I struggled with initially, but that actually makes sense. As things stand, there's no clean way to directly check the values assigned to the aggregate root through one of the apply
methods.
We could try and make $costBasis
public and read-only, but that won't work because its value can change over time (see next section). And if we make it public and not read-only, its value can now be changed from the outside, which defeats the purpose of the aggregate root.
There is a whole explanation as to why you don't test an aggregate's state directly, which is based on a conversation with Frank de Jonge, the author of EventSauce. The gist is that the aggregate's only job is to generate and record events following decisions based on invariants, and nothing more. So that is what we test.
Here, we decided to save the cost basis in the aggregate root, but it wasn't a requirement at this stage. When this property is used for other purposes down the line, there will be tests controlling the corresponding behaviours, indirectly validating its value.
This is actually quite nice because it allows us to follow a "cross that bridge when we get to it" approach, which is also a sign of a clear separation of concerns.
And as it happens, we'll cross that bridge in the next section.
Increasing the NFT's cost basis
We are now about to address the second type of state change, which is to increase the NFT's cost basis whenever it was created from several other NFTs.
As this is going to be somewhat similar to what we've done in previous sections, I will not cover it in so much detail.
There's only one invariant to enforce – that we cannot increase the cost basis of an NFT that wasn't acquired yet.
Let's add a test covering a valid cost basis increase:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php
// ...
it('can increase the cost basis of a non-fungible asset', function () {
given(new NonFungibleAssetAcquired(
date: '2015-10-21',
costBasis: 100,
));
when(new IncreaseNonFungibleAssetCostBasis(
asset: 'MonkeyJPEG',
date: '2015-10-22',
costBasisIncrease: 50,
));
then(new NonFungibleAssetCostBasisIncreased(
date: '2015-10-22',
costBasisIncrease: 50,
costBasis: 150,
));
});
|
And another one checking the invariant:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <?php
// ...
it('cannot increase the cost basis of a non-fungible asset that has not been acquired', function () {
when(new IncreaseNonFungibleAssetCostBasis(
asset: 'MonkeyJPEG',
date: '2015-10-21',
costBasisIncrease: 100,
));
expectToFail(NonFungibleAssetException::notAcquired('MonkeyJPEG'));
});
|
Both tests should fail. A few classes are missing, starting with the action and event.
Add the following IncreaseNonFungibleAssetCostBasis.php
file to domain/src/Aggregates/NonFungibleAsset/Actions
:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <?php
namespace Domain\Aggregates\NonFungibleAsset\Actions;
final readonly class IncreaseNonFungibleAssetCostBasis
{
public function __construct(
public string $asset,
public string $date,
public int $costBasisIncrease,
) {
}
}
|
And this NonFungibleAssetCostBasisIncreased.php
file to domain/src/Aggregates/NonFungibleAsset/Events
:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <?php
namespace Domain\Aggregates\NonFungibleAsset\Events;
final readonly class NonFungibleAssetCostBasisIncreased
{
public function __construct(
public string $date,
public int $costBasisIncrease,
public int $costBasis,
) {
}
}
|
This time around the event has an extra property that the action doesn't – $costBasis
. This property will receive the new total cost basis, which is the sum of the previous cost basis plus the increase.
Import the action and event in NonFungibleAssetTest
and update the handle
method of NonFungibleAssetTestCase
, so it knows what to do when it receives an IncreaseNonFungibleAssetCostBasis
action:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?php
// ...
public function handle(object $action)
{
$nonFungibleAsset = $this->repository->retrieve($this->aggregateRootId);
match ($action::class) {
AcquireNonFungibleAsset::class => $nonFungibleAsset->acquire($action),
IncreaseNonFungibleAssetCostBasis::class => $nonFungibleAsset->increaseCostBasis($action),
};
$this->repository->persist($nonFungibleAsset);
}
|
Note that we've replaced the if
condition with a match
expression, so we can handle more actions without creating a mess.
We now need to implement the increaseCostBasis
method on the NonFungibleAsset
aggregate root, as well as the corresponding applyNonFungibleAssetCostBasisIncreased
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php
// ...
public function increaseCostBasis(IncreaseNonFungibleAssetCostBasis $action): void
{
$this->recordThat(new NonFungibleAssetCostBasisIncreased(
date: $action->date,
costBasisIncrease: $action->costBasisIncrease,
costBasis: $this->costBasis + $action->costBasisIncrease,
));
}
public function applyNonFungibleAssetCostBasisIncreased(NonFungibleAssetCostBasisIncreased $event): void
{
$this->costBasis = $event->costBasis;
}
|
Run the tests again – the first one should now pass since we always record a NonFungibleAssetCostBasisIncreased
event when performing an IncreaseNonFungibleAssetCostBasis
action. We're not enforcing the invariant yet, hence the second test fails.
At the end of the previous section, we concluded that the only way to make sure the aggregate root's $costBasis
property had the correct value would be to test that value indirectly, through some other logic that needs it. And that's exactly what we've just done.
The test makes sure that the following event is recorded:
<?php
// ...
then(new NonFungibleAssetCostBasisIncreased(
date: '2015-10-22',
costBasisIncrease: 50,
costBasis: 150,
));
Since the costBasis
argument is the result of the previous cost basis plus the increase, controlling its value through this event means we're indirectly confirming that the previous cost basis was correct in the first place.
We know now that the new cost basis passed to the NonFungibleAssetCostBasisIncreased
event is correct, but we have no way to be sure that the aggregate root's $costBasis
was correctly updated from applyNonFungibleAssetCostBasisIncreased
:
<?php
// ...
public function applyNonFungibleAssetCostBasisIncreased(NonFungibleAssetCostBasisIncreased $event): void
{
$this->costBasis = $event->costBasis;
}
That's OK – there will be an opportunity to do just that at some point too, when this value is needed for something else.
Let's now enforce the invariant so the other test passes as well:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <?php
// ...
public function increaseCostBasis(IncreaseNonFungibleAssetCostBasis $action): void
{
throw_unless($this->acquired, NonFungibleAssetException::notAcquired('MonkeyJPEG'));
$this->recordThat(new NonFungibleAssetCostBasisIncreased(
date: $action->date,
costBasisIncrease: $action->costBasisIncrease,
costBasis: $this->costBasis + $action->costBasisIncrease,
));
}
|
We check whether the asset was acquired by making sure $this->acquired
is true
, and we throw a NonFungibleAssetException::notAcquired
exception if that's not the case.
This exception doesn't exist yet – we need to add it to the NonFungibleAssetException
class:
1 2 3 4 5 6 7 8 | <?php
// ...
public static function notAcquired(string $asset): self
{
return new self(sprintf('Non-fungible asset %s has not been acquired', $asset));
}
|
Run the test again – it should now be successful.
A quick note about this section. The above implies that it is the responsibility of external components to know when they should call the aggregate root's acquire
or increaseCostBasis
method, depending on whether the NFT was acquired.
This is fine because the aggregate root won't allow for acquiring the same NFT several times anyway, nor will it increase the cost basis of an NFT that hasn't been acquired yet. We've made sure that an invalid state is impossible, so we can safely let external components figure out which actions to perform, knowing they can't make mistakes.
This is what it means for the aggregate root to be responsible for the consistency of the aggregate.
If you want to see an example of what the corresponding logic looks like, check out the AcquireNonFungibleAsset
action as implemented in Dime.
Disposing of the NFT
This is the last state change we need to cover. It is fairly similar to the other ones, so if you're game, I invite you to try and implement it by yourself. I'll just give the instructions.
Remember that you can check the solution in the repository though, and that you can refer to previous sections if you feel like you've missed something.
Disposing of an NFT is a separate action and event. The action doesn't feature a cost basis but must contain the proceeds of the disposal instead, which is the amount the NFT was sold for.
The event, on the other hand, should not only have the proceeds but also the total cost basis and the corresponding capital gain (which can be negative).
The only invariant is that we cannot dispose of an NFT that wasn't acquired yet.
Finally, disposing of the NFT should reset the state of the aggregate.
Good luck!
Closing thoughts
This aggregate is an extremely simplified example and its implementation leaves a lot to be desired.
Just to name a few issues – dates should not be strings, the various amounts should be decimal values and include a currency, and some invariants are missing, like making sure transactions are in chronological order.
These are domain concerns that would have got in the way of the article's main purpose, however, which was to introduce EventSauce.
In that respect, it barely scratches the surface – implementing and testing aggregates in isolation is only one of the many features EventSauce has to offer.
The documentation is a good place to continue your exploration, and if you need some inspiration you can refer to Dime's repository to see how things are implemented there. There's also a Slack workspace for you to join, where you can ask for help and chat with other EventSauce users.
The next (and last) post of this series will cover the distribution of Dime. Subscribe to email alerts below so you don't miss it, or follow me on Twitter where I will share it as soon as they are available.
Big thanks to Robert Baelde and Frank de Jonge for their feedback.
You can also subscribe to the RSS or Atom feed, or follow me on Twitter.