DEV Community

Freek Van der Herten
Freek Van der Herten

Posted on • Originally published at freek.dev

Mixing event sourcing in a traditional Laravel app

Together with my colleague Brent, I'm working on designing the architecture of a massive Laravel application. In that application, we'll have traditional parts and event sourced parts. In this blog post, I'd like to give a practical example of how we think to achieve this.
This post is the second one in a two-part series. I highly encourage to read the first part before continuing to read this post. You also should have a good understanding of event sourcing, the role of aggregates, and projectors. Get up to speed by going through this introduction.

Setting the stage

First, let's do a quick recap of Brent's post. We need event sourcing in our application. In certain parts, we need to make decisions based on the past and, as time progresses, we need to be able to generate reports without knowing upfront what should be in those reports.

These problems can be solved by using event sourcing. We just store all events that happen in the app, so we can have aggregate roots that can make decisions based on the past when they are reconstituted from the events. We can generate new reports by creating projectors and replaying all recorded events to them.

A wise man, by the name of Frank de Jonge, once said: "Event sourcing makes the hard parts easy, and the easy parts hard". And it's true. Setting up event sourcing takes some time, and it's not as straightforward. Some overhead gets in the way when you want to do simple things.

In our application, we also have parts that are rather cruddy and don't need the power of event sourcing. Wouldn't it be nice to be able to have the best of both worlds in a single app? Brent and I researched this subject and pair programmed on some experiments. We'd like to share one of these experiments with you.

A practical example

We're going to implement some of the parts mentioned in Brent's post: products and orders. Product is a boring cruddy part. We don't need history nor reports here. For the orders part, we'll use event sourcing.

You can find the source code of the example in this repo on GitHub. Keep in mind that this is a very simple example. In a real-world app the logic involved would probably be more complicated. In this experiment, we're just concentrating on how such to part should/would communicate with each other.

The non-event-sourced part

If you take a look in the Context directory, you'll see the two parts: Order and Product. Let's take a look at `Product first:

screenshot

As you can see, this part is very simple: we just have models and events. The idea here is that we can just build it as we're used to, with not too much overhead. The only thing that we need to do is to fire off specific events when something changes, so the other parts of the app can hook into that.

In the Product model you'll see this code:


protected $dispatchesEvents = [
'created' => ProductCreatedEvent::class,
'updated' => ProductUpdatedEvent::class,
'deleting' => DeletingProductEvent::class,
'deleted' => ProductDeletedEvent::class,
];

In $dispatchesEvents, you can define which events should be fired when certain things happen to the model. This is standard Laravel functionality. Sure, we could rely on the events that eloquent natively fires, but we want to make this very explicit by using event classes of our own.

Let's take a look at one of these events.

`
namespace App\Context\Product\Events;

use App\Context\Product\Models\Product;

class ProductCreatedEvent
{
public Product $product;

public function __construct(Product $product)
{
    $this->product = $product;
}

}
`

As you can see, there's nothing special going on here. We're just using the model as an event property.

The event sourced part

Let's now turn our eye to the Order part of the app, which is event sourced. This is how that structure looks like:

screenshot

You see projectors and an aggregate root here, so it's clear that this part uses event sourcing. We're using our homegrown event sourcing package to handle the logic around storing events, projectors, aggregate roots, ...

In the Product part of the app, we fired off events. These events were just regular Laravel events, we didn't store them. The ProductEventSubscriber is listening for these events. Let's take a look at the code.

`
namespace App\Context\Order\Subscribers;

use App\Context\Product\Events\DeletingProductEvent as AdminDeletingProductEvent;
use App\Context\Product\Events\ProductCreatedEvent as AdminProductCreatedEvent;
use App\Context\Order\Events\ProductCreatedEvent;
use App\Context\Order\Events\ProductDeletedEvent;
use App\Support\Events\EventSubscriber as BaseEventSubscriber;
use App\Support\Events\SubscribesToEvents;

class ProductEventSubscriber implements BaseEventSubscriber
{
use SubscribesToEvents;

protected array $handlesEvents = [
    AdminProductCreatedEvent::class => 'onProductCreated',
    AdminDeletingProductEvent::class => 'onDeletingProduct',
];

public function onProductCreated(AdminProductCreatedEvent $event): void
{
    $event = $event->product;

    event(new ProductCreatedEvent(
        $event->getUuid(),
        $event->stock,
    ));
}

public function onDeletingProduct(AdminDeletingProductEvent $event): void
{
    $event = $event->product;

    event(new ProductDeletedEvent(
        $event->getUuid(),
    ));
}

}
`

What happens here is quite interesting. We are going to listen for the events that are coming from the non event sourced part. When an event from the Product context, that is interesting to the Order context, comes in, we are going to fire off another event. That other event is part of the Product context.

So when App\Context\Product\Events\ProductCreatedEvent comes in (and it is aliased to AdminProductCreatedEvent because probably an action by an admin on the UI will have caused that action), we are going to fire off App\Context\Order\Events\ProductCreatedEvent (from the Order context).

We are going to take all the properties that are interesting to Order context and put them in the App\Context\Order\Events\ProductCreatedEvent.

`
public function onProductCreated(AdminProductCreatedEvent $event): void
{
$event = $event->product;

event(new ProductCreatedEvent(
    $event->getUuid(),
    $event->stock,
));

}
`

Let's take a look at that event itself.

`
namespace App\Context\Order\Events;

use App\Support\ValueObjects\ProductUuid;
use Spatie\EventSourcing\ShouldBeStored;

