feat(usecase): add Transactional interface and update use cases

- Implemented Transactional interface for use cases requiring database transactions.
- Updated CreateClient, RegenerateClientSecret, and SetClientStatus to implement Transactional.
- Modified LaravelExecutor to handle transactional use cases appropriately.
- Enhanced tests for LaravelExecutor to cover transactional behavior.
This commit is contained in:
Jérémy Munsch 2026-08-21 09:38:29 +02:00
parent aca627ff78
commit 9c2c109d51
10 changed files with 384 additions and 30 deletions

View file

@ -13,11 +13,12 @@ use Application\Security\Ports\Services\ClientCredentialGenerator;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\CreateClient\Input, \Application\Security\Usecases\CreateClient\Output> */
final readonly class CreateClient implements Usecase
final readonly class CreateClient implements Transactional, Usecase
{
public function __construct(
private ClientRepository $clientRepository,

View file

@ -10,11 +10,12 @@ use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\RegenerateClientSecret\Input, \Application\Security\Usecases\RegenerateClientSecret\Output> */
final readonly class RegenerateClientSecret implements Usecase
final readonly class RegenerateClientSecret implements Transactional, Usecase
{
public function __construct(
private ClientRepository $clientRepository,

View file

@ -9,11 +9,12 @@ use Application\Security\Exceptions\ClientNotFoundException;
use Application\Security\Ports\Repositories\ClientRepository;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\ToggleClientStatus\Input, \Application\Security\Usecases\ToggleClientStatus\Output> */
final readonly class SetClientStatus implements Usecase
final readonly class SetClientStatus implements Transactional, Usecase
{
public function __construct(
private ClientRepository $clientRepository,

View file

@ -9,7 +9,9 @@ use Psr\Log\LoggerInterface;
use Shared\DDD\Security\UserDetailsProvider;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Shared\DDD\Usecase\Input;
use Shared\DDD\Usecase\Output;
use Shared\DDD\Usecase\Presenter;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
final readonly class LaravelExecutor implements UsecaseExecutor
@ -28,19 +30,20 @@ final readonly class LaravelExecutor implements UsecaseExecutor
*
* @param Usecase<TInput, TOutput> $usecase
* @param TInput $input
* @param Presenter<TOutput, TView> $presenter
* @return TView
* @param Presenter<TOutput, TView>|null $presenter
* @return ($presenter is null ? TOutput : TView)
*/
#[\Override]
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object
{
$currentUser = $this->userDetailsProvider?->currentUser();
$context = [
'usecase' => $usecase::class,
'input' => $input::class,
'presenter' => $presenter::class,
'user' => $currentUser ? [
'usecase' => $usecase::class,
'input' => $input::class,
'presenter' => $presenter ? $presenter::class : 'none',
'transactional' => $usecase instanceof Transactional,
'user' => $currentUser ? [
'id' => $currentUser->id(),
'name' => $currentUser->name(),
'type' => $currentUser->type()->type(),
@ -51,16 +54,21 @@ final readonly class LaravelExecutor implements UsecaseExecutor
$this->logger->debug('Starting usecase execution', $context);
try {
$result = $this->database->transaction(function () use ($usecase, $input, $presenter): object {
if ($usecase instanceof Transactional) {
$output = $this->database->transaction(fn (): Output => $usecase->execute($input));
} else {
$output = $usecase->execute($input);
return $presenter->present($output);
});
}
$this->logger->info('Usecase executed successfully', $context);
/** @var TView */
return $result;
if ($presenter === null) {
/** @var (TView is null ? TOutput : TView) */
return $output;
}
/** @var (TView is null ? TOutput : TView) */
return $presenter->present($output);
} catch (\Throwable $e) {
$this->logger->error('Usecase execution failed: ' . $e->getMessage(), [
...$context,

View file

@ -4,10 +4,15 @@ declare(strict_types=1);
namespace Shared\DDD\Usecase\Builder;
use Shared\DDD\Usecase\Output;
use Shared\DDD\Usecase\Presenter;
/** @template TOutput of \Shared\DDD\Usecase\Output */
interface BuilderStepPresenter
/**
* @template TOutput of Output
*
* @extends BuilderStepBuild<TOutput>
*/
interface BuilderStepPresenter extends BuilderStepBuild
{
/**
* @template TView of object

View file

@ -83,8 +83,8 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
#[\Override]
public function run(): object
{
if ($this->usecase === null || $this->input === null || $this->presenter === null) {
throw new LogicException('Usecase, input and presenter are all required.');
if ($this->usecase === null || $this->input === null) {
throw new LogicException('Usecase and input are required.');
}
/** @var TView */

View file

@ -17,8 +17,8 @@ interface UsecaseExecutor
*
* @param Usecase<TInput, TOutput> $usecase
* @param TInput $input
* @param Presenter<TOutput, TView> $presenter
* @return TView
* @param Presenter<TOutput, TView>|null $presenter
* @return ($presenter is null ? TOutput : TView)
*/
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object;
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Usecase;
/**
* Marker interface for use cases that perform database mutations
* requiring an atomic database transaction.
*/
interface Transactional
{
}

View file

@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Usecase;
use Illuminate\Database\DatabaseManager;
use Infra\Usecase\LaravelExecutor;
use Mockery;
use Psr\Log\LoggerInterface;
use Shared\DDD\Security\UserDetails;
use Shared\DDD\Security\UserDetailsProvider;
use Shared\DDD\Security\UserType;
use Shared\DDD\Usecase\Input;
use Shared\DDD\Usecase\Output;
use Shared\DDD\Usecase\Presenter;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
covers(LaravelExecutor::class);
describe('LaravelExecutor Integration Tests', function (): void {
it('executes non-transactional usecase directly without opening a database transaction', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
};
$outputMock = new class () implements Output
{
};
$usecaseMock = new class ($outputMock) implements Usecase
{
public function __construct(private readonly Output $output)
{
}
public function execute(Input $input): Output
{
return $this->output;
}
};
/** @var DatabaseManager&Mockery\MockInterface $databaseMock */
$databaseMock = Mockery::mock(DatabaseManager::class);
$databaseMock->shouldNotReceive('transaction');
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('debug')->once();
$loggerMock->shouldReceive('info')->once();
$executor = new LaravelExecutor($databaseMock, $loggerMock);
// WHEN
$result = $executor->execute($usecaseMock, $inputMock);
// THEN
expect($result)->toBe($outputMock);
});
it('executes transactional usecase inside a database transaction', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
};
$outputMock = new class () implements Output
{
};
$usecaseMock = new class ($outputMock) implements Transactional, Usecase
{
public function __construct(private readonly Output $output)
{
}
public function execute(Input $input): Output
{
return $this->output;
}
};
/** @var DatabaseManager&Mockery\MockInterface $databaseMock */
$databaseMock = Mockery::mock(DatabaseManager::class);
$databaseMock->shouldReceive('transaction')
->once()
->andReturnUsing(static fn (\Closure $callback) => $callback());
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('debug')->once();
$loggerMock->shouldReceive('info')->once();
$executor = new LaravelExecutor($databaseMock, $loggerMock);
// WHEN
$result = $executor->execute($usecaseMock, $inputMock);
// THEN
expect($result)->toBe($outputMock);
});
it('transforms output using presenter when presenter is provided', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
};
$outputMock = new class () implements Output
{
};
$viewResult = new \stdClass();
$viewResult->success = true;
$usecaseMock = new class ($outputMock) implements Usecase
{
public function __construct(private readonly Output $output)
{
}
public function execute(Input $input): Output
{
return $this->output;
}
};
$presenterMock = new class ($viewResult) implements Presenter
{
public function __construct(private readonly object $view)
{
}
public function present(Output $output): object
{
return $this->view;
}
};
/** @var DatabaseManager&Mockery\MockInterface $databaseMock */
$databaseMock = Mockery::mock(DatabaseManager::class);
$databaseMock->shouldNotReceive('transaction');
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('debug')->once();
$loggerMock->shouldReceive('info')->once();
$executor = new LaravelExecutor($databaseMock, $loggerMock);
// WHEN
$result = $executor->execute($usecaseMock, $inputMock, $presenterMock);
// THEN
expect($result)->toBe($viewResult);
});
it('logs error and rethrows exception when usecase execution fails', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
};
$exception = new \RuntimeException('Database connection lost');
$usecaseMock = new class ($exception) implements Transactional, Usecase
{
public function __construct(private readonly \RuntimeException $exception)
{
}
public function execute(Input $input): Output
{
throw $this->exception;
}
};
/** @var DatabaseManager&Mockery\MockInterface $databaseMock */
$databaseMock = Mockery::mock(DatabaseManager::class);
$databaseMock->shouldReceive('transaction')
->once()
->andReturnUsing(static fn (\Closure $callback) => $callback());
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('debug')->once();
$loggerMock->shouldReceive('error')->once();
$executor = new LaravelExecutor($databaseMock, $loggerMock);
// WHEN & THEN
expect(fn () => $executor->execute($usecaseMock, $inputMock))
->toThrow(\RuntimeException::class, 'Database connection lost');
});
it('includes user details in logging context when user details provider is present', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
};
$outputMock = new class () implements Output
{
};
$usecaseMock = new class ($outputMock) implements Usecase
{
public function __construct(private readonly Output $output)
{
}
public function execute(Input $input): Output
{
return $this->output;
}
};
$userType = new class () implements UserType
{
public function type(): string
{
return 'api_client';
}
public function equals(mixed $other): bool
{
return $other instanceof self && $other->type() === $this->type();
}
};
$userDetails = new class ($userType) implements UserDetails
{
public function __construct(private readonly UserType $userType)
{
}
public function id(): string
{
return 'uuid-123';
}
public function name(): string
{
return 'Test Client';
}
public function type(): UserType
{
return $this->userType;
}
public function identifier(): string
{
return 'client-key-456';
}
};
/** @var UserDetailsProvider&Mockery\MockInterface $userProviderMock */
$userProviderMock = Mockery::mock(UserDetailsProvider::class);
$userProviderMock->shouldReceive('currentUser')->andReturn($userDetails);
/** @var DatabaseManager&Mockery\MockInterface $databaseMock */
$databaseMock = Mockery::mock(DatabaseManager::class);
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('debug')->once()->with(
'Starting usecase execution',
Mockery::on(
static fn (array $context): bool => isset($context['user'])
&& $context['user']['id'] === 'uuid-123'
&& $context['user']['name'] === 'Test Client'
&& $context['user']['type'] === 'api_client'
&& $context['user']['identifier'] === 'client-key-456'
&& $context['transactional'] === false
)
);
$loggerMock->shouldReceive('info')->once();
$executor = new LaravelExecutor($databaseMock, $loggerMock, $userProviderMock);
// WHEN
$result = $executor->execute($usecaseMock, $inputMock);
// THEN
expect($result)->toBe($outputMock);
});
});

View file

@ -19,7 +19,7 @@ covers(
);
describe('UsecaseBuilder Unit Tests', function (): void {
it('successfully builds and executes a use case using fluent syntax', function (): void {
it('successfully builds and executes a use case using fluent syntax with presenter', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
@ -60,11 +60,11 @@ describe('UsecaseBuilder Unit Tests', function (): void {
{
}
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object
{
$output = $usecase->execute($input);
return $presenter->present($output);
return $presenter?->present($output) ?? $output;
}
};
@ -79,11 +79,52 @@ describe('UsecaseBuilder Unit Tests', function (): void {
expect($result)->toBe($viewResult);
});
it('successfully builds and executes a use case without presenter returning Output directly', function (): void {
// GIVEN
$inputMock = new class () implements Input
{
};
$outputMock = new class () implements Output
{
};
$usecaseMock = new class ($outputMock) implements Usecase
{
public function __construct(private readonly Output $output)
{
}
public function execute(Input $input): Output
{
return $this->output;
}
};
$executorMock = new class () implements UsecaseExecutor
{
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object
{
$output = $usecase->execute($input);
return $presenter?->present($output) ?? $output;
}
};
// WHEN
$result = UsecaseBuilder::using($executorMock)
->do($usecaseMock)
->from($inputMock)
->run();
// THEN
expect($result)->toBe($outputMock);
});
it('throws LogicException when setting input before usecase', function (): void {
// GIVEN
$executorMock = new class () implements UsecaseExecutor
{
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object
{
return new \stdClass();
}
@ -104,7 +145,7 @@ describe('UsecaseBuilder Unit Tests', function (): void {
// GIVEN
$executorMock = new class () implements UsecaseExecutor
{
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object
{
return new \stdClass();
}
@ -129,7 +170,7 @@ describe('UsecaseBuilder Unit Tests', function (): void {
// GIVEN
$executorMock = new class () implements UsecaseExecutor
{
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object
{
return new \stdClass();
}
@ -139,6 +180,6 @@ describe('UsecaseBuilder Unit Tests', function (): void {
$builder = new UsecaseExecutionBuilder($executorMock);
expect(fn () => $builder->run())
->toThrow(LogicException::class, 'Usecase, input and presenter are all required.');
->toThrow(LogicException::class, 'Usecase and input are required.');
});
});