DEV Community

Freek Van der Herten
Freek Van der Herten

Posted on • Originally published at freek.dev on

★ 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.

Top comments (0)