Tale is a small library to help write a "distributed transaction like" object across a number of services. It's loosely based on the saga pattern. A good intro is available on the couchbase blog: https://blog.couchbase.com/saga-pattern-implement-business-transactions-using-microservices-part/, (*2)
composer require mead-steve/tale
An example use case of this would be some holiday booking software broken down into a few services., (*3)
Assuming we have the following services: Flight booking API, Hotel booking API, and a Customer API., (*4)
We'd write the following steps:, (*5)
class DebitCustomerBalanceStep implements Step { //.. Some constructor logic for initialising the api etc... public function execute(CustomerPurchase $state) { $paymentId = $this->customerApi->debit($state->Amount); return $state->markAsPaid($paymentId); } public function compensate($state): void { $this->customerApi->refundAccountForPayment($state->paymentId) }
class BookFlightStep implements Step { //.. Some constructor logic for initialising the api etc... public function execute(FlightPurchase $state) { $flightsBookingRef = $this->flightApi->buildBooking( $state->Destination, $state->Origin, self::RETURN, $this->airline ); if ($flightsBookingRef=== null) { raise \Exception("Unable to book flights"); } return $state->flightsBooked($flightsBookingRef); } public function compensate($state): void { $this->customerApi->cancelFlights($state->flightsBookingRef) }
and so on for any of the steps needed. Then in whatever is handling the user's request a distributed transaction can be built:, (*6)
$transaction = (new Transaction()) ->add(new DebitCustomerBalance($user)) ->add(new BookFlightStep($airlineOfChoice)) ->add(new BookHotelStep()) ->add(new EmailCustomerDetailsOfBookingStep()) $result = $transaction ->run($startingData) ->throwFailures() ->finalState();
If any step along the way fails then the compensate method on each step is called in reverse order until everything is undone., (*7)
The current state is passed from one step to the next. This same state is also used to compensate for the transactions in the event of a failure further on in the transaction. Since this is the case it is important that implementations consider making the state immutable., (*8)
Tale provides a CloneableState
interface to help with this. Any state implementing
this interface will have its cloneState
method called before being passed to a step
ensuring that steps won't share references to the same state., (*9)
class FakeState implements CloneableState { public function cloneState() { return clone $this; } } $stepOne = new LambdaStep( function (MyStateExample $state) { $state->mutateTheState = "step one" return $state; } ); $stepTwo = new LambdaStep( function (MyStateExample $state) { $state->mutateTheState = "step two" return $state; } ); $transaction = (new Transaction()) ->add($stepOne) ->add($stepTwo); $startingState = new MyStateExample(); $finalState = $transaction->run($startingState)->finalState();
In the example above $startingState
, $finalState
and $state
given to both function
calls are all clones of each other so changing one won't affect any earlier states., (*10)
Contributions are very welcome. Please open an issue first if the change is large or will break backwards compatibility., (*11)
All builds must pass the travis tests before merge.
Running ./run_tests.sh
will run the same tests as travis.yml but locally., (*12)
The dockerfile provides an environment that can execute all the tests & static analysis., (*13)