Saga
Process Manager Saga PHP
Saga is responsible for coordination of long running processes. It can store information about what happened and make a decision what to do in the right time.
In
Ecotone, Saga is actually Aggregate.
You may use Command Handlers
and Event Handlers
both in Sagas and Aggregates.
This provides a lot of flexibility, as Aggregate can combine behaviour of Saga and vice versa, if needed.
You may treat Saga
as a marker which will tell that this specific aggregate is meant to be handling long running process.It's really up to you, if you want to distinct Sagas from Aggregates,
Ecotone
does not try to impose the solution. Saga just as Aggregates are stored using Repository implementation, you may store Saga as event stream or store whole current state.
#[Saga]
class OrderFulfillment
{
#[AggregateIdentifier]
private string $orderId;
private bool $isFinished;
private function __construct(string $orderId)
{
$this->orderId = $orderId;
}
#[EventHandler]
public static function start(OrderWasPlacedEvent $event) : self
{
return new self($event->getOrderId());
}
#[EventHandler]
public function whenPaymentWasDone(PaymentWasFinishedEvent $event, CommandBus $commandBus) : self
{
if ($this->isFinished) {
return;
}
$this->isFinished = true;
$commandBus->send(new ShipOrderCommand($this->orderId));
}
}
Aggregate
- Saga can stated-stored Aggregate or Event Sourced Aggregate
EventHandler
- We mark method to be called, when specific event happens. start
- isfactory method
and should construct new instanceOrderFulfillment.
Depending on need you may construct differently as Event Sourced Aggregate.paymentWasDone
- Is called whenPaymentWasFinishedEvent
event is published. We have injectedCommandBus
into the method in order to finish process by sendingShipOrderCommand.
We could also publish event instead.
#[EventSourcingSaga]
class OrderFulfillment
(...)
As Saga is identified by identifier, just like an Aggregate the events need to be correlated with specific instance.
When we do have event like
PaymentWasFinishedEvent
we need to tell Ecotone
which instance of OrderFulfillment
it should be retrieve from Repository and call method on.This is done automatically, when property name in
Event
is the same as property marked as @AggregateIdentifier
in aggregate. class PaymentWasFinishedEvent
{
private string $orderId;
}
If the property name is different we need to give
Ecotone
a hint, how to correlate identifiers. class SomeEvent
{
#[TargetAggregateIdentifier("orderId")]
private string $purchaseId;
}
In other scenario, when there is no property to correlate, we can make use of
Before
or Presend
Interceptors to enrich event's metadata with required identifier.
Suppose the orderId identifier is available in metadata under key orderNumber, then we can tell Endpoint to use the mapping.#[EventHandler(identifierMetadataMapping: ["orderId" => "orderNumber"])]
public function failPayment(PaymentWasFailedEvent $event, CommandBus $commandBus) : self
{
// do something with $event
}
In the previous example we have assumed, that the first event we will receive is
OrderWasPlacedEvent
and the second which finishes the Saga is PaymentWasFinishedEvent.
It it's always risky to make such assumptions, especially, when events comes from different systems.
What we could do instead, is to expect them to come in different order and handle it gracefully.#[Aggregate]
class OrderFulfillment
{
#[AggregateIdentifier]
private string $orderId;
private bool $isFinished;
private function __construct(string $orderId)
{
$this->orderId = $orderId;
}
#[EventHandler("whenOrderWasPlaced")]
public static function startByPlacedOrder(OrderWasPlacedEvent $event) : self
{
return new self($event->getOrderId());
}
#[EventHandler("whenPaymentWasDone")]
public static function startByPaymentFinished(PaymentWasFinished $event) : self
{
return new self($event->getOrderId());
}
#[EventHandler("whenOrderWasPlaced")]
public function whenOrderWasPlaced(OrderWasPlacedEvent $event, CommandBus $commandBus) : self
{
if ($this->isFinished) {
return;
}
$this->isFinished = true;
$commandBus->send(new ShipOrderCommand($this->orderId));
}
#[EventHandler("whenPaymentWasDone")]
public function whenPaymentWasDone(PaymentWasFinishedEvent $event, CommandBus $commandBus) : self
{
if ($this->isFinished) {
return;
}
$this->isFinished = true;
$commandBus->send(new ShipOrderCommand($this->orderId));
}
}
#[EventHandler("whenOrderWasPlaced")]
public static function startByPlacedOrder(OrderWasPlacedEvent $event) : self
#[EventHandler("whenOrderWasPlaced")]
public function whenOrderWasPlaced(OrderWasPlacedEvent $event, CommandBus $commandBus) : self
If you look closely, you will see that those the factory method
startByPlacedOrder
and action method whenOrderWasPlaced
are handling the same event.
If that's the case, Ecotone will verify, before calling factory method, if the aggregate exists and if so, will reroute the event to the action method.
This solution will prevent us from depending on the order of events, without introducing routing functionality into our business code.There may be situations, when we will want to handle events, only if Saga already started.
#[Aggregate]
class OrderFulfillment
{
#[AggregateIdentifier]
private string $customerId;
private function __construct(string $customerId)
{
$this->customerId = $customerId;
}
#[EventHandler]
public static function start(ReceivedGreatCustomerBadge $event) : void
{
return new self($event->getCustomerId());
}
#[EventHandler(dropMessageOnNotFound: true)]
public function whenNewOrderWasPlaced(OrderWasPlaced $event, CommandBus $commandBus) : void
{
$commandBus->send(new PromotionCode($this->customerId));
}
}
We want to send promotion code to the customer, if he received great customer badge, but if not we do nothing.
EventHandler(dropMessageOnNotFound=true)
If this saga instance will be not found, then this event will be dropped and will not call
whenNewOrderWasPlaced
method.Options we used in here, can also be applied to Command Handlers
Last modified 4mo ago