feat(brands): add pagination caching, fast local resolution and resilient database fallback

This commit is contained in:
Jérémy Munsch 2026-09-14 17:30:51 +02:00
parent df04ab87ac
commit 106d4467c4
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
8 changed files with 429 additions and 68 deletions

View file

@ -28,7 +28,7 @@ final readonly class FindAllBrands implements Usecase
$brandItems = [];
foreach ($paginated->getItems() as $arcadiaBrand) {
$brand = $this->brandResolver->resolve($arcadiaBrand->id);
$brand = $this->brandResolver->resolveFromArcadia($arcadiaBrand);
$brandItems[] = BrandItem::fromDomain($brand);
}

View file

@ -8,6 +8,7 @@ use Domain\Events\BrandCreated;
use Domain\Events\BrandRenamed;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Domain\Ports\Repositories\BrandRepository;
@ -31,10 +32,30 @@ final readonly class BrandResolver
public function resolve(ArcadiaId $arcadiaBrandCode): Brand
{
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrandCode);
$arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaBrandCode);
if ($existingBrand !== null) {
if ($arcadiaBrand !== null && ! $existingBrand->name->equals($arcadiaBrand->name)) {
return $existingBrand;
}
$arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaBrandCode);
if ($arcadiaBrand === null) {
throw BrandNotFoundException::forArcadiaId($arcadiaBrandCode);
}
return $this->resolveFromArcadia($arcadiaBrand);
}
/**
* Resolve an existing or new Brand entity from an ArcadiaBrand VO.
* Handles name updates (BrandRenamed) and creation (BrandCreated).
*/
public function resolveFromArcadia(ArcadiaBrand $arcadiaBrand): Brand
{
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrand->id);
if ($existingBrand !== null) {
if (! $existingBrand->name->equals($arcadiaBrand->name)) {
$oldName = $existingBrand->name;
$existingBrand = $existingBrand->rename($arcadiaBrand->name);
@ -53,10 +74,6 @@ final readonly class BrandResolver
return $existingBrand;
}
if ($arcadiaBrand === null) {
throw BrandNotFoundException::forArcadiaId($arcadiaBrandCode);
}
do {
$code = $this->brandCodeGenerator->generate($arcadiaBrand->name);
} while ($this->brandRepository->findByCode($code) !== null);

View file

@ -7,10 +7,13 @@ namespace Infra\Adapters\Directories;
use Database\ArcadiaReplicate\Tables\Refp\MarqueTable;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Directories\ArcadiaBrandDirectory as ArcadiaBrandDirectoryPort;
use Illuminate\Database\Connection;
use Illuminate\Database\Query\Builder;
use Infra\Adapters\Mappers\ArcadiaBrandMapper;
use Infra\Cache\BrandCache;
use Infra\Database\Models\Brand as BrandModel;
use Shared\Paginated;
use Shared\Pagination;
@ -21,40 +24,57 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort
public function __construct(
private readonly Connection $connection,
private readonly ?BrandCache $brandCache = null,
) {
}
#[\Override]
public function findAll(Pagination $pagination): Paginated
{
$query = $this->connection
->table(MarqueTable::table($this->connection))
->where('COD_MARQ', '>', 0)
->where('LIB_MARQ', 'NOT LIKE', 'Z -%')
->where(static function (Builder $q): void {
$q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1);
})
->where(static function (Builder $q): void {
$q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99);
});
$total = (int) (clone $query)->count();
$rows = (clone $query)
->orderBy('COD_MARQ', 'asc')
->offset($pagination->from())
->limit($pagination->pageSize())
->get();
$items = [];
foreach ($rows as $row) {
$domain = ArcadiaBrandMapper::toDomain($row);
$this->cache[$domain->id->value] = $domain;
$items[] = $domain;
if ($this->brandCache !== null) {
$cached = $this->brandCache->getArcadiaPaginated($pagination);
if ($cached !== null) {
return $cached;
}
}
return new Paginated($pagination, $items, $total);
try {
$query = $this->connection
->table(MarqueTable::table($this->connection))
->where('COD_MARQ', '>', 0)
->where('LIB_MARQ', 'NOT LIKE', 'Z -%')
->where(static function (Builder $q): void {
$q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1);
})
->where(static function (Builder $q): void {
$q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99);
});
$total = (int) (clone $query)->count();
$rows = (clone $query)
->orderBy('COD_MARQ', 'asc')
->offset($pagination->from())
->limit($pagination->pageSize())
->get();
$items = [];
foreach ($rows as $row) {
$domain = ArcadiaBrandMapper::toDomain($row);
$this->cache[$domain->id->value] = $domain;
$items[] = $domain;
}
/** @var list<ArcadiaBrand> $items */
$paginated = new Paginated($pagination, $items, $total);
$this->brandCache?->putArcadiaPaginated($pagination, $paginated);
return $paginated;
} catch (\Throwable $e) {
return $this->fallbackFindAll($pagination, $e);
}
}
#[\Override]
@ -64,26 +84,83 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort
return $this->cache[$id->value];
}
$row = $this->connection
->table(MarqueTable::table($this->connection))
->where('COD_MARQ', $id->value)
->where('COD_MARQ', '>', 0)
->where('LIB_MARQ', 'NOT LIKE', 'Z -%')
->where(static function (Builder $q): void {
$q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1);
})
->where(static function (Builder $q): void {
$q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99);
})
try {
$row = $this->connection
->table(MarqueTable::table($this->connection))
->where('COD_MARQ', $id->value)
->where('COD_MARQ', '>', 0)
->where('LIB_MARQ', 'NOT LIKE', 'Z -%')
->where(static function (Builder $q): void {
$q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1);
})
->where(static function (Builder $q): void {
$q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99);
})
->first();
if ($row === null) {
$this->cache[$id->value] = null;
return null;
}
$domain = ArcadiaBrandMapper::toDomain($row);
$this->cache[$id->value] = $domain;
return $domain;
} catch (\Throwable) {
return $this->fallbackFind($id);
}
}
/**
* @return Paginated<ArcadiaBrand>
*/
private function fallbackFindAll(Pagination $pagination, \Throwable $originalException): Paginated
{
$query = BrandModel::query()->orderBy('name', 'asc');
$total = $query->count();
if ($total === 0) {
throw $originalException;
}
$models = $query
->offset($pagination->from())
->limit($pagination->pageSize())
->get();
$items = [];
foreach ($models as $model) {
$arcadiaId = $model->arcadia_id instanceof ArcadiaId
? $model->arcadia_id
: new ArcadiaId((string) $model->arcadia_id);
$items[] = new ArcadiaBrand(
$arcadiaId,
new BrandName($model->name)
);
}
/** @var list<ArcadiaBrand> $items */
return new Paginated($pagination, $items, $total);
}
private function fallbackFind(ArcadiaId $id): ?ArcadiaBrand
{
$model = BrandModel::query()
->where('arcadia_id', $id->value)
->first();
if ($row === null) {
$this->cache[$id->value] = null;
if ($model === null) {
return null;
}
$domain = ArcadiaBrandMapper::toDomain($row);
$arcadiaId = $model->arcadia_id instanceof ArcadiaId
? $model->arcadia_id
: new ArcadiaId((string) $model->arcadia_id);
$domain = new ArcadiaBrand($arcadiaId, new BrandName($model->name));
$this->cache[$id->value] = $domain;
return $domain;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Infra\Cache;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
@ -12,6 +13,8 @@ use Domain\Models\ValueObjects\Brand\BrandName;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Ramsey\Uuid\Uuid;
use Shared\Paginated;
use Shared\Pagination;
final readonly class BrandCache
{
@ -90,6 +93,60 @@ final readonly class BrandCache
}
}
/**
* @return Paginated<ArcadiaBrand>|null
*/
public function getArcadiaPaginated(Pagination $pagination): ?Paginated
{
try {
$data = $this->cacheStore->get($this->paginationKey($pagination));
if (! is_array($data) || ! isset($data['total'], $data['items']) || ! is_int($data['total']) || ! is_array($data['items'])) {
return null;
}
$items = [];
foreach ($data['items'] as $item) {
if (! is_array($item) || ! isset($item['id'], $item['name']) || ! is_string($item['id']) || ! is_string($item['name'])) {
return null;
}
$items[] = new ArcadiaBrand(
new ArcadiaId($item['id']),
new BrandName($item['name'])
);
}
/** @var list<ArcadiaBrand> $items */
return new Paginated($pagination, $items, $data['total']);
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::getArcadiaPaginated failed', ['exception' => $e->getMessage()]);
return null;
}
}
/**
* @param Paginated<ArcadiaBrand> $paginated
*/
public function putArcadiaPaginated(Pagination $pagination, Paginated $paginated): void
{
try {
$items = array_map(static fn (ArcadiaBrand $brand): array => [
'id' => $brand->id->value,
'name' => $brand->name->value,
], $paginated->getItems());
$data = [
'total' => $paginated->getTotal(),
'items' => $items,
];
$this->cacheStore->set($this->paginationKey($pagination), $data, self::CACHE_TTL_SECONDS);
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::putArcadiaPaginated failed', ['exception' => $e->getMessage()]);
}
}
public function put(Brand $brand): void
{
try {
@ -163,6 +220,11 @@ final readonly class BrandCache
return "brand:arcadia:{$arcadiaId->value}";
}
private function paginationKey(Pagination $pagination): string
{
return "brand:arcadia:page:{$pagination->pageNumber()}:size:{$pagination->pageSize()}";
}
/**
* @return array{id: string, name: string, code: string, arcadiaId: string}
*/

View file

@ -85,7 +85,10 @@ class DomainServiceProvider extends ServiceProvider
);
$this->app->bind(
ArcadiaBrandDirectory::class,
fn (Application $app) => new ArcadiaMarqueBrandDirectory($this->getArcadiaConnection($app))
fn (Application $app) => new ArcadiaMarqueBrandDirectory(
$this->getArcadiaConnection($app),
$app->make(BrandCache::class)
)
);
$this->app->bind(BrandRepository::class, EloquentBrandRepository::class);
$this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class);

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Adapters\Directories;
use Database\ArcadiaReplicate\Tables\Refp\MarqueTable;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Illuminate\Database\Connection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Infra\Adapters\Directories\ArcadiaMarqueBrandDirectory;
use Infra\Cache\BrandCache;
use Infra\Database\Models\Brand as BrandModel;
use Mockery;
use Shared\Pagination;
use Tests\TestCase;
use Ramsey\Uuid\Uuid;
uses(TestCase::class, RefreshDatabase::class);
covers(ArcadiaMarqueBrandDirectory::class);
beforeEach(function (): void {
Cache::flush();
BrandModel::query()->delete();
});
describe('ArcadiaMarqueBrandDirectory Integration Tests', function (): void {
it('finds brands and caches the paginated result', function (): void {
// GIVEN
$conn = DB::connection();
$brandCache = resolve(BrandCache::class);
$directory = new ArcadiaMarqueBrandDirectory($conn, $brandCache);
MarqueTable::up($conn->getSchemaBuilder());
$conn->table(MarqueTable::table($conn))->insert([
'COD_MARQ' => 1,
'LIB_MARQ' => 'Le Gaulois',
'A_EPURER' => 0,
'TYPE_MARQUE' => 1,
]);
$conn->table(MarqueTable::table($conn))->insert([
'COD_MARQ' => 2,
'LIB_MARQ' => 'Maître Coq',
'A_EPURER' => 0,
'TYPE_MARQUE' => 1,
]);
$pagination = Pagination::ofPage(1, 25);
// WHEN
$result = $directory->findAll($pagination);
// THEN
expect($result->getItems())->toHaveCount(2)
->and($result->getTotal())->toBe(2)
->and($result->getItems()[0]->name->value)->toBe('Le Gaulois')
->and($result->getItems()[1]->name->value)->toBe('Maître Coq');
// Check it was cached
$cached = $brandCache->getArcadiaPaginated($pagination);
expect($cached)->not->toBeNull()
->and($cached->getItems())->toHaveCount(2);
});
it('falls back to local BrandModel when database connection fails during findAll', function (): void {
// GIVEN
BrandModel::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Local Gaulois',
'arcadia_id' => new ArcadiaId('1'),
'uwil_public_id' => new BrandCode('LGL01'),
]);
/** @var Connection&Mockery\MockInterface $failingConnection */
$failingConnection = Mockery::mock(Connection::class);
$failingConnection->shouldReceive('table')
->andThrow(new \RuntimeException('Oracle is down'));
$directory = new ArcadiaMarqueBrandDirectory($failingConnection, null);
$pagination = Pagination::ofPage(1, 25);
// WHEN
$result = $directory->findAll($pagination);
// THEN
expect($result->getItems())->toHaveCount(1)
->and($result->getItems()[0]->id->value)->toBe('1')
->and($result->getItems()[0]->name->value)->toBe('Local Gaulois');
});
it('falls back to local BrandModel when database connection fails during find', function (): void {
// GIVEN
BrandModel::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Fallback Brand',
'arcadia_id' => new ArcadiaId('42'),
'uwil_public_id' => new BrandCode('FLB01'),
]);
/** @var Connection&Mockery\MockInterface $failingConnection */
$failingConnection = Mockery::mock(Connection::class);
$failingConnection->shouldReceive('table')
->andThrow(new \RuntimeException('Oracle is down'));
$directory = new ArcadiaMarqueBrandDirectory($failingConnection, null);
// WHEN
$found = $directory->find(new ArcadiaId('42'));
// THEN
expect($found)->not->toBeNull()
->and($found->id->value)->toBe('42')
->and($found->name->value)->toBe('Fallback Brand');
});
});

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Domain\Events\BrandCreated;
use Domain\Events\BrandRenamed;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
@ -20,6 +19,7 @@ use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Event\Event;
use Domain\Events\BrandRenamed;
covers(BrandResolver::class);
@ -118,7 +118,7 @@ describe('BrandResolver Unit Tests', function (): void {
->toThrow(BrandNotFoundException::class, 'Arcadia brand with ID 999 not found.');
});
it('updates brand name, persists and dispatches BrandRenamed when Arcadia brand name changes', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
it('returns existing brand directly without querying Arcadia directory when already in repository', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$oldBrand = Brand::hydrate(
@ -129,7 +129,7 @@ describe('BrandResolver Unit Tests', function (): void {
);
$brandRepository->add($oldBrand);
// Arcadia has renamed the brand
// Arcadia directory has a different name, but resolve() prioritizes local repository without extra Oracle calls
$arcadiaDirectory->add(new ArcadiaBrand(new ArcadiaId('1'), new BrandName('Le Gaulois France')));
$dispatcher = new class () implements DomainEventDispatcher
@ -149,22 +149,9 @@ describe('BrandResolver Unit Tests', function (): void {
$resolved = $resolver->resolve(new ArcadiaId('1'));
// THEN
expect($resolved->name->value)->toBe('Le Gaulois France')
->and($resolved->id()->value)->toBe($brandId->value)
->and($resolved->code->value)->toBe('LGL01');
$persisted = $brandRepository->findByArcadiaId(new ArcadiaId('1'));
expect($persisted)->not->toBeNull()
->and($persisted->name->value)->toBe('Le Gaulois France');
expect($dispatcher->dispatched)->toHaveCount(1)
->and($dispatcher->dispatched[0])->toBeInstanceOf(BrandRenamed::class);
/** @var \Domain\Events\BrandRenamed $event */
$event = $dispatcher->dispatched[0];
expect($event->oldName->value)->toBe('Le Gaulois')
->and($event->newName->value)->toBe('Le Gaulois France')
->and($event->brandId->value)->toBe($brandId->value);
expect($resolved)->toBe($oldBrand)
->and($resolved->name->value)->toBe('Le Gaulois')
->and($dispatcher->dispatched)->toBeEmpty();
});
it('preserves and returns existing brand when brand was removed from Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
@ -202,4 +189,52 @@ describe('BrandResolver Unit Tests', function (): void {
->and($resolved->name->value)->toBe('Historical Brand')
->and($dispatcher->dispatched)->toBeEmpty();
});
it('updates brand name, persists and dispatches BrandRenamed when resolving from ArcadiaBrand with changed name', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$oldBrand = Brand::hydrate(
id: $brandId,
name: new BrandName('Le Gaulois'),
code: new BrandCode('LGL01'),
arcadiaId: new ArcadiaId('1'),
);
$brandRepository->add($oldBrand);
$renamedArcadia = new ArcadiaBrand(new ArcadiaId('1'), new BrandName('Le Gaulois France'));
$dispatcher = new class () implements DomainEventDispatcher
{
/** @var list<Event> */
public array $dispatched = [];
public function dispatch(Event $event): void
{
$this->dispatched[] = $event;
}
};
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher);
// WHEN
$resolved = $resolver->resolveFromArcadia($renamedArcadia);
// THEN
expect($resolved->name->value)->toBe('Le Gaulois France')
->and($resolved->id()->value)->toBe($brandId->value)
->and($resolved->code->value)->toBe('LGL01');
$persisted = $brandRepository->findByArcadiaId(new ArcadiaId('1'));
expect($persisted)->not->toBeNull()
->and($persisted->name->value)->toBe('Le Gaulois France');
expect($dispatcher->dispatched)->toHaveCount(1)
->and($dispatcher->dispatched[0])->toBeInstanceOf(BrandRenamed::class);
/** @var \Domain\Events\BrandRenamed $event */
$event = $dispatcher->dispatched[0];
expect($event->oldName->value)->toBe('Le Gaulois')
->and($event->newName->value)->toBe('Le Gaulois France')
->and($event->brandId->value)->toBe($brandId->value);
});
});

View file

@ -14,6 +14,9 @@ use Mockery;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Ramsey\Uuid\Uuid;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Shared\Paginated;
use Shared\Pagination;
covers(BrandCache::class);
@ -311,4 +314,49 @@ describe('BrandCache Unit Tests', function (): void {
// No exception is thrown
expect(true)->toBeTrue();
});
it('gets and puts arcadia paginated results to cache', function (): void {
// GIVEN
$pagination = Pagination::ofPage(1, 25);
$brand1 = new ArcadiaBrand(new ArcadiaId('1'), new BrandName('Brand One'));
$brand2 = new ArcadiaBrand(new ArcadiaId('2'), new BrandName('Brand Two'));
$paginated = new Paginated($pagination, [$brand1, $brand2], 2);
$expectedData = [
'total' => 2,
'items' => [
['id' => '1', 'name' => 'Brand One'],
['id' => '2', 'name' => 'Brand Two'],
],
];
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('set')
->once()
->with('brand:arcadia:page:1:size:25', $expectedData, 86400);
$cacheMock->shouldReceive('get')
->once()
->with('brand:arcadia:page:1:size:25')
->andReturn($expectedData);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$brandCache->putArcadiaPaginated($pagination, $paginated);
$result = $brandCache->getArcadiaPaginated($pagination);
// THEN
expect($result)->not->toBeNull()
->and($result->getTotal())->toBe(2)
->and($result->getItems())->toHaveCount(2)
->and($result->getItems()[0]->id->value)->toBe('1')
->and($result->getItems()[0]->name->value)->toBe('Brand One')
->and($result->getItems()[1]->id->value)->toBe('2')
->and($result->getItems()[1]->name->value)->toBe('Brand Two');
});
});