Implementing event sourcing: improving the developer experience
Recently we've released v2 of laravel-event-projector. The package is probably the easiest way to get started with event sourcing in Laravel. In v2 we've introduced two "invisible" features that improve the developer experience: auto-detection of event handling methods and auto-discovery of event handlers.
Even if you don't know anything about event sourcing, you should be able to follow most of this post. To get a basic understanding of what our package does, head over to the intro section of the laravel-event-projector docs.
What are event handlers?
Event handlers are classes that will get called when certain events are fired. Here's an example projector (a projector is a particular type of event handler).
class BalanceProjector implements Projector
{
use ProjectsEvents;
protected $handlesEvents = [
MoneyAddedEvent::class => 'onMoneyAdded',
MoneySubtractedEvent::class => 'onMoneySubtracted',
];
public function onMoneyAdded(MoneyAddedEvent $event)
{
// do some work
}
public function onMoneySubtracted(MoneySubtractedEvent $event)
{
// do some work
}
}
Auto detect event handling methods
That handlesEvent
array determines which functions should should get called when specific events come in. For example, when this projector receives a MoneyAddedEvent
the onMoneyAdded
function should get called. If you take a look at that function, you see that the event we want to receive is type hinted.
Wouldn't it be cool if users wouldn't have to code up an handlesEvent
array and that the package would automatically call functions that typehint an event? In the newest version of event-projector, we did just that.
Let's take a look at the relevant code from the HandlesEvents
-trait which is applied to every event handler. The autoDetectHandlesEvents
will use some reflection to get each method on the event handler. Next, for each function that was found, it will take a look if the first parameter is a class that implements ShouldBeStored
.
private function autoDetectHandlesEvents(): Collection
{
return collect((new ReflectionClass($this))->getMethods())
->flatMap(function (ReflectionMethod $method) {
$method = new ReflectionMethod($this, $method->name);
$eventClass = collect($method->getParameters())
->map(function (ReflectionParameter $parameter) {
return optional($parameter->getType())->getName();
})
->first(function ($typeHint) {
return is_subclass_of($typeHint, ShouldBeStored::class);
});
if (! $eventClass) {
return;
}
return [$eventClass => $method->name];
})
->filter();
The autoDetectHandlesEvents
will return an array that is used to route the events to the right method name.
Auto register event handlers
In v1 of the package, each event handler had to be registered at the Projectionist
. The Projectionist
is a class that will make sure that event handlers will get called when certain events are fired.
Projectionist::addProjector(MyProjector::class);
Projectionist::addReactor(MyReactor::class);
In the newest version of the package, you don't need to do this manually anymore. The package will automatically find and use event handlers. The functionality is inspired by how Laravel auto discovers its events and event listeners. Let's take a look at how event handler auto-discovery works in our package.
In the service provider you'll find the discoverEventHandlers
function which is called from the boot
method.
private function discoverEventHandlers()
{
$projectionist = app(Projectionist::class);
$cachedEventHandlers = $this->getCachedEventHandlers();
if (! is_null($cachedEventHandlers)) {
$projectionist->addEventHandlers($cachedEventHandlers);
return;
}
(new DiscoverEventHandlers())
->within(config('event-projector.auto_discover_projectors_and_reactors'))
->useBasePath(base_path())
->ignoringFiles(Composer::getAutoloadedFiles(base_path('composer.json')))
->addToProjectionist($projectionist);
}
If the event handlers are cached, it'll use those cached registrations and not perform auto-discovery. We'll talk about this later.
The real auto-discovering happens inside the DiscoverEventHandlers
class. We pass it the directory where to look for the event handlers classes, by default this will be app_path()
. We'll also pass it the base_path()
. This is needed DiscoverEventHandlers
will use that path to convert path names to class names.
Let's take a look at the implementation last function in the chain: addToProjectionist
. This is the most important function of DiscoverEventHandlers
:
public function addToProjectionist(Projectionist $projectionist)
{
$files = (new Finder())->files()->in($this->directories);
return collect($files)
->reject(function (SplFileInfo $file) {
return in_array($file->getPathname(), $this->ignoredFiles);
})
->map(function (SplFileInfo $file) {
return $this->fullQualifiedClassNameFromFile($file);
})
->filter(function (string $eventHandlerClass) {
return is_subclass_of($eventHandlerClass, EventHandler::class);
})
->pipe(function (Collection $eventHandlers) use ($projectionist) {
$projectionist->addEventHandlers($eventHandlers->toArray());
});
}
First, it will get all the files inside the directory where we should be looking for event handlers (as mentioned before this is app_path()
by default).
Next, we will reject some files we don't want to process now. By default, these are non-class files that are loaded up by composer.
After that we will map each file path to the class should be in there. So the fullQualifiedClassNameFromFile
function will convert /home/username/myproject.com/app/Projectors/MyProjector.php
to App\Projectors\MyProjector
.
We'll continue by filtering for all classes that extend our EventHandler
class. And finally, all remaining classes will be registered at the projectionist.
Now, you probably don't want to scan all of the classes when you're in production. That's why the package also contains a command to cache all registered event handlers. In short this class will write all registered event handler classes to a file. Like we've already seen, the service provider will check for the existence of that file and not perform auto-discovery when that file is present.
In closing
With auto-detection of event handling methods and auto-discovery of event handlers, users of the package can simply create a class like this in their project, and it'll just work. No need route events to methods yourself and no need to register anything.
class BalanceProjector implements Projector
{
use ProjectsEvents;
public function onMoneyAdded(MoneyAddedEvent $event)
{
// perform some work
}
public function onMoneySubtracted(MoneySubtractedEvent $event)
{
// perform some work
}
}
I believe this kind of polishing makes a big difference for the users of our package. Thanks you to Sebastian De Deyne for suggesting these features.
If you want to know more about laravel-event-projector itself, head over to the docs of the package.
What are your thoughts on "Implementing event sourcing: improving the developer experience"?