class ProductCreatedEvent extends ShouldBeStored
{
public ProductUuid $productUuid;

public int $stock;

public function __construct(ProductUuid $productUuid, int $stock)
{
    $this->productUuid = $productUuid;

    $this->stock = $stock;
}

}

`

You can see that this event extends ShouldBeStored. That base class is part of our event sourcing package. This will cause the event to be stored.

We immediately use that stored event to build up a projection that holds the stock. Let's take a look at ProductStockProjector.

`
namespace App\Context\Order\Projectors;

use App\Context\Order\Events\OrderCancelledEvent;
use App\Context\Order\Events\ProductCreatedEvent;
use App\Context\Order\Events\ProductDeletedEvent;
use App\Context\Order\Events\OrderCreatedEvent;
use App\Context\Order\Models\Order;
use App\Context\Order\Models\ProductStock;
use App\Support\ValueObjects\ProductStockUuid;
use Spatie\EventSourcing\Projectors\Projector;
use Spatie\EventSourcing\Projectors\ProjectsEvents;

class ProductStockProjector implements Projector
{
use ProjectsEvents;

protected array $handlesEvents = [
    ProductCreatedEvent::class => 'onProductCreated',
    ProductDeletedEvent::class => 'onProductDeleted',
    OrderCreatedEvent::class => 'onOrderCreated',
    OrderCancelledEvent::class => 'onOrderCancelled',
];

public function onProductCreated(ProductCreatedEvent $event): void
{
    ProductStock::create([
        'uuid' => ProductStockUuid::create(),
        'product_uuid' => $event->productUuid,
        'stock' => $event->stock,
    ]);
}

public function onProductDeleted(ProductDeletedEvent $event): void
{
    ProductStock::forProduct($event->productUuid)->delete();
}

public function onOrderCreated(OrderCreatedEvent $event): void
{
    $productStock = ProductStock::forProduct($event->productUuid);

    $productStock->update([
        'stock' => $productStock->stock - $event->quantity,
    ]);
}

public function onOrderCancelled(OrderCancelledEvent $event): void
{
    $order = Order::findByUuid($event->aggregateRootUuid());

    $productStock = ProductStock::forProduct($order->product->uuid);

    $productStock->update([
        'stock' => $productStock->stock + $order->quantity,
    ]);
}

}
`

When ProductCreatedEvent is fired, we will call ProductStock::create. ProductStock is a regular Eloquent model.

`
namespace App\Context\Order\Models;

use App\Context\Product\Models\Product;
use Illuminate\Database\Eloquent\Model;

class ProductStock extends Model
{
/**
* @param \App\Context\Product\Models\Product|\App\Support\ValueObjects\ProductUuid $productUuid
*
* @return \App\Context\Order\Models\ProductStock
*/
public static function forProduct($productUuid): ProductStock
{
if ($productUuid instanceof Product) {
$productUuid = $productUuid->getUuid();
}

    return static::query()
        ->where('product_uuid', $productUuid)
        ->first();
}

}
`

In the ProductStockProjector the ProductStock model will be updated when orders come in, when they get canceled, or when an order is deleted.

Currently, we have an agreement with every member working on the project that it is not allowed to write to model from other contexts. The Order context may write and read from this model, but the Product context may only read it. Because this is such an important rule, we will probably technically enforce it soon.

Finally, let's take a look at the OrderAggregateRoot where the ProductStock model is being used to make a decision.

`
namespace App\Context\Order;

use App\Context\Product\Models\Product;
use App\Context\Order\Events\CouldNotCreateOrderBecauseInsufficientStock;
use App\Context\Order\Events\OrderCreatedEvent;
use App\Context\Order\Events\OrderCancelledEvent;
use App\Context\Order\Exceptions\CannotCreateOrderBecauseInsufficientStock;
use App\Context\Order\Models\ProductStock;
use Spatie\EventSourcing\AggregateRoot;

class OrderAggregateRoot extends AggregateRoot
{
public function createOrder(Product $product, int $quantity): self
{
$eventAvailability = ProductStock::forProduct($product);

    if ($eventAvailability->availability < $quantity) {
        $this->recordThat(new CouldNotCreateOrderBecauseInsufficientStock(
            $product->getUuid(),
            $quantity
        ));

        throw CannotCreateOrderBecauseInsufficientStock::make();
    }

    $unitPrice = $product->unit_price;

    $totalPrice = $unitPrice * $quantity;

    $this->recordThat(new OrderCreatedEvent(
        $product->getUuid(),
        $quantity,
        $unitPrice,
        $totalPrice,
    ));

    return $this;
}

public function cancelOrder(): self
{
    $this->recordThat(new OrderCancelledEvent());

    return $this;
}

}
`

In a complete app, the createOrder function would probably be triggered by an action that is performed in the UI. A product can be ordered for a certain amount. Using the ProductStock, the aggregate root will decide if there is enough stock for the order to be created.

Summary

A non event sourced part of the app fires of regular events. The event sourced parts listen for these events and record data interesting for them in events of their own. These events, own to the context, will be stored. These stored events are being used to feed projectors. These projectors create models that can be read by the non-event-sourced parts. The non-events-sourced parts may only read these models. Aggregate roots can use the events and projection to make decisions.

This approach brings a lot of benefits:

  • a non-event-sourced part can be built like we're used to. We just have to take care that events are being fired
  • an event sourced part can record its own events to act on
  • we could create new projectors and replay all recorded events to build up new state

I know this all is a lot to take in. This way of doing things is by no means set in stone for us. We're in a discovery phase, but we have a good feeling about what we've already discovered. Brent and/or I will probably write some follow-up posts soon.

Top comments (0)