Loading learning content...
In many codebases, caching is an afterthought—a quick optimization sprinkled wherever performance problems emerge. Cache implementations hide inside classes, tightly coupled to the code they optimize, making them impossible to test, configure, or replace.
World-class systems take a different approach: they treat cache as a first-class collaborator. Just as you inject repositories, loggers, and other dependencies, cache becomes an explicit dependency with a defined interface. This architectural elevation transforms caching from a hidden optimization into a visible, testable, and configurable component of your system.
This approach is foundational in enterprise architectures, distributed systems, and any application where cache behavior significantly impacts correctness, performance, and operational visibility.
By the end of this page, you will understand how to design cache interfaces following SOLID principles, inject cache dependencies cleanly into your objects, test cached behavior effectively, and implement cache decorators and wrappers for transparent caching.
The first step in treating cache as a collaborator is extracting it from the classes that use it. Instead of creating cache internally, objects receive cache implementations through constructor injection.
From Hidden Cache to Explicit Dependency:
12345678910111213141516171819
// ❌ Cache hidden inside classclass UserService { private cache = new Map(); async getUser(id: string) { if (this.cache.has(id)) { return this.cache.get(id); } const user = await this.db.find(id); this.cache.set(id, user); return user; }} // Problems:// - Can't test caching behavior// - Can't swap cache implementations// - Can't share cache across services// - Can't configure cache externally12345678910111213141516171819202122
// ✅ Cache as explicit dependencyclass UserService { constructor( private db: UserRepository, private cache: Cache<User> ) {} async getUser(id: string) { const cached = await this.cache.get(id); if (cached) return cached; const user = await this.db.find(id); await this.cache.set(id, user); return user; }} // Benefits:// - Testable with mock cache// - Swappable implementations// - Shareable across services// - Externally configurableDesigning the Cache Interface:
A well-designed cache interface follows the Interface Segregation Principle—clients should only depend on methods they use:
12345678910111213141516171819202122232425262728293031323334353637383940
// Core read operationsinterface CacheReader<T> { get(key: string): Promise<T | null>; has(key: string): Promise<boolean>; getMany(keys: string[]): Promise<Map<string, T>>;} // Core write operationsinterface CacheWriter<T> { set(key: string, value: T, options?: CacheOptions): Promise<void>; delete(key: string): Promise<boolean>; clear(): Promise<void>;} // Cache optionsinterface CacheOptions { ttl?: number; // Time-to-live in milliseconds tags?: string[]; // Tags for batch invalidation priority?: 'low' | 'normal' | 'high'; // Eviction priority} // Full cache interfaceinterface Cache<T> extends CacheReader<T>, CacheWriter<T> { // Cache statistics for monitoring getStats(): CacheStats;} interface CacheStats { hits: number; misses: number; size: number; maxSize: number; hitRate: number;} // Tag-based invalidation extensioninterface TaggableCache<T> extends Cache<T> { invalidateByTag(tag: string): Promise<number>; invalidateByTags(tags: string[]): Promise<number>;}With a clean interface defined, you can create multiple implementations optimized for different scenarios:
In-Memory Cache Implementation:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
class InMemoryCache<T> implements Cache<T> { private store = new Map<string, CacheEntry<T>>(); private stats = { hits: 0, misses: 0 }; constructor(private readonly maxSize: number = 1000) {} async get(key: string): Promise<T | null> { const entry = this.store.get(key); if (!entry) { this.stats.misses++; return null; } if (entry.expiresAt && entry.expiresAt < Date.now()) { this.store.delete(key); this.stats.misses++; return null; } this.stats.hits++; return entry.value; } async set(key: string, value: T, options?: CacheOptions): Promise<void> { // Evict if at capacity if (this.store.size >= this.maxSize && !this.store.has(key)) { this.evictOldest(); } this.store.set(key, { value, createdAt: Date.now(), expiresAt: options?.ttl ? Date.now() + options.ttl : undefined, tags: options?.tags || [], }); } async delete(key: string): Promise<boolean> { return this.store.delete(key); } async clear(): Promise<void> { this.store.clear(); } async has(key: string): Promise<boolean> { return (await this.get(key)) !== null; } async getMany(keys: string[]): Promise<Map<string, T>> { const result = new Map<string, T>(); for (const key of keys) { const value = await this.get(key); if (value !== null) { result.set(key, value); } } return result; } getStats(): CacheStats { const total = this.stats.hits + this.stats.misses; return { ...this.stats, size: this.store.size, maxSize: this.maxSize, hitRate: total > 0 ? this.stats.hits / total : 0, }; } private evictOldest(): void { const oldestKey = this.store.keys().next().value; if (oldestKey) this.store.delete(oldestKey); }} interface CacheEntry<T> { value: T; createdAt: number; expiresAt?: number; tags: string[];}Null Cache for Testing:
A null implementation that caches nothing—useful for tests that verify non-cached behavior:
123456789101112131415161718192021222324252627282930313233343536373839
class NullCache<T> implements Cache<T> { async get(_key: string): Promise<T | null> { return null; // Always miss } async set(_key: string, _value: T): Promise<void> { // Do nothing } async delete(_key: string): Promise<boolean> { return false; } async clear(): Promise<void> {} async has(_key: string): Promise<boolean> { return false; } async getMany(_keys: string[]): Promise<Map<string, T>> { return new Map(); } getStats(): CacheStats { return { hits: 0, misses: 0, size: 0, maxSize: 0, hitRate: 0 }; }} // Usage in testsdescribe('UserService', () => { it('fetches from database when cache misses', async () => { const mockDb = { find: jest.fn().mockResolvedValue({ id: '1', name: 'Test' }) }; const service = new UserService(mockDb, new NullCache()); await service.getUser('1'); expect(mockDb.find).toHaveBeenCalledWith('1'); });});The Decorator pattern enables adding caching behavior without modifying existing classes. A caching decorator wraps another implementation and intercepts calls to check/populate the cache.
Caching Repository Decorator:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
interface UserRepository { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; save(user: User): Promise<void>; delete(id: string): Promise<void>;} // Caching decorator wraps any UserRepository implementationclass CachingUserRepository implements UserRepository { constructor( private readonly inner: UserRepository, private readonly cache: Cache<User>, private readonly ttl: number = 300000 // 5 minutes ) {} async findById(id: string): Promise<User | null> { const cacheKey = `user:id:${id}`; // Try cache first const cached = await this.cache.get(cacheKey); if (cached) return cached; // Cache miss - fetch from inner repository const user = await this.inner.findById(id); if (user) { await this.cache.set(cacheKey, user, { ttl: this.ttl, tags: [`user:${id}`] }); } return user; } async findByEmail(email: string): Promise<User | null> { const cacheKey = `user:email:${email}`; const cached = await this.cache.get(cacheKey); if (cached) return cached; const user = await this.inner.findByEmail(email); if (user) { await this.cache.set(cacheKey, user, { ttl: this.ttl, tags: [`user:${user.id}`] }); } return user; } async save(user: User): Promise<void> { await this.inner.save(user); // Invalidate all cache entries for this user if (this.cache instanceof TaggableCache) { await (this.cache as TaggableCache<User>).invalidateByTag(`user:${user.id}`); } } async delete(id: string): Promise<void> { await this.inner.delete(id); // Invalidate cache if (this.cache instanceof TaggableCache) { await (this.cache as TaggableCache<User>).invalidateByTag(`user:${id}`); } }} // Composition in dependency injection containerconst container = { userRepository: () => { const dbRepo = new PostgresUserRepository(db); const cache = new RedisCache<User>(redis, 'users'); return new CachingUserRepository(dbRepo, cache); }};Decorators compose beautifully. You can wrap a repository with caching, then wrap that with logging, then wrap that with metrics—each decorator adding behavior without modifying the others. This is the Open/Closed Principle in action.
When cache is an explicit collaborator, testing becomes straightforward. You can verify caching behavior by injecting mock caches that track interactions:
Testing Cache Interactions:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
class MockCache<T> implements Cache<T> { private store = new Map<string, T>(); public getCalls: string[] = []; public setCalls: Array<{ key: string; value: T }> = []; async get(key: string): Promise<T | null> { this.getCalls.push(key); return this.store.get(key) ?? null; } async set(key: string, value: T): Promise<void> { this.setCalls.push({ key, value }); this.store.set(key, value); } // ... other methods // Test helpers prime(key: string, value: T): void { this.store.set(key, value); } reset(): void { this.store.clear(); this.getCalls = []; this.setCalls = []; }} describe('CachingUserRepository', () => { let mockCache: MockCache<User>; let mockInner: jest.Mocked<UserRepository>; let repo: CachingUserRepository; beforeEach(() => { mockCache = new MockCache(); mockInner = { findById: jest.fn(), findByEmail: jest.fn(), save: jest.fn(), delete: jest.fn(), }; repo = new CachingUserRepository(mockInner, mockCache); }); it('returns cached user without hitting database', async () => { const user = { id: '1', name: 'Cached User' }; mockCache.prime('user:id:1', user); const result = await repo.findById('1'); expect(result).toEqual(user); expect(mockInner.findById).not.toHaveBeenCalled(); }); it('caches user after database fetch', async () => { const user = { id: '1', name: 'DB User' }; mockInner.findById.mockResolvedValue(user); await repo.findById('1'); expect(mockCache.setCalls).toContainEqual( expect.objectContaining({ key: 'user:id:1', value: user }) ); }); it('invalidates cache on save', async () => { const user = { id: '1', name: 'Updated User' }; mockCache.prime('user:id:1', { id: '1', name: 'Old' }); await repo.save(user); // Verify cache was invalidated (implementation dependent) });});Congratulations! You have completed the Caching in Object Design module. You now understand memoization patterns, lazy loading with caching, cached computed properties, and treating cache as a first-class collaborator. These patterns form the foundation for building high-performance, maintainable object-oriented systems.