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:
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:
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.
Your post is so useful to your readers. Keep posting! lanai screening Melbourne FL
Using the create Sprunked Order function in a complete app ensures that product stock is checked before processing an order. This event-driven architecture allows non-event-sourced parts to function traditionally while leveraging events for decision-making. It enhances flexibility and responsiveness in app development.