friends-of-ddd/transaction-manager

Simple transaction manager abstraction for DDD

0.1.1 2024-03-01 04:42 UTC

This package is auto-updated.

Last update: 2024-04-30 04:17:59 UTC


README

The library introduces decoupled interfaces for persisting in database. With Domain-Driven design (DDD) in mind:

  • Transaction manager: everything in a closure either gets committed or rolled back. Any exception inside callback causes rollback.

  • Flusher: is a mechanism to reduce the amount of flushes per request.

:exclamation: Refer to friends-of-ddd/transaction-manager-doctrine for doctrine implementation of this library.

  1. Purpose
  2. Installation
  3. Transaction manager
  4. Flusher
  5. Supported PHP versions

Purpose

  • Pure abstraction for transactions.
  • Framework and library agnostic.
  • Easily testable.
  • An alternative to doctrine's EntityManagerInterface which is polluted with all sorts of actions on database. (SOLID's Interface segregation principle violation)
  • Support popular PHP ORMs (Doctrine, Eloquent) with active and beta versions test coverage

Installation

Doctrine:

composer require friends-of-ddd/transaction-manager-doctrine

Transaction manager

Example


use FriendsOfDdd\TransactionManager\Domain\TransactionManagerInterface;

final readonly class MoneyTransferService
{
    public function __construct(
        private AccountBalanceRepositoryInterface $accountBalanceRepository,
        private TransactionManagerInterface $transactionManager,
    ) {
    }

    public function transfer(Money $amount, AccountBalance $fromAccount, AccountBalance $toAccount): void
    {
        $fromAccount->withdraw($amount);
        $toAccount->topUp($amount);

        $this->transactionManager->wrapInTransaction(
            function () use ($fromAccount, $toAccount) {
                $this->accountBalanceRepository->save($fromAccount);
                $this->accountBalanceRepository->save($toAccount);
            }
        );
    }
}

Mock implementation for testing

You can mock transaction manager in your tests with MockedTransactionManager class.


use PHPUnit\Framework\TestCase;
use FriendsOfDdd\TransactionManager\Infrastructure\MockedTransactionManager;

final class MoneyTransferServiceTest extends TestCase
{
    private MoneyTransferService $moneyTransferService;
    private InMemoryAccountBalanceRepository $accountBalanceRepository;
    private MockedTransactionManager $transactionManager;

    protected function setUp(): void 
    {
        $this->accountBalanceRepository = new InMemoryAccountBalanceRepository();
        $this->transactionManager = new MockedTransactionManager();
        $this->moneyTransferService = new MoneyTransferService(
            $this->accountBalanceRepository,
            $this->transactionManager,
        );
    }

    public function testTransactionDoesNotFail(): void 
    {
        // Arrange
        $fromAccount = AccountBalanceMother::createWithAmount(Amount::fromInt(100));
        $this->accountBalanceRepository->save($fromAccount);

        $toAccount = AccountBalanceMother::createWithAmount(Amount::fromInt(0));
        $this->accountBalanceRepository->save($toAccount);
    
        // Act
        $this->moneyTransferService->transfer(
            Amount::fromFloat(19.99),
            $fromAccount,
            $toAccount,
        );
        
        // Assert
        self::assertEquals(
            Amount::fromFloat(80.01),
            $this->accountBalanceRepository->getById($fromAccount->getId())->getBalance()
        );
        self::assertEquals(
            Amount::fromFloat(19.99),
            $this->accountBalanceRepository->getById($toAccount->getId())->getBalance()
        );
    }

    public function testTransactionFails(): void 
    {
        // Arrange
        $fromAccount = AccountBalanceMother::createWithAmount(Amount::fromInt(100));
        $this->accountBalanceRepository->save($fromAccount);

        $toAccount = AccountBalanceMother::createWithAmount(Amount::fromInt(0));
        $this->accountBalanceRepository->save($toAccount);
        
        $expectedException = new RuntimeException();
        $this->transactionManager->expectedException = $expectedException;
        
        // Assert
        $this->expectExceptionObject($expectedException);
    
        // Act
        $this->moneyTransferService->transfer(
            Amount::fromFloat(19.99),
            $fromAccount,
            $toAccount,
        );
    }
}

Logic Termination

There are cases when transaction is terminated not due to a system fault but according to a predefined logic. You can throw LogicTerminationInterface or LogicTerminationException for such cases.

For example, in doctrine any exception that is thrown from callback and is not instance of LogicTerminationInterface closes database connection, so connection becomes not writable.

Flusher

ORMs like Doctrine store persisted entities in memory until they are flushed in real DB. In order to control flushing (don't do it for every object) you can use FlusherInterface.

Lazy Flusher

LazyFlusherDecoraror can flush only once for all code enclosed in a closure function.


use FriendsOfDdd\TransactionManager\Application\FlusherInterface;

final readonly class AccountStatementGenerator
{
    public function __construct(
        private AccountRepositoryInterface $accountRepository,
        private AccountStatementRepositoryInterface $accountStatementRepository,
        private AccountStatementFactory $accountStatementFactory,
        private FlusherInterface $flusher,
    ) {
    }

    public function generateStatementsByUserId(UserId $userId): void
    {
        $userAccounts = $this->accountRepository->getAllByUserId($userId);
        
        $this->flusher->flushOnComplete(
            function () use ($userAccounts) {
                foreach ($userAccounts as $userAccount) {
                    $accountStatement = $this->accountStatementFactory->create($userAccount->getId());
                    $this->accountStatementRepository->save($accountStatement);
                }
            }
        );
    }
}


use Doctrine\ORM\EntityManagerInterface;
use FriendsOfDdd\TransactionManager\Application\FlusherInterface;

final readonly class DoctrineAccountStatementRepository implements AccountStatementRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private FlusherInterface $flusher,
    ) {
    }
    
    public function save(AccountStatement $accountStatement): void 
    {
        $this->flusher->flushOnComplete(
            function () {
                $this->entityManager->persist();
            }
        );
    }
}

EntityManagerInterace::flush() won't be called with every save() call, but only once at the end.

You can configure $maxBufferSize in lazy flusher constructor argument in order to flush after the number callbacks completed.

Void flusher

For test purposes (InMemory repositories), when you don't need to flush you can use VoidOrmSessionFlusher implementation.

Supported PHP versions:

  • 8.0.*
  • 8.1.*
  • 8.2.*
  • 8.3.*