Snapshots
Some aggregates can have a large number of events.
This is not a problem if there are a few hundred.
But if the number gets bigger at some point, then loading and rebuilding can become slow.
The snapshot
system can be used to control this.
Normally, the events are all executed again on the aggregate in order to rebuild the current state.
With a snapshot
, we can shorten the way in which we temporarily save the current state of the aggregate.
When loading it is checked whether the snapshot exists.
If a hit exists, the aggregate is built up with the help of the snapshot.
A check is then made to see whether further events have existed since the snapshot
and these are then also executed on the aggregate.
Here, however, only the last events are loaded from the database and not all.
Configuration
First of all you have to define a snapshot store. This store may have multiple adapters for different caches. These caches also need a name so that you can determine which aggregates should be stored in which cache.
use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager;
use Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter;
use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore;
$snapshotStore = new DefaultSnapshotStore([
'default' => new Psr16SnapshotAdapter($defaultCache),
'other_cache' => new Psr16SnapshotAdapter($otherCache),
]);
After creating the snapshot store, you need to pass that store to the DefaultRepositoryManager.
use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager;
$snapshotStore = // ...
$repositoryManager = new DefaultRepositoryManager(
$aggregateRootRegistry,
$store,
$eventBus,
$snapshotStore
);
Note
You can read more about Repository here.
Next we need to tell the Aggregate to take a snapshot of it. We do this using the snapshot attribute. There we also specify where it should be saved.
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Snapshot;
#[Aggregate('profile')]
#[Snapshot('default')]
final class Profile extends AggregateRoot
{
// ...
}
When taking a snapshot, all properties are extracted and saved. When loading, this data is written back to the properties. In other words, in the end everything has to be serializable. To ensure this, the same system is used as for the events. You can define normalizers to bring the properties into the correct format.
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Snapshot;
#[Aggregate('profile')]
#[Snapshot('default')]
final class Profile extends AggregateRoot
{
public string $id;
public string $name,
#[Normalize(new DateTimeImmutableNormalizer())]
public DateTimeImmutable $createdAt;
// ...
}
Danger
If anything changes in the properties of the aggregate, then the cache must be cleared. Or the snapshot version needs to be changed so that the previous snapshot is invalid.
Warning
In the end it has to be possible to serialize it as json.
Note
You can find more about normalizer here.
Snapshot batching
Since the loading of events in itself is quite fast and only becomes noticeably slower with thousands of events,
we do not need to create a snapshot after each event. That would also have a negative impact on performance.
Instead, we can also create a snapshot after N
events.
The remaining events that are not in the snapshot are then loaded from store.
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Snapshot;
#[Aggregate('profile')]
#[Snapshot('default', batch: 1000)]
final class Profile extends AggregateRoot
{
// ...
}
Snapshot versioning
Whenever something changes on the aggregate, the previous snapshot must be discarded. You can do this by removing the entire snapshot cache when deploying. But that can be quickly forgotten. It is much easier to specify a snapshot version. This snapshot version is also saved. When loading, the versions are compared and if they do not match, the snapshot is discarded and the aggregate is rebuilt from scratch. The new aggregate is then saved again as a snapshot.
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Snapshot;
#[Aggregate('profile')]
#[Snapshot('default', version: '2')]
final class Profile extends AggregateRoot
{
// ...
}
Warning
If the snapshots are discarded, a load peak can occur since the aggregates have to be rebuilt.
Tip
You can also use uuids for the snapshot version.
Adapter
We offer a few SnapshotAdapter
implementations that you can use.
But not a direct implementation of a cache.
There are many good libraries out there that address this problem,
and before we reinvent the wheel, choose one of them.
Since there is a psr-6 and psr-16 standard, there are plenty of libraries.
Here are a few listed:
psr6
A Psr6SnapshotAdapter
, the associated documentation can be found here.
use Patchlevel\EventSourcing\Snapshot\Adapter\Psr6SnapshotAdapter;
$adapter = new Psr6SnapshotAdapter($cache);
psr16
A Psr16SnapshotAdapter
, the associated documentation can be found here.
use Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter;
$adapter = new Psr16SnapshotAdapter($cache);
in memory
A InMemorySnapshotAdapter
that can be used for test purposes.