From 18b83b6e674ffbc22680a3d1cbe81d1b0eba8525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Wed, 26 Aug 2026 09:51:47 +0200 Subject: [PATCH] perf(directory): memoize arcadia brand lookups to eliminate N+1 queries during INCO sheet hydration (F-2026-0003) --- .../EloquentArcadiaBrandDirectory.php | 30 ++++-- src/Infra/Providers/DomainServiceProvider.php | 5 +- .../EloquentArcadiaBrandDirectoryTest.php | 98 +++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 tests/Integration/Infra/Adapters/Directories/EloquentArcadiaBrandDirectoryTest.php diff --git a/src/Infra/Adapters/Directories/EloquentArcadiaBrandDirectory.php b/src/Infra/Adapters/Directories/EloquentArcadiaBrandDirectory.php index 04d98ff..8f99db8 100644 --- a/src/Infra/Adapters/Directories/EloquentArcadiaBrandDirectory.php +++ b/src/Infra/Adapters/Directories/EloquentArcadiaBrandDirectory.php @@ -12,27 +12,43 @@ use Infra\Database\Models\ArcadiaBrand as ArcadiaBrandModel; final class EloquentArcadiaBrandDirectory implements ArcadiaBrandDirectory { + /** @var array */ + private array $cache = []; + #[\Override] public function findAll(): array { - return array_values( - ArcadiaBrandModel::query() - ->get() - ->map(fn (ArcadiaBrandModel $model): ArcadiaBrand => $this->toDomain($model)) - ->all() - ); + $models = ArcadiaBrandModel::query()->get(); + $items = []; + + foreach ($models as $model) { + $domain = $this->toDomain($model); + $this->cache[$domain->id->value] = $domain; + $items[] = $domain; + } + + return $items; } #[\Override] public function find(ArcadiaId $id): ?ArcadiaBrand { + if (array_key_exists($id->value, $this->cache)) { + return $this->cache[$id->value]; + } + $model = ArcadiaBrandModel::query()->find($id->value); if ($model === null) { + $this->cache[$id->value] = null; + return null; } - return $this->toDomain($model); + $domain = $this->toDomain($model); + $this->cache[$id->value] = $domain; + + return $domain; } private function toDomain(ArcadiaBrandModel $model): ArcadiaBrand diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index daadb7e..c913975 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -25,6 +25,7 @@ use Infra\Adapters\Repositories\EloquentBrandRepository; use Infra\Cache\BrandCache; use Infra\Config\ArcadiaStorageConfig; use Infra\Usecase\LaravelExecutor; +use Psr\Log\LoggerInterface; use Shared\DDD\Event\DomainEventDispatcher; use Shared\DDD\Event\TransactionalDomainEventDispatcher; use Shared\DDD\Usecase\Builder\UsecaseExecutor; @@ -38,14 +39,14 @@ class DomainServiceProvider extends ServiceProvider $this->app->bind(IncoSheetDirectory::class, EloquentIncoSheetDirectory::class); $this->app->bind(IncoShortSheetDirectory::class, EloquentIncoShortSheetDirectory::class); $this->app->bind(IncoCompositionDirectory::class, EloquentIncoCompositionDirectory::class); - $this->app->bind(ArcadiaBrandDirectory::class, EloquentArcadiaBrandDirectory::class); + $this->app->scoped(ArcadiaBrandDirectory::class, EloquentArcadiaBrandDirectory::class); $this->app->bind(BrandRepository::class, EloquentBrandRepository::class); $this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class); $this->app->bind(BrandCache::class, static function (Application $app): BrandCache { $storageConfig = $app->make(ArcadiaStorageConfig::class); $cache = Cache::store($storageConfig->cacheStore); - $logger = $app->make(\Psr\Log\LoggerInterface::class); + $logger = $app->make(LoggerInterface::class); return new BrandCache($cache, $logger); }); diff --git a/tests/Integration/Infra/Adapters/Directories/EloquentArcadiaBrandDirectoryTest.php b/tests/Integration/Infra/Adapters/Directories/EloquentArcadiaBrandDirectoryTest.php new file mode 100644 index 0000000..a892b3b --- /dev/null +++ b/tests/Integration/Infra/Adapters/Directories/EloquentArcadiaBrandDirectoryTest.php @@ -0,0 +1,98 @@ +delete(); + ArcadiaBrand::query()->create(['code' => 1, 'libelle' => 'Le Gaulois']); + ArcadiaBrand::query()->create(['code' => 2, 'libelle' => 'MaƮtre CoQ']); + }); + + it('finds an arcadia brand by ArcadiaId and memoizes the result in memory', function (): void { + // GIVEN + $directory = new EloquentArcadiaBrandDirectory(); + $arcadiaId = new ArcadiaId('1'); + + // WHEN: First lookup (hits database) + $first = $directory->find($arcadiaId); + + // THEN + expect($first)->toBeInstanceOf(ArcadiaBrandDomain::class) + ->and($first?->id->value)->toBe('1') + ->and($first?->name->value)->toBe('Le Gaulois'); + + // WHEN: Second lookup - DB query count should not increase because of in-memory caching + $queryCount = 0; + DB::listen(function () use (&$queryCount): void { + $queryCount++; + }); + + $second = $directory->find($arcadiaId); + + // THEN: Second call returns the exact memoized entity with 0 extra queries + expect($second)->toBe($first) + ->and($queryCount)->toBe(0); + }); + + it('returns null when arcadia brand is not found and caches the null result', function (): void { + // GIVEN + $directory = new EloquentArcadiaBrandDirectory(); + $unknownId = new ArcadiaId('999'); + + // WHEN: First lookup for non-existent brand + $first = $directory->find($unknownId); + + // THEN + expect($first)->toBeNull(); + + // WHEN: Second lookup + $queryCount = 0; + DB::listen(function () use (&$queryCount): void { + $queryCount++; + }); + + $second = $directory->find($unknownId); + + // THEN + expect($second)->toBeNull() + ->and($queryCount)->toBe(0); + }); + + it('finds all arcadia brands and populates the cache', function (): void { + // GIVEN + $directory = new EloquentArcadiaBrandDirectory(); + + // WHEN + $all = $directory->findAll(); + + // THEN + expect($all)->toHaveCount(2); + + // WHEN: Calling find() on cached items + $queryCount = 0; + DB::listen(function () use (&$queryCount): void { + $queryCount++; + }); + + $brand1 = $directory->find(new ArcadiaId('1')); + + // THEN + expect($brand1)->not->toBeNull() + ->and($brand1?->name->value)->toBe('Le Gaulois') + ->and($queryCount)->toBe(0); + }); +});