| name | cache-management |
| description | Implement production-grade caching with cache keys/TTLs/consistency classes per query, SWR (stale-while-revalidate), explicit invalidation, HTTP cache headers, and comprehensive testing for stale reads and cache warmup. Use when adding caching to queries, implementing cache invalidation, configuring HTTP caching, or ensuring cache consistency and performance. |
Cache Management Skill
Context (Input)
Use this skill when:
- Adding caching to repositories or expensive queries
- Implementing cache invalidation via domain events
- Defining cache keys, TTLs, and consistency requirements
- Implementing stale-while-revalidate (SWR) pattern
- Configuring HTTP cache headers (Cache-Control, ETag, Vary)
- Testing cache behavior (stale reads, cold start, invalidation)
- Reducing database load with caching
- Setting up async event-driven cache invalidation
Task (Function)
Implement production-ready caching with proper key design, TTL management, event-driven invalidation, HTTP cache headers, and comprehensive testing.
Success Criteria:
- Cache policy declared for each query (key, TTL, consistency class)
- Decorator pattern with
CachedXxxRepository wrapping MongoXxxRepository
- Event-driven invalidation via domain event subscribers
- Marker interface pattern for auto-binding cache pools
- Best-effort invalidation (try/catch, never fail business operations)
- HTTP cache headers configured (Cache-Control, ETag for API responses)
- Async event processing via message queue (AP from CAP theorem)
- Comprehensive unit tests for all cache paths
- Cache observability (hit/miss/error logging)
make ci outputs "โ
CI checks successfully passed!"
โ ๏ธ CRITICAL CACHE POLICY
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ALWAYS use Decorator Pattern for caching (wrap repositories) โ
โ ALWAYS use CacheKeyBuilder service (prevent key drift) โ
โ ALWAYS invalidate via Domain Events (decouple from business) โ
โ ALWAYS use TagAwareCacheInterface for cache tags โ
โ ALWAYS wrap cache ops in try/catch (best-effort, no failures)โ
โ ALWAYS use Marker Interface for auto-binding cache pools โ
โ ALWAYS process invalidation async (AP from CAP theorem) โ
โ โ
โ โ FORBIDDEN: Caching in repository, implicit invalidation โ
โ โ
REQUIRED: Decorator pattern, event-driven invalidation โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
CAP Theorem: Why We Choose AP (Availability + Partition Tolerance)
Cache invalidation follows AP from CAP theorem - we prioritize:
- Availability: Business operations never fail due to cache issues
- Partition Tolerance: System works even when cache is unavailable
Trade-off: Brief staleness is acceptable over blocking writes.
Implementation:
- Cache errors fallback to database (try/catch everywhere)
- Invalidation processed asynchronously via message queue
- Exceptions in subscribers are logged + emit metrics (self-healing)
- Business operations complete even if cache invalidation fails
Non-negotiable requirements:
- Use Decorator Pattern:
CachedXxxRepository wraps MongoXxxRepository
- Use centralized
CacheKeyBuilder service (in Shared/Infrastructure/Cache)
- Invalidate via Domain Event Subscribers (one subscriber per event)
- Use Marker Interface for auto-binding cache pools via
_instanceof
- Process cache invalidation asynchronously via message queue
- Wrap ALL cache operations in try/catch (never fail business operations)
- Use
TagAwareCacheInterface (not CacheInterface) for tag support
- Configure test cache pools with
tags: true in config/packages/test/cache.yaml
- Log cache operations for observability
File Locations (This Codebase)
These are example locations based on the Codely/Hexagonal structure used in VilnaCRM services.
Adapt the bounded context (User, OAuth, etc.) to your feature.
| Component | Typical Location |
|---|
| CacheKeyBuilder | src/Shared/Infrastructure/Cache/CacheKeyBuilder.php |
| CachedXxxRepository | src/{Context}/{Bounded}/Infrastructure/Repository/CachedXxxRepository.php |
| Base repository (inner) | src/{Context}/{Bounded}/Infrastructure/Repository/*Repository.php |
| Marker interface | src/{Context}/{Bounded}/Application/EventSubscriber/*CacheInvalidationSubscriberInterface.php |
| Invalidation subscriber | src/{Context}/{Bounded}/Application/EventSubscriber/*CacheInvalidationSubscriber.php |
| Cache pool config | config/packages/cache.yaml |
| Test cache config | config/packages/test/cache.yaml |
| Service wiring / aliases | config/services.yaml |
| HTTP cache tests | tests/Integration/*HttpCacheTest.php |
| Unit tests | tests/Unit/** |
| Integration tests (optional) | tests/Integration/** |
TL;DR - Cache Management Checklist
Before Implementing Cache:
Architecture Setup:
During Implementation:
Testing:
Before Merge:
Quick Start: Cache in 9 Steps
Step 1: Declare Cache Policy
Before writing code, declare the complete policy:
Step 2: Create CacheKeyBuilder Service
Location: src/Shared/Infrastructure/Cache/CacheKeyBuilder.php
final readonly class CacheKeyBuilder
{
public function __construct(private SerializerInterface $serializer)
{
}
public function build(string $namespace, string ...$parts): string
{
return $namespace . '.' . implode('.', $parts);
}
public function buildCustomerKey(string $customerId): string
{
return $this->build('customer', $customerId);
}
public function buildCustomerEmailKey(string $email): string
{
return $this->build('customer', 'email', $this->hashEmail());
}
{
();
->(
,
,
(, ->serializer->(, ::))
);
}
{
(, ());
}
}
Step 3: Create Cached Repository Decorator
Location: src/{Context}/{Entity}/Infrastructure/Repository/Cached{Entity}Repository.php
final class CachedCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private CustomerRepositoryInterface $inner, // Wraps base repository
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
public function __call(string $method, array $arguments): mixed
{
return $this->inner->{$method}(...$arguments);
}
public function find(mixed $id, int $lockMode = 0, ?int $lockVersion = null): ?Customer
{
$cacheKey = $this->cacheKeyBuilder->(() );
{
->cache->(
,
fn (ItemInterface ) => ->(, , , , ),
beta:
);
} (\ ) {
->(, );
->inner->(, , );
}
}
{
->inner->();
}
{
->();
->([, ]);
->logger->(, [
=> ,
=> ,
=> ,
]);
->inner->(, , );
}
{
->logger->(, [
=> ,
=> ->(),
=> ,
]);
}
}
Step 4: Create Marker Interface for Auto-Binding
Location: src/{Context}/{Entity}/Application/EventSubscriber/{Entity}CacheInvalidationSubscriberInterface.php
Purpose: Enables automatic cache pool injection via _instanceof in services.yaml.
<?php
declare(strict_types=1);
namespace App\Core\Customer\Application\EventSubscriber;
use App\Shared\Domain\Bus\Event\DomainEventSubscriberInterface;
interface CustomerCacheInvalidationSubscriberInterface extends DomainEventSubscriberInterface
{
}
Step 5: Create Event Subscribers for Invalidation
Location: src/{Context}/{Entity}/Application/EventSubscriber/{Event}CacheInvalidationSubscriber.php
IMPORTANT: Create ONE subscriber per event. Implement the marker interface.
final readonly class CustomerUpdatedCacheInvalidationSubscriber implements
CustomerCacheInvalidationSubscriberInterface
{
public function __construct(
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
public function __invoke(CustomerUpdatedEvent $event): void
{
$tagsToInvalidate = $this->buildTagsToInvalidate($event);
$this->cache->invalidateTags($tagsToInvalidate);
$this->logSuccess($event);
}
public function subscribedTo(): array
{
return [::];
}
{
= [
. ->(),
. ->cacheKeyBuilder->(->()),
,
];
(->() && ->() !== ) {
[] = . ->cacheKeyBuilder->(->());
}
;
}
{
->logger->(, [
=> ->(),
=> ->(),
=> ,
=> ,
]);
}
}
Step 6: Configure services.yaml with Marker Interface
CRITICAL: Use _instanceof with the marker interface for auto-binding cache pools.
services:
App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository:
public: true
App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository:
arguments:
$inner: '@App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository'
$cache: '@cache.customer'
App\Core\Customer\Domain\Repository\CustomerRepositoryInterface:
alias: App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository
public: true
_instanceof:
App\Core\Customer\Application\EventSubscriber\CustomerCacheInvalidationSubscriberInterface:
bind:
$cache: '@cache.customer'
App\Shared\Domain\Bus\Event\DomainEventSubscriberInterface:
tags: ['app.event_subscriber']
App\Shared\Domain\Bus\Event\EventBusInterface:
alias: App\Shared\Infrastructure\Bus\Event\Async\ResilientAsyncEventBus
Step 7: Configure Cache Pools
Production - config/packages/cache.yaml:
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(resolve:REDIS_URL)%'
pools:
cache.customer:
adapter: cache.adapter.redis
default_lifetime: 600
provider: '%env(resolve:REDIS_URL)%'
tags: true
Test - config/packages/test/cache.yaml:
framework:
cache:
pools:
cache.customer:
adapter: cache.adapter.array
provider: null
tags: true
Step 8: Configure HTTP Cache Headers (API Platform)
For API endpoints, configure HTTP cache headers in your API Platform resource:
App\Core\Customer\Domain\Entity\Customer:
operations:
get:
class: ApiPlatform\Metadata\Get
cacheHeaders:
max_age: 600
shared_max_age: 600
public: true
vary: ['Accept', 'Accept-Language']
get_collection:
class: ApiPlatform\Metadata\GetCollection
cacheHeaders:
max_age: 300
shared_max_age: 600
public: true
vary: ['Accept', 'Accept-Language']
HTTP Cache Headers Explained:
| Header | Single Resource | Collection | Purpose |
|---|
max-age | 600s (10 min) | 300s (5 min) | Browser cache TTL |
s-maxage | 600s | 600s | CDN/proxy cache TTL |
public | true | true | Allow shared caching |
Vary | Accept, Accept-Language | Accept, Accept-Language | Cache key variants |
ETag | Auto-generated | Auto-generated | Conditional requests |
ETag Behavior:
- ETag is automatically generated based on resource content
- ETag changes after resource modification
- Clients can use
If-None-Match for conditional requests
- Returns
304 Not Modified if resource unchanged
Step 9: Verify with CI
make ci
HTTP Cache Testing
Test HTTP cache headers in integration tests:
final class CustomerHttpCacheTest extends ApiTestCase
{
public function testGetCustomerReturnsCacheControlHeaders(): void
{
$client = self::createClient();
$customer = $this->createTestCustomer();
$client->request('GET', "/api/customers/{$customer->getUlid()}");
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Cache-Control', 'max-age=600, public, s-maxage=600');
self::assertResponseHasHeader('ETag');
}
public function testGetCustomerCollectionReturnsCacheControlHeaders(): void
{
$client = self::createClient();
$this->createTestCustomer();
$client->request('GET', '/api/customers');
self::assertResponseIsSuccessful();
::(, );
}
{
= ::();
= ->();
= ->(, );
= ->()[][] ?? ;
::();
->(, , [
=> [ => ],
=> [ => ],
]);
= ->(, );
= ->()[][] ?? ;
::(, );
}
}
Async Event Processing Architecture
Cache invalidation is processed asynchronously for resilience:
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ
โ Domain Event โโโโโโถโ ResilientAsyncEvent โโโโโโถโ SQS Queue โ
โ (Published) โ โ Dispatcher โ โ โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโฌโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโ โ
โ DomainEventMessage โโโโโโโโโโโโโโโโโ
โ Handler โ
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โ
โโโโโโโโโโโโผโโโโโโโโโโโโ
โ Cache Invalidation โ
โ Subscriber โ
โโโโโโโโโโโโโโโโโโโโโโโโ
Resilience Layers:
- Layer 1:
ResilientAsyncEventDispatcher catches SQS send failures
- Layer 2:
DomainEventMessageHandler catches subscriber failures
- All failures: Logged + emit metrics (self-healing pipeline)
Additional Resources
- Policy decisions:
reference/cache-policies.md
- Invalidation patterns:
reference/invalidation-strategies.md
- SWR details:
reference/swr-pattern.md
- End-to-end example:
examples/cache-implementation.md
- Tests guide:
examples/cache-testing.md