friends-of-ddd / transaction-manager-doctrine
Simple transaction manager implementation for Doctrine
Requires
- php: >=8.0
- doctrine/orm: ^2.19.3|^3.1.1|^4.0
- friends-of-ddd/transaction-manager: ^0.1.1
Requires (Dev)
- ext-pdo_pgsql: *
- friendsofphp/php-cs-fixer: ^3.41
- phpunit/phpunit: ^9.6
- ramsey/uuid: ^4.7
- ramsey/uuid-doctrine: ^2.0
- symfony/cache: ^5.4
README
A doctrine implementation of friends-of-ddd/transaction-manager
library.
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.
Purpose
- Pure abstraction for transactions.
- Easily testable.
- An alternative to doctrine's
EntityManagerInterface
which is polluted with all sorts of actions on database. (SOLID Interface segregation principle violation) - Support Doctrine with active and beta versions test coverage
Installation
composer require friends-of-ddd/transaction-manager-doctrine
Transaction Manager
Configuration
Symfony:
Initialization without automatic clearing:
# config/services.yaml:
services:
FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\DoctrineTransactionManager: ~
FriendsOfDdd\TransactionManager\Domain\TransactionManagerInterface:
'@FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\DoctrineTransactionManager'
Initialization with automatic clearing:
# config/services.yaml:
services:
FriendsOfDdd\TransactionManager\Domain\TransactionManagerInterface:
class: FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\DoctrineClearingTransactionManagerDecorator
factory: '@FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\Factory\ClearingTransactionManagerFactory'
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);
}
);
}
}
Automatic clearing
Transaction manager can be initialized with automatic clearing. (See Configuration)
After any transaction commit or roll back all entity objects will be cleared/detatched from Doctrine cache automatically.
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
Doctrine stores 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
.
Configuration
Symfony:
Initialization with both lazy and clearing features via factory class:
# config/services.yaml:
services:
FriendsOfDdd\TransactionManager\Application\FlusherInterface:
class: FriendsOfDdd\TransactionManager\Infrastructure\Flusher\LazyFlusherDecorator
factory: '@FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\Factory\LazyFlusherWithClearingFactory'
arguments:
$maxBufferSize: 100
Initialization with both lazy feature only via factory class:
# config/services.yaml:
services:
FriendsOfDdd\TransactionManager\Application\FlusherInterface:
class: FriendsOfDdd\TransactionManager\Infrastructure\Flusher\LazyFlusherDecorator
factory: '@FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\Factory\LazyFlusherFactory'
arguments:
$maxBufferSize: 100
Manual initialization via services decorators:
# config/services.yaml:
services:
FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\DoctrineFlusher: ~
FriendsOfDdd\TransactionManager\Application\FlusherInterface:
'@FriendsOfDdd\TransactionManager\Infrastructure\Doctrine\DoctrineFlusher'
FriendsOfDdd\TransactionManager\Infrastructure\Flusher\DoctrineClearingFlusherDecorator:
decorates: '@FriendsOfDdd\TransactionManager\Application\FlusherInterface'
decoration_priority: 0
arguments:
$originalFlusher: '@FriendsOfDdd\TransactionManager\Infrastructure\Flusher\LazyFlusherDecorator.inner'
FriendsOfDdd\TransactionManager\Infrastructure\Flusher\LazyFlusherDecorator:
decorates: '@FriendsOfDdd\TransactionManager\Application\FlusherInterface'
decoration_priority: 1
arguments:
$originalFlusher: '@FriendsOfDdd\TransactionManager\Infrastructure\Flusher\LazyFlusherDecorator.inner'
$maxBufferSize: 100 # Optional: will flush after the amount of callbacks completed.
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.
Clearing flusher
Clearing is used for memory usage optimization. Once data gets flushed it is recommended to clear (detach) the entity objects from doctrine cache.
DoctrineClearingFlusherDecorator
runs \Doctrine\ORM\EntityManager::clear()
after each flush invoked by flusher interface.
If you use it combined with LazyFlusherDecoraror
, clearing is made only after actual flash is called, e.g. after reaching max buffer limit.
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.*
- 8.4.*
Supported Doctrine versions
- 2.19.7+
- 3.2.2+