Imagine a scenario where we have to buy a bunch of products in a single operation (or transaction, intended in the economical meaning), where it's all or nothing. So, if just one thing goes wrong, we have to discard all the purchase!
In programming usually we will define at least two entities each of which will have its own repository that will be used to retrieve, save and update data.
But how can you guarantee that each operation will be executed inside a single transaction?
Starting scenario
The following is a php
example of what we've just described, obviously these concepts are programming language agnostic.
Also, I'll try to follow the concepts expressed in the article Dividing responsibilities by Matthias Noback.
Product
Let's start with our Product
model and its repository:
<?php
class ProductForPurchase
{
public function __construct(
readonly private ProductId $productId,
private int $availability
)
public function buy(int $quantity): ProductForAvailabilityUpdate
{
if($quantity <= 0){
throw QuantityException::becauseMustBeAPositiveInteger($quantity);
}
if($quantity > $this->availability){
throw AvailabilityException::becauseQuantityExceedsAvalability($quantity, $this->availability);
}
$this->availability -= $quantity;
return new ProductForAvailabilityUpdate($this->productId, $this->availability);
}
}
I usually separate repositories too, considering write and read responsibilities.
interface ProductWriteRepository
{
/**
* @throws ProductNotFoundException
*/
public function getProductForPurchaseBy(ProductId $productId): ProductForPurchase;
public function updateProductAvailability(ProductForAvailabilityUpdate $productForAvailabilityUpdate): ProductForPurchase;
}
Purchase
Now continue with modeling the Purchase
and then its repository.
<?php
readonly class NewPurchase
{
/**
* @param ProductId[] $products
*/
private function __construct(public PurchaseId $purchaseId, public array $productsId, public DateTimeInterface $createdAt)
{
// Checks that $products are all instances of Product
if(count($productsId) === 0){
throw PurchaseException::becauseNoProductDefined();
}
$this->createdAt = new DateTimeImmutable();
}
public static function create(array $productsId): self
{
return new self(PurchaseId::new(), $productsId);
}
}
interface PurchaseWriteRepository
{
public function save(NewPurchase $newPurchase): void;
}
The service
What we need now is a service that puts everything together. Something that, given a DTO will perform all the needed actions to complete our purchase.
We'll define our DTO.
<?php
readonly class ProductAndQuantityDto
{
public function __construct(public ProductId $productId, public int $quantity){
}
}
Now it's the turn of the service.
<?php
readonly class PurchaseService
{
public function __construct(
private ProductWriteRepository $productWriteRepository,
private PurchaseWriteRepository $purchaseWriteRepository
){
}
/**
* @param ProductAndQuantityDto[] $productsAndQuantity
*/
public function buyProducts(array $productsAndQuantity): void
{
// Checks if $productsAndQuantity are all instances of ProductAndQuantityDto
$productsIdPurchased = [];
/** @var ProductAndQuantityDto $productAndQuantity */
foreach($productsAndQuantity as $productAndQuantity)
{
$productForPurchase = $this->productWriteRepository->getProductForPurchaseBy($productAndQuantity->productId);
$productForAvailabilityUpdate = $productForPurchase->buy($productAndQuantity->quantity);
$this->productWriteRepository->updateProductAvailability($productForAvailabilityUpdate);
$productsIdPurchased[] = $productAndQuantity->productId;
}
$this->purchaseWriteRepository->save(NewPurchase::create($productsIdPurchased));
}
}
Ok that's it, we're done!
The problem
But wait a minute, what happens if we lost the connection to our DB (supposing that we have implemented the repository using a DB), or whatever, just when creating the new purchase $this->purchaseWriteRepository->save(NewPurchase::create($productsIdPurchased));
?
The answer is that we will have a bunch of products updated with the new availability, but we have lost the money because the operation is not finalised.
A similar thing happens if we pass to the service a Dto with an id of an inexistent product. The system will throw a ProductNotFoundException
and that's it: say goodbye to our profit.
The solution
There's a statement by Evans in his Domain Driven Design blue book that says:
The REPOSITORY concept is adaptable to many situations. The possibilities of implementation are so diverse that I can only list some concerns to keep in mind.... Leave transaction control to the client. Although the REPOSITORY will insert and delete from the database, it will ordinarily not commit anything.... Transaction management will be simpler if the REPOSITORY keeps its hands off.
So here it is my proposal: can we define an object that encapsulates a set of operations within a transaction?
Of course we can!
Introducing Transaction
We can first write down our operation interface.
<?php
interface Operation
{
public function execute(): void
}
And now the transaction.
<?php
abstract class Transaction
{
/**
* @throws Exception
*/
public function executeTransaction(array $operations): void
{
$this->beginTransaction();
try {
foreach ($operations as $operation) {
$operation->execute();
}
$this->commit();
} catch (\Exception $exception) {
$this->rollback();
throw $exception;
}
}
abstract protected function beginTransaction(): void;
abstract protected function rollback(): void;
abstract protected function commit(): void;
}
Now supposing that we will implement our repositories with doctrine ORM, we can also implement our Transaction.
final class DoctrineTransaction extends Transaction
{
public function __construct(private EntityManagerInterface $em){
}
protected function beginTransaction(): void
{
$this->em->getConnection()->beginTransaction();
}
protected function rollback(): void
{
$this->em->getConnection()->rollback();
}
protected function commit(): void
{
$this->em->getConnection()->commit();
}
}
At this point we need just two operations, one for updating the availability and one for creating the purchase.
<?php
final class UpdateProductAvailabilityOperation implements Operation
{
public function __construct(
private ProductWriteRepository $repository,
private ProductForAvailabilityUpdate $product
){
}
public function execute(): void
{
$this->repository->updateProductAvailability($this->product);
}
}
<?php
final class CreatePurchaseOperation implements Operation
{
public function __construct(
private PurchaseWriteRepository $repository,
private NewPurchase $purchase
){
}
public function execute(): void
{
$this->repository->save($this->purchase);
}
}
The last thing that we need is to update our service to take advantage of our Transaction object.
<?php
readonly class PurchaseService
{
public function __construct(
private ProductWriteRepository $productWriteRepository,
private PurchaseWriteRepository $purchaseWriteRepository,
private Transaction $transaction
){
}
/**
* @param ProductAndQuantityDto[] $productsAndQuantity
*/
public function buyProducts(array $productsAndQuantity): void
{
// Checks if $productsAndQuantity are all instances of ProductAndQuantityDto
$productsIdPurchased = [];
$operations = [];
/** @var ProductAndQuantityDto $productAndQuantity */
foreach($productsAndQuantity as $productAndQuantity)
{
$productForPurchase = $this->productWriteRepository->getProductForPurchaseBy($productAndQuantity->productId);
$productForAvailabilityUpdate = $productForPurchase->buy($productAndQuantity->quantity);
$operations[] = new UpdateProductAvailabilityOperation(
$this->productWriteRepository,
$productForAvailabilityUpdate
);
$productsIdPurchased[] = $productAndQuantity->productId;
}
$operations[] = new CreatePurchaseOperation(
$this->purchaseWriteRepository,
NewPurchase::create($productsIdPurchased)
);
$this->transaction->executeTransaction($operations);
}
}
Wrap Up
We have successfully created our transaction and if any Exception will be thrown, now we can be sure that nothing will be committed. We don't need a GetProductForPurchaseOperation
for the $productForPurchase = $this->productWriteRepository->getProductForPurchaseBy($productAndQuantity->productId);
, because the exception thrown will also stop all the transaction.
So, hope that helps! If you have any feedback please leave a comment or contact me.
Top comments (0)