Advanced LLD Caching, rate limiting and persistence

Caching, rate limiting and persistence

Build three common infrastructure pieces inside a codebase, behind clean interfaces.

  • 15 min read
  • 6 chapters
  • Lesson 2 of 3

What you will learn

  1. 01Infrastructure inside the code
  2. 02An in-process cache: LRU
  3. 03Rate limiting: the token bucket
  4. 04The persistence layer: a repository
  5. 05Combine them with a decorator
  6. 06Tradeoffs to state
01

Infrastructure inside the code

System design talked about caches, limiters and databases as boxes. Here we look inside them as classes. The skill is to give each a small interface so the rest of the code does not care how it works.

allowedfind, saveon a missRate limiterCLOCKtoken bucketLot serviceLLDbusiness rulesCached repoCACHELRU in frontSQL storeDBsource of truth

Swipe sideways to pan · drag a box to move it

100%
Three pieces of infrastructure behind small interfaces.
02

An in-process cache: LRU

A cache needs a size limit and an eviction rule. LRU (least recently used) drops whatever has gone longest without use. A Map remembers insertion order, which makes it easy.

class LRU<K, V> {
  private m = new Map<K, V>();
  constructor(private cap: number) {}
  get(k: K): V | undefined {
    const v = this.m.get(k);
    if (v === undefined) return undefined;
    this.m.delete(k); this.m.set(k, v);     // mark as most recent
    return v;
  }
  set(k: K, v: V) {
    this.m.delete(k); this.m.set(k, v);
    if (this.m.size > this.cap) this.m.delete(this.m.keys().next().value as K);
  }
}

Add a time to live if staleness matters. Production caches also need protection against concurrent access, a topic from the previous lesson.

03

Rate limiting: the token bucket

A rate limiter protects a service from too many requests. The token bucket is flexible: a bucket holds up to cap tokens, refills at a steady rate, and each request spends one. A burst is allowed until the bucket empties, then the rate is enforced.

class TokenBucket {
  private tokens: number;
  private last: number;
  constructor(private cap: number, private perSec: number, now: number) {
    this.tokens = cap; this.last = now;
  }
  allow(now: number): boolean {
    this.tokens = Math.min(this.cap, this.tokens + ((now - this.last) / 1000) * this.perSec);
    this.last = now;
    if (this.tokens < 1) return false;
    this.tokens -= 1;
    return true;
  }
}

Note that time is passed in, which keeps the class pure enough to test without waiting. A fixed window counter is simpler but lets a double burst through at the boundary of two windows. A sliding window fixes that at the cost of storing more. Keep one bucket per user or key.

allowedfind, saveon a missRate limiterCLOCKtoken bucketLot serviceLLDbusiness rulesCached repoCACHELRU in frontSQL storeDBsource of truth

Swipe sideways to pan · drag a box to move it

100%
The limiter spends one token per request.
04

The persistence layer: a repository

Business code should not know SQL. A repository hides storage behind a collection-like interface.

interface TicketRepository {
  find(id: string): Promise<Ticket | undefined>;
  save(t: Ticket): Promise<void>;
}

The domain talks to this interface. One implementation uses SQL, another keeps a map for tests. This is dependency inversion at work. Related ideas: a transaction groups several saves so they all happen or none do, and a data transfer object is a plain shape for crossing a boundary, kept separate from the domain object so the database layout can change freely.

05

Combine them with a decorator

Because the cache and the repository share an interface, caching can be added by wrapping, with no change to either.

class CachedTicketRepository implements TicketRepository {
  constructor(private inner: TicketRepository, private cache: LRU<string, Ticket>) {}
  async find(id: string) {
    const hit = this.cache.get(id);
    if (hit) return hit;
    const t = await this.inner.find(id);
    if (t) this.cache.set(id, t);
    return t;
  }
  async save(t: Ticket) { await this.inner.save(t); this.cache.set(t.id, t); }
}
allowedfind, saveon a missRate limiterCLOCKtoken bucketLot serviceLLDbusiness rulesCached repoCACHELRU in frontSQL storeDBsource of truth

Swipe sideways to pan · drag a box to move it

100%
The cache wraps the repository as a decorator.
allowedfind, saveon a missRate limiterCLOCKtoken bucketLot serviceLLDbusiness rulesCached repoCACHELRU in frontSQL storeDBsource of truth

Swipe sideways to pan · drag a box to move it

100%
Only misses reach the database.
06

Tradeoffs to state

  • An in-process cache is fast but private to one server, and copies can differ across servers.
  • A limiter held in memory forgets on restart and does not span servers. Share the counts in a store when you need exact limits.
  • A repository adds a layer. It pays off when storage may change or when you want fast tests, and is wasteful for a tiny script.