DEV Community

Cover image for Encapsulate a set of repositories operations within a single transaction
Leandro Luccerini for Claranet

Posted on • Updated on

Encapsulate a set of repositories operations within a single transaction

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
interface PurchaseWriteRepository
{

    public function save(NewPurchase $newPurchase): void;

}
Enter fullscreen mode Exit fullscreen mode

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){
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
<?php
final class CreatePurchaseOperation implements Operation
{
    public function __construct(
        private PurchaseWriteRepository $repository,
        private NewPurchase $purchase
    ){
    }

    public function execute(): void
    {
        $this->repository->save($this->purchase);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)