AI EngineeringZero to ProductionHome·About·What’s new·Contact
System Design · Chapter SD4

Design patterns

Patterns as shared vocabulary: creational, structural & behavioral in real Python (with Pythonic shortcuts), climbing to two full machine-coding rounds — a rate limiter and an LRU cache.

⏱️ ~3 hours🧪 5 labs🎯 Beginner→Expert

Learning objectives

  • Explain what patterns are and when NOT to use them.
  • Implement patterns from all three families in runnable Python.
  • Give the Pythonic shortcut where the language makes a pattern trivial.
  • Complete two machine-coding rounds end-to-end.
▶ Runnable companionEvery code block here is also saved under code/sd4-design-patterns/ — pure Python / sqlite3, runs with no setup.

1 · What patterns are (and aren't) essential

Here's a scene you've probably sat through. Two engineers are staring at the same pull request, and one says "this should just be a Strategy." The other nods, and in three words a whole design decision is settled — no whiteboard, no long explanation, no arguing about the shape of the code. That's the real superpower of design patterns: not the code, but the shared vocabulary. A pattern is a name for a solution that so many people have reached independently that it earned its own word, the way "sandwich" saves you from saying "a filling between two slices of bread."

Think of them as recipes rather than ingredients. A recipe doesn't hand you the finished dish — it captures the shape of a good solution that generations of cooks converged on, so you don't rediscover it from scratch every time. When you learn "Observer," you're not memorizing lines to paste; you're learning to recognize a family of problems ("many things need to react when one thing changes") and the shape that reliably solves it. That recognition is what lets a senior engineer glance at a tangle of if-statements and say "that wants to be a State machine" — they've seen the pattern's silhouette before.

Now the mistake that makes patterns notorious — and it's worth naming loudly because it's the whole reason this section leads with a caution. Beginners who just learned patterns start seeing them everywhere and cram them in where a plain function would do. You end up with an AbstractWidgetFactoryProvider to build something a one-line dictionary lookup would have handled — a phenomenon so common it has a mocking nickname, "cargo-culting." Patterns are tools for a problem you actually have, not trophies to collect. The right instinct is the reverse: write the simplest thing that works, and reach for a pattern only when the simple thing starts to hurt (you're editing five files to add one option; a change in one place breaks three others). The pattern earns its keep by removing pain, never by adding ceremony.

This matters doubly in Python, and it's the running theme of this whole chapter. Many classic patterns were invented for languages like Java or C++, where the type system forces you to build scaffolding — interfaces, class hierarchies, factory objects — just to pass behavior around. Python has first-class functions (you can hand a function to another function like any other value), so several patterns that need a page of classes elsewhere collapse into "just pass a function." A Strategy is a function argument; a Singleton is a module you import. Knowing the pattern still matters — it names the problem and signals your intent to teammates — but the Pythonic implementation is often almost free. Below, the patterns are grouped into three families by what kind of problem they solve.

A design pattern is a named, reusable solution to a recurring design problem — shared vocabulary more than code to copy. Three families. Caution: patterns are tools, not goals; Python's first-class functions make several patterns nearly free.

one vocabulary, three families — grouped by the KIND of problem each solves Design patterns named, shared solutions Creational how objects are MADE Structural how objects COMPOSE Behavioral how objects INTERACT Factory · SingletonBuilder Adapter · DecoratorFacade Strategy · ObserverState Patterns aren't a flat list — they cluster by the problem they solve. Every pattern in this chapter drops into one of these three buckets, so the family name alone tells a teammate roughly what a pattern is for.
🗺️ How to read this diagram

Start at the top box — "design patterns" as one shared vocabulary — then follow the three arrows down: each splits the vocabulary into a family, and the bottom row lists example patterns that live in each.

  • The three coloured boxes are the families. The small line under each names the question it answers: Creational = "how are objects made?", Structural = "how do they compose?", Behavioral = "how do they interact?".
  • The blue arrows fan out from the single root, showing every pattern inherits the same idea (a named, reusable solution) but is sorted by intent.
  • The bottom row is the payoff: concrete pattern names (Factory, Decorator, Strategy…) mapped to their family — the exact grouping the rest of this page walks through section by section.

In short: name the family and you've already said half of what a pattern does — made, composed, or interacting.

FamilySolvesExamples
Creationalhow objects are madeFactory, Singleton, Builder
Structuralhow objects composeAdapter, Decorator, Facade
Behavioralhow objects interactStrategy, Observer, State

2 · Creational — Factory & Singleton essential

Picture the bug that teaches this pattern. Your codebase talks to S3 for storage, and you wrote S3() in forty different files. Then the client moves to a local disk for dev, and you're on a scavenger hunt through all forty, changing each one — and you miss two, and they only break in production at 2am. Every one of those S3() calls was a decision ("use S3") baked into a place that had no business making that decision. Creational patterns exist to pull the "which object do I build?" decision out of the code that just wants to use the object.

A Factory is the fix, and the everyday analogy is a restaurant kitchen. You order "the pasta" — you don't walk in and personally assemble it, and you certainly don't need to know whether the chef changed the recipe last week. One place (the kitchen, the factory function) knows how to build the thing; everyone else just asks by name. In code that means the caller says make_storage("s3") instead of S3(), so the day you swap backends you edit one lookup table and every one of those forty call sites keeps working, untouched.

A Singleton solves a different worry: "there must be exactly one of this." One config object, one connection pool, one logger — because if two parts of your program each made their own, they'd disagree, or you'd open twice the database connections you meant to. The picture is a building's single thermostat: everyone shares it, and turning it up in one room turns it up for the whole floor, because there's only one. In classic languages you enforce this with a special class that hands back the same instance every time (you'll see exactly that __new__ trick below).

Where the "don't over-apply" warning bites hardest — and it's the Singleton. The Singleton is the most abused pattern in the book, because a shared global object is really just a global variable wearing a costume, and globals make code hard to test (you can't swap in a fake) and hard to reason about (anyone can mutate it from anywhere). And in Python the ceremony is usually pointless: a module is already a singleton — import the same module twice and you get the same object — so a plain module-level object gives you "exactly one shared thing" for free, no class required. Reach for the class-based version only when you genuinely need lazy creation or subclass control; the rest of the time, prefer the module global (the practice ladder drives this home). Factories, by contrast, earn their keep the moment you have more than one "kind" to choose between at runtime.

FACTORY — dispatch by name SINGLETON — one shared object caller make_storage("s3") factory lookup table S3 Local swap backends = edit one table Config() Config() Config() one instance Factory hides which object; Singleton guarantees how many. The caller names a string and the factory builds the matching class; separately, every Config() call returns the one shared instance — two different creational jobs.
🗺️ How to read this diagram

The vertical dashed line splits two independent ideas: read the left half left-to-right for the Factory, then the right half for the Singleton.

  • Left (Factory): the caller passes a string like "s3" to make_storage (blue arrow). The factory's lookup table then dispatches — dashed grey arrows — to either the S3 or Local class. The caller never names a class, so swapping backends means editing that one table.
  • Right (Singleton): three separate Config() calls (stacked boxes) all point via blue arrows to a single purple "one instance" box. No matter how many times you construct it, you get the same object back.
  • The payoff contrast: Factory answers "which class do I build?" and Singleton answers "how many of this thing exist?" — both are creational, but they solve opposite questions.

In short: Factory fans out to many classes by name; Singleton funnels many calls in to one object.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 · Factory + Singleton
creational.pyfrom abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save(self, key): ...
class S3(Storage):
    def save(self, key): return f"S3:{key}"
class Local(Storage):
    def save(self, key): return f"local:{key}"

def make_storage(kind) -> Storage:            # FACTORY: callers don't name classes
    return {"s3": S3, "local": Local}[kind]()

class Config:                                 # SINGLETON: one shared instance
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls); cls._instance.data = {}
        return cls._instance

print(make_storage("s3").save("a"), make_storage("local").save("b"))
print("same config:", Config() is Config())
S3:a local:b
same config: True
▶ How this works

Two creational patterns in one file — they answer the question "how do I make objects?" without hard-coding class names everywhere. Factory hides which class to build behind one function; Singleton guarantees there is only ever one shared object of a kind (handy for config, a database connection, a logger).

  1. class Storage(ABC) with @abstractmethod def save is a contract: any storage must have a save method. S3 and Local are two real implementations that fill in that method differently.
  2. make_storage(kind) is the Factory. You ask for "s3" or "local" by name; it looks the name up in a little dictionary {"s3": S3, "local": Local} and the trailing () builds one. The caller never writes S3() directly, so swapping storage means changing one string.
  3. class Config is the Singleton. The special __new__ method runs before an object is created. It checks if cls._instance is None — the first time it builds and remembers one instance; every later Config() returns that same stored object instead of a fresh one.
  4. Config() is Config() uses is (same object in memory, not just equal values). It prints True, proving both calls handed back the one shared instance.

What the output means: S3:a local:b shows the factory built two different storages from strings; same config: True confirms the Singleton returned one and the same object twice.

Try this: Add a third backend — e.g. class Memory(Storage) — and one entry "mem": Memory to the factory dictionary. Notice you extend the system without touching make_storage's callers. That is the whole point of a Factory.

3 · Structural — Decorator & Adapter intermediate

Here's a rule you'll be handed on your first real team, and it feels maddening at first: "don't edit that class." Maybe it's shared by ten other teams, maybe it's third-party code you don't own, maybe it's load-bearing and nobody dares touch it. But you still need it to do something more — cache its results, log its calls, speak a different dialect. Structural patterns are the answer to "how do I change what an object does without opening it up?" They're about composition: snapping objects together so the combination does the new thing, while each original piece stays exactly as it was.

A Decorator is a gift box, and the analogy is worth taking literally. You wrap the original object in a new one that has the same shape (the same methods), so from the outside nobody can tell it's wrapped — but on the way in and out, the wrapper adds its own touch. Want to cache a slow fetcher? Wrap it: the wrapper checks its memory first and only calls the real fetcher on a miss. The fetcher never learns it's been wrapped, and you can stack wrappers (caching and logging and retry) like nesting boxes. This is the Open/Closed principle from SD3 made concrete — the class is closed to editing but open to extension through wrapping.

An Adapter solves a different mismatch: two things that should fit but don't, because their interfaces disagree. The physical analogy is the plug adapter in your travel bag — your laptop charger and the foreign wall socket both do "electricity," but the shapes don't match, so a little in-between piece translates one to the other. In code, a third-party sensor reports Celsius but your whole app is built around Fahrenheit; rather than sprinkle conversions everywhere, you write one adapter that presents the Fahrenheit interface your code expects and quietly does the ×9/5+32 inside. Your code depends only on the interface it always wanted; the foreign quirk is sealed in one place.

The tension to watch — and where beginners over-apply. Both patterns add a layer, and layers aren't free: another object to trace through when debugging, another hop for a reader to follow. The honest test is "am I absorbing a real mismatch or hiding a fixable one?" If you own the class and can just change it, change it — don't wrap your own code in an adapter to avoid a five-minute edit; that's ceremony, not design. Reach for a Decorator when the concern is genuinely cross-cutting (caching, logging, timing — things you'd otherwise copy into every method), and for an Adapter when the code you must fit is genuinely not yours to change. Used for those, they're clean; used to dodge a simple refactor, they're just indirection.

the Caching wrapper has the SAME get() — the caller can't tell it's wrapped caller ds.get("x") Caching wrapper cache seen "x"? store on miss DataSource real fetch (unchanged) miss only 1st call fetch runs 2nd call served from cache A Decorator adds behaviour by wrapping, not editing. The Caching box sits around the untouched DataSource, intercepts get, and only forwards to the real fetch on a cache miss — Open/Closed made concrete.
🗺️ How to read this diagram

Follow the call from the left: the caller invokes ds.get("x") and hits the purple wrapper first, not the real source directly.

  • The purple box is the Caching wrapper. Inside, the white cache box checks "have I seen this key?" — the green DataSource (the real, unchanged worker) is only reached on a miss, shown by the dashed grey arrow labelled "miss only".
  • The two boxes on the right are the outcome over time: the 1st call (green) misses and runs the real fetch; the 2nd call (blue) finds the value cached and skips the fetch entirely.
  • The key visual point: the wrapper has the same get method as the thing it wraps, so from the caller's side nothing changed — yet behaviour was added without ever editing DataSource.

In short: the wrapper stands in front, answers from memory when it can, and only bothers the real object on a miss.

Step 2 · Decorator adds caching to any fetcher
structural.pyclass DataSource:
    def get(self, key):
        print(f"  fetch {key}"); return f"value:{key}"

class Caching:                                 # wraps another DataSource
    def __init__(self, inner): self.inner, self._c = inner, {}
    def get(self, key):
        if key not in self._c: self._c[key] = self.inner.get(key)
        return self._c[key]

ds = Caching(DataSource())
print(ds.get("x")); print(ds.get("x"))         # 2nd call: no fetch
  fetch x
value:x
value:x
▶ How this works

The Decorator pattern adds new behaviour to an object by wrapping it, instead of editing the original class. Here we bolt caching onto any data source so a repeated lookup is served from memory rather than fetched again. The original DataSource is never changed — this is the Open/Closed idea from SD3 (open to extend, closed to edit).

  1. DataSource.get(key) is the real (slow) worker: it prints fetch key every time it runs, so you can see when an actual fetch happens.
  2. class Caching takes another data source in its __init__ (self.inner) and keeps an empty dictionary self._c as its cache. It has the same get method, so callers can't tell it apart from the real thing.
  3. Inside get: if key not in self._c — only when the answer is missing does it call self.inner.get(key) and store the result. Otherwise it returns the saved value.
  4. ds = Caching(DataSource()) wraps the real source. The first ds.get("x") triggers a fetch; the second finds "x" already cached and skips it.

What the output means: fetch x prints only once even though we called get("x") twice; both calls return value:x. The single fetch line is proof the cache worked.

Try this: Call ds.get("y") — you'll see a new fetch y. Then call it again and the fetch disappears. You just added caching to a class you never modified.

4 · Behavioral — Strategy & Observer (the Pythonic way) advanced

Every codebase has that one function — the one everyone's afraid of. It sorts results, and over two years it grew a staircase of if mode == "price": ... elif mode == "name": ... elif mode == "rating": ..., and now adding a fourth sort means editing the scary function again and re-testing all three old branches so you don't break them. That growing if/elif ladder is a smell, and behavioral patterns are the deodorant. They're about how objects talk to each other — who decides what, who tells whom — and they exist to keep that conversation flexible instead of hard-wired.

The Strategy pattern kills the if/elif ladder by turning each branch into a swappable piece. The analogy is a GPS asking "fastest, shortest, or scenic route?" — same trip, same machine, but you plug in a different algorithm for computing the route. Instead of the sort function knowing about every possible ordering, you hand it the ordering you want as a parameter, and it just applies whatever it's given. Adding a new sort is now writing a new little piece and passing it in — the scary function never changes, so there's nothing old to break.

The Observer pattern solves "when this happens, a bunch of other things need to know." Think of a group chat, or a magazine subscription: when an order is placed, the email service wants to know, and analytics wants to know, and inventory wants to know — but the order-placing code shouldn't have to hold a list of all of them and call each by hand (that list would grow forever, and order-placement would be entangled with everything downstream). Instead, interested parties subscribe, and the event just broadcasts; the publisher never learns who's listening or what they do. Add a new subscriber and nothing else changes.

And here is the punchline of this whole chapter, so read it twice. In Java, both of these are ceremonies: Strategy is an interface plus one class per algorithm; Observer is a Subject interface, an Observer interface, and registration boilerplate. In Python, a function is a value you can pass around and store in a list — so a Strategy is just a function argument (that's exactly what sorted(items, key=...) already is, a Strategy hiding in the standard library), and an Observer's subscriber list is just a list of functions. You will genuinely almost never write the class-heavy textbook version in Python; if you find yourself building a Strategy base class with subclasses, stop — that's the over-application trap, importing another language's tax into a language that doesn't charge it. Know the pattern so you recognize the problem, then reach for the plain function that solves it.

STRATEGY — swap the algorithm OBSERVER — fan out to subscribers by_price by_name sort_items applies key=… plug in either → no rewrite order_placed .fire("A1") email analytics add more… Both are just functions in Python. Strategy plugs one interchangeable function into sort_items; Observer keeps a list of subscriber functions and fire calls every one — no interfaces, no subclass hierarchy.
🗺️ How to read this diagram

The dashed line again separates two patterns. Read the left half for Strategy, the right half for Observer — both flow left-to-right.

  • Left (Strategy): by_price and by_name are two interchangeable functions; you plug one of them into sort_items as its key. Passing a different function changes the sort order with no change to sort_items itself — that's the swap.
  • Right (Observer): a single order_placed.fire("A1") fans out along arrows to every subscribed function — email, analytics, and any you add later. The event never knows who is listening.
  • The "add more…" box is the payoff for both: a new sort is just another function to pass; a new listener is just another function in the list. Nothing existing gets edited.

In short: Strategy = pick one function to plug in; Observer = broadcast to a whole list of functions.

Step 3 · Strategy + Observer, Pythonic
behavioral.py# STRATEGY: a strategy is just a callable
def by_price(i): return i["price"]
def by_name(i):  return i["name"]
def sort_items(items, key): return sorted(items, key=key)

items = [{"name": "pen", "price": 3}, {"name": "book", "price": 1}]
print([i["name"] for i in sort_items(items, by_price)])   # cheapest first

# OBSERVER: subscribers are just functions in a list
class Event:
    def __init__(self): self._subs = []
    def subscribe(self, fn): self._subs.append(fn)
    def fire(self, data):
        for fn in self._subs: fn(data)

order_placed = Event()
order_placed.subscribe(lambda o: print(f"email: order {o}"))
order_placed.subscribe(lambda o: print(f"analytics: {o}"))
order_placed.fire("A1")
['book', 'pen']
email: order A1
analytics: A1
▶ How this works

Two behavioral patterns — how objects interact. Strategy lets you swap the algorithm a piece of code uses (here, how to sort) without rewriting it. Observer lets many listeners react to an event without the event knowing who they are. In Python both are usually just functions, so you rarely need the heavy class hierarchies other languages use.

  1. Strategy: by_price and by_name are tiny functions that each pull one field out of an item. sort_items(items, key) takes one of them as its key and hands it to Python's built-in sorted. Passing a different function = a different sorting strategy, with no change to sort_items.
  2. The list comprehension [i["name"] for i in sort_items(items, by_price)] sorts the items cheapest-first, then keeps just their names to print.
  3. Observer: class Event holds a list self._subs of subscriber functions. subscribe(fn) adds one; fire(data) loops over them all and calls each with the data.
  4. order_placed.subscribe(lambda o: ...) registers two one-line functions (a lambda is a function with no name). order_placed.fire("A1") then notifies both — the event never needs to know what they do.

What the output means: ['book', 'pen'] is the price-sorted order (book £1 before pen £3). Then firing the event runs both subscribers: email: order A1 and analytics: A1.

Try this: Add print([i["name"] for i in sort_items(items, by_name)]) to sort alphabetically instead — same function, new strategy. Or subscribe a third listener and fire again; it prints too, no other code touched.

Don't cargo-cult patternsIn Java you'd write a Strategy interface + N classes. In Python, pass a function. Know the pattern to recognize the problem — then use the simplest Python that solves it.

5 · Machine-coding round #1 — rate limiter advanced

The clock is running and your mind goes blank — that's the machine-coding round. Unlike a LeetCode puzzle with one clever trick, this interview asks you to build a small real component in about 45 minutes: not a toy that returns the right answer, but something with a clean interface, sensible classes, and a test that proves it works. It rattles people because there's no single "aha"; it's judged on whether you write code the way a professional writes code under mild pressure. The good news is that it rewards process, and the process is learnable — which is exactly what the patterns you just met are for.

This is where design vocabulary stops being academic and starts saving your interview. When you can say "I'll inject the clock so this is testable" (Dependency Inversion, SD3) or "the eviction policy is a Strategy so we can swap LRU for LFU later," you're not reciting patterns — you're using them as the shorthand that lets you design out loud, fast, while your hands are busy typing. The first prompt below is a canonical one: a rate limiter, the thing that stops a client from hammering your API a thousand times a second. It looks intimidating and turns out to be a dozen honest lines.

The single most common mistake here isn't the algorithm — it's calling time.time() inside the limiter, which makes it impossible to test without literally sleeping in real time. The fix is small and interviewers love it: pass the current time in as an argument (allow(now)). Suddenly you can feed it fake times and assert exact behavior at t=1000, t=1001, no waiting. Watch for that "inject the clock" move — it's the tell that separates someone who's done this from someone reciting a blog post.

Interviews include a machine-coding round: a small, clean, working component in ~45 min. Approach: clarify the interface, pick the algorithm, write SOLID classes, self-test.

How to run a machine-coding round

  1. Clarify requirements + the exact interface (methods, inputs, outputs).
  2. Pick the core algorithm/data structure.
  3. Write clean, single-responsibility, testable code (inject the clock!).
  4. Add a self-test that proves it; mention thread-safety + extensions.
bucket holds up to capacity=2 tokens; refills at rate=2/s; each request spends one at t=1000 · full 2 tokens refill ↑ allow #1 → spend → True allow #2 → spend → True allow #3 → empty → False at t=1001 refilled → allow=True 1s later, tokens are back A bucket that refills over time and drains one token per request. With capacity 2 the first two requests at t=1000 pass and the third is refused; one second later the refill puts a token back, so the next request is allowed again.
🗺️ How to read this diagram

Start at the blue bucket on the left, then read the three request boxes top-to-bottom — that's the order the burst arrives.

  • The blue bucket holds the tokens (the two green circles). The small "refill ↑" arrow on top is time adding tokens back, but never above the capacity of 2.
  • The three stacked boxes are three requests at the same instant t=1000: the first two are green — each spends a token and returns True. The third is red — the bucket is empty, so it returns False (the dashed "reject" arrow).
  • The box on the right is one second later: because tokens refill at rate=2/s, the bucket has refilled, so a fresh allow returns True again — the "burst then throttle then recover" behaviour.

In short: capacity sets how big a burst you tolerate; rate sets how fast tokens (and permission) come back.

Step 4 · Token-bucket rate limiter
rate_limiter.pyimport threading

class RateLimiter:
    """Allow `rate` req/sec, bursting up to `capacity` tokens. Clock injected -> testable."""
    def __init__(self, rate, capacity):
        self.rate, self.capacity = rate, capacity
        self.tokens = capacity; self.updated = None
        self._lock = threading.Lock()
    def allow(self, now):
        with self._lock:
            if self.updated is None: self.updated = now
            self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
            self.updated = now
            if self.tokens >= 1:
                self.tokens -= 1; return True
            return False

rl = RateLimiter(rate=2, capacity=2)
print([rl.allow(1000.0) for _ in range(3)])   # [True, True, False]
print(rl.allow(1001.0))                        # True — refilled after 1s
[True, True, False]
True
▶ How this works

A rate limiter decides whether a request is allowed right now so a service isn't flooded. This is the classic token-bucket design: imagine a bucket that refills with tokens over time; each request spends one token, and when the bucket is empty requests are refused. It allows short bursts but caps the long-run rate.

  1. __init__ stores the rate (tokens added per second) and capacity (the most tokens the bucket can hold). It starts full (self.tokens = capacity) and takes a threading.Lock() so two threads can't corrupt the count at once.
  2. allow(now) receives the current time as an argument (the "clock" is injected, not read inside) — that makes the class easy to test with fake times.
  3. The refill line self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate) adds tokens for the time elapsed since last checked, but never above capacity. Then it records self.updated = now.
  4. if self.tokens >= 1: spend one token (self.tokens -= 1) and return True (allowed); otherwise return False (rejected).

What the output means: [True, True, False]: at time 1000 the bucket holds 2 tokens, so the first two requests pass and the third is refused (empty). At time 1001, one second later, rate=2 has refilled tokens, so the next allow returns True.

Try this: Change capacity=2 to capacity=5 and re-run the burst — more requests now pass instantly. The rate controls the steady speed; capacity controls how big a burst you tolerate.

6 · Machine-coding round #2 — LRU cache expert

Your cache is full and something has to go — but what? That one question is why the LRU cache is the second machine-coding staple, and why it's a step harder than the rate limiter. A cache is a small, fast box that remembers recent answers so you don't redo expensive work; the moment it fills up, you must evict something to make room. "Least Recently Used" is the sensible bet: throw out whatever you haven't touched in the longest time, because it's the least likely to be needed next. Your phone does exactly this with the apps it keeps warm in memory — open one you haven't used in days and it reloads, because the OS quietly evicted it.

The reason this earns "expert" is a genuine tension: the interviewer wants both get and put to be O(1) — instant, no matter how big the cache. A plain dictionary gives you O(1) lookup but forgets order, so finding "the least recently used item" would mean scanning everything (O(n)). A sorted list keeps order but slow inserts. The classic answer is to combine two structures: a hash map for instant lookup, and a doubly-linked list for instant reordering and eviction — each covering the other's weakness. Being able to explain why you need both is half the score.

Then comes the Python punchline — and the trap of not knowing it. You can hand-roll the hash-map-plus-linked-list, and in a C++ or Java interview you'd have to. But Python ships collections.OrderedDict, which is precisely that combination already built and battle-tested: move_to_end marks an item freshly-used in O(1), and popitem(last=False) evicts the oldest in O(1). Reaching for it isn't cheating — it's showing you know the standard library, which is exactly what a real engineer does instead of reinventing a subtle data structure at 45-minute pace. (Know the manual version too, because some interviewers specifically ask for it to test the fundamentals — but lead with the clean one.) This exact component powers the caching layer you'll build in SD5.

A second, harder round: an LRU cache with O(1) get/put. The trick is combining a hash map (for lookup) with a doubly-linked order (for eviction) — Python's OrderedDict gives both. This exact component powers SD5's caching.

OrderedDict: front = least-recently-used · back = most-recently-used (cap=2) front (LRU) back (MRU) start 1: "a" 2: "b" get(1) 2: "b" 1: "a" ← 1 moved to back put(3) 2 evicted 1: "a" 3: "c" Order is the whole trick. Reading key 1 slides it to the back (most-recent); the next put(3) overflows capacity 2 and evicts whatever sits at the front — key 2, the least-recently-used — all in O(1).
🗺️ How to read this diagram

Read the three rows top to bottom — each is the cache's contents after one operation, following the blue arrows down the left.

  • The row order matters: the front (left) is the least-recently-used key and the back (right) is the most-recently-used. That ordering is exactly what OrderedDict maintains for free.
  • Row 1 (start): after inserting keys 1 then 2, key 1 is at the front. Row 2 (get(1)): reading key 1 promotes it — the green box shows it moved to the back, so key 2 is now the front (least-recent).
  • Row 3 (put(3)): adding a third key exceeds capacity 2, so the front key — 2, in red — is evicted, leaving keys 1 and 3. That's why get(2) later returns a miss.

In short: every access shuffles a key to the back; when full, whatever is stuck at the front gets thrown out.

Step 5 · LRU cache with a self-test
lru_cache.pyfrom collections import OrderedDict

class LRUCache:
    """O(1) get/put; evicts least-recently-used when full."""
    def __init__(self, capacity):
        self.cap = capacity; self._d = OrderedDict()
    def get(self, key):
        if key not in self._d: return -1
        self._d.move_to_end(key); return self._d[key]
    def put(self, key, value):
        if key in self._d: self._d.move_to_end(key)
        self._d[key] = value
        if len(self._d) > self.cap: self._d.popitem(last=False)

c = LRUCache(2)
c.put(1, "a"); c.put(2, "b")
assert c.get(1) == "a"          # 1 is now most-recent
c.put(3, "c")                    # evicts 2 (least-recent)
assert c.get(2) == -1
assert c.get(3) == "c"
print("LRU cache passes its self-test")
LRU cache passes its self-test
▶ How this works

An LRU (Least-Recently-Used) cache keeps only the most recently used items and throws away the one untouched longest when it runs out of room. It's how real caches stay small yet useful. The hard part is doing both get and put in O(1) (constant, instant) time — Python's OrderedDict remembers insertion order and gives us that for free.

  1. __init__ stores the max size self.cap and an empty OrderedDict(). In this dict, the front is the least-recently-used item and the end is the most-recently-used.
  2. get(key): if the key isn't there, return -1 (a "miss"). If it is, self._d.move_to_end(key) marks it as freshly used, then returns its value.
  3. put(key, value): if the key already exists, move it to the end first; then store the value. If the dict is now bigger than self.cap, self._d.popitem(last=False) removes the item at the front — the least-recently-used one.
  4. The assert lines are a built-in self-test: each one crashes loudly if the cache misbehaves, so running the file with no error is the proof it works.

What the output means: After put(1,"a") and put(2,"b"), reading key 1 makes it most-recent; adding key 3 then evicts key 2. So get(2) returns -1 (gone) and get(3) returns "c". All asserts pass, so it prints LRU cache passes its self-test.

Try this: Change LRUCache(2) to LRUCache(3) and re-run. Now key 2 is not evicted, so assert c.get(2) == -1 fails — proof that capacity is what forces eviction.

Why inject the clock / self-testPassing now in (not calling time.time() inside) makes the limiter deterministically testable — Dependency Inversion (SD3) paying off. A self-test that asserts the behavior is exactly what interviewers want to see.

Exercise SD4.1 — Your own machine-coding round

Context: Running your own timed machine-coding round is how you rehearse the real thing: clarify the interface, choose a data structure, write SOLID classes, and back it with self-tests — all in about 45 minutes.

Your task: In 45 minutes, build one of a parking-lot allocator, an in-memory KV store with TTL, or a connection pool, with a clear interface, an intentional data-structure choice, SOLID classes, and assert-based self-tests.

Requirements:

  • State the public interface before writing any implementation
  • Justify the chosen data structure for the core operations
  • Design the classes along SOLID lines (single responsibility, injectable dependencies)
  • Include assert-based self-tests that exercise the main paths
  • Name one design pattern you used and one concrete extension you'd add next

💡 Hint: Model time or capacity explicitly (pass now, cap the pool) so the tests are deterministic; the pattern you name should fall out of the design, not be bolted on.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · FactoryBeginner

Context: A factory centralizes the knowledge of how to build objects so callers don't hard-code constructors all over the codebase. When creation logic changes, you edit one place.

Your task: Write a shape_factory(kind) that returns the correct shape instance for a given name, so callers ask for a "circle" or "square" without naming the class.

Requirements:

  • Provide at least two shape classes exposing a common method (e.g. area())
  • Map the kind string to the right class in a single lookup
  • Return a constructed instance, not the class itself
  • Show that adding a new shape means editing one map, not every call site

💡 Hint: A dict from name to class, indexed and immediately called, is the whole factory; the payoff is that call sites stay ignorant of concrete constructors.

Show solution

The factory is the single place that knows how to build things:

class Circle:  area = lambda self: 3.14159
class Square:  area = lambda self: 4.0

def shape_factory(kind):
    return {"circle": Circle, "square": Square}[kind]()

print(round(shape_factory("circle").area(), 2))   # 3.14
print(shape_factory("square").area())             # 4.0

Adding a shape means editing one map, not every call site — the classic reason to reach for a factory.

Exercise 2 · Singleton (the Pythonic way)Intermediate

Context: Singletons enforce exactly one instance — a shared config, say. Python makes the textbook version look heavy, because a module-level object already is a singleton, so knowing when to skip the ceremony matters.

Your task: Show the naive class-based singleton (overriding __new__) alongside the Pythonic shortcut of a shared module-level object, and note when each is appropriate.

Requirements:

  • Implement a class that returns the same instance on every construction via __new__
  • Prove two constructions are the same object (is comparison)
  • Contrast it with a plain module-level object imported and shared everywhere
  • Mutate the shared object and show all references see the change
  • Conclude that the module-level object is the first choice; the class ceremony is rarely worth it

💡 Hint: Cache the single instance on a class attribute inside __new__; then observe that a module global gives you the same guarantee for free.

Show solution

Python often makes a pattern trivial — a module is a singleton:

# Class-based (works, but verbose):
class Config:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

print(Config() is Config())   # True

# Pythonic shortcut: just use a module-level object.
CONFIG = {"env": "prod"}       # import it anywhere; one shared object
CONFIG["env"] = "staging"
print(CONFIG)                  # {'env': 'staging'}

The lesson's caution applies: reach for the module-level object first; the class ceremony is rarely worth it in Python.

Exercise 3 · Decorator (wrap behavior)Advanced

Context: Decorators add behavior around a function without editing it — the structural pattern Python built into the language. Wrapping is how you bolt on cross-cutting concerns like counting, timing, or logging.

Your task: Write a counted decorator that tracks how many times the wrapped function was called, wrapping any function without altering its body, and keeping the call-count deterministic (no real clock).

Requirements:

  • Wrap the target function and forward arbitrary *args, **kwargs
  • Track a call counter that survives across calls (e.g. on the wrapper)
  • Preserve the wrapped function's name and docstring with functools.wraps
  • Show the count incrementing and the original return value still coming through
  • Keep it deterministic — count calls rather than measuring wall-clock time

💡 Hint: functools.wraps is the production-safety detail that keeps the wrapped function's identity intact; store the counter as an attribute of the wrapper.

Show solution

Decorators wrap — the structural pattern Python builds into the language:

import functools

def counted(fn):
    @functools.wraps(fn)
    def wrapper(*a, **k):
        wrapper.calls += 1
        return fn(*a, **k)
    wrapper.calls = 0
    return wrapper

@counted
def add(a, b): return a + b

add(1, 2); add(3, 4)
print(add.calls)          # 2
print(add(5, 6))          # 11

functools.wraps preserves the wrapped function's name/doc — the detail that makes decorators production-safe.

Exercise 4 · AdapterExpert

Context: An adapter makes an incompatible interface fit the one your code already speaks. It is how you integrate a third-party class you can't change without leaking its quirks everywhere.

Your task: Adapt a third-party CelsiusSensor (which reads Celsius) to the TemperatureReader interface your app expects (which reads Fahrenheit).

Requirements:

  • Treat the third-party sensor as unchangeable
  • Define the interface your application actually depends on (Fahrenheit)
  • Implement an adapter that wraps the sensor and translates its output
  • Have your code depend only on the target interface, never the sensor directly
  • Show the adapter absorbing the mismatch (the correct converted reading)

💡 Hint: The adapter holds the foreign object and does the unit conversion inside the method your interface promises; nothing downstream knows the source was in Celsius.

Show solution

The adapter translates one interface into another your code already speaks:

class CelsiusSensor:                  # third-party, can't change
    def read_c(self): return 25.0

class TemperatureReader:              # what our app expects
    def read_f(self): raise NotImplementedError

class CelsiusAdapter(TemperatureReader):
    def __init__(self, sensor): self.sensor = sensor
    def read_f(self): return self.sensor.read_c() * 9 / 5 + 32

reader = CelsiusAdapter(CelsiusSensor())
print(reader.read_f())   # 77.0

Our code depends only on read_f(); the adapter absorbs the mismatch so the third-party class needs no changes.

Exercise 5 · Machine-coding: a rate limiterProfessional

Context: Machine-coding rounds test whether you can build a small, correct, testable system under time pressure. A fixed-window rate limiter is a canonical prompt, and injecting the clock is what makes it deterministic and offline-testable.

Your task: Implement a fixed-window rate limiter that allows at most N calls per time window, modelling time explicitly by passing now into each call so results are reproducible.

Requirements:

  • Track the current window's start time and the count of calls within it
  • Reset the count when a call arrives in a new window
  • Allow a call only while the count is below the limit, otherwise reject it
  • Accept now as a parameter rather than reading the system clock
  • Demonstrate the limit blocking the surplus call and a new window re-allowing calls

💡 Hint: Passing now in (instead of calling time.time()) is what makes every window boundary reproducible in a test; compare now - window_start against the window size.

Show solution

Injecting the clock keeps it testable — a machine-coding staple:

class RateLimiter:
    def __init__(self, limit, window):
        self.limit, self.window = limit, window
        self.count, self.window_start = 0, 0
    def allow(self, now):
        if now - self.window_start >= self.window:   # new window
            self.window_start, self.count = now, 0
        if self.count < self.limit:
            self.count += 1
            return True
        return False

rl = RateLimiter(limit=2, window=10)
print([rl.allow(t) for t in (0, 1, 2)])   # [True, True, False]  (3rd in window blocked)
print(rl.allow(11))                        # True  (new window at t=11)

Passing now in (rather than calling time.time()) makes every window boundary reproducible in a test.

Exercise 6 · Machine-coding: an LRU cacheIndustry scenario

Context: The LRU cache is the second machine-coding staple: constant-time get and put with least-recently-used eviction. An ordered dict gives you the ordering for free instead of hand-rolling a doubly linked list.

Your task: Implement an LRU cache with O(1) get and put using collections.OrderedDict, evicting the least-recently-used key when the cache is at capacity.

Requirements:

  • Back the cache with an OrderedDict and a capacity
  • On get, mark the key most-recently-used with move_to_end (or return a miss sentinel)
  • On put, insert/update and mark the key most-recently-used
  • When over capacity, evict the least-recently-used entry with popitem(last=False)
  • Demonstrate an access promoting a key and a subsequent insert evicting the stale one

💡 Hint: move_to_end on every access plus popitem(last=False) on overflow implements true LRU with clean O(1) operations — no linked list needed.

Show solution

Ordered-dict move-to-end gives O(1) LRU without a hand-rolled linked list:

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.d = OrderedDict()
    def get(self, key):
        if key not in self.d: return -1
        self.d.move_to_end(key)          # mark as recently used
        return self.d[key]
    def put(self, key, value):
        if key in self.d: self.d.move_to_end(key)
        self.d[key] = value
        if len(self.d) > self.cap:
            self.d.popitem(last=False)   # evict least-recently-used

c = LRUCache(2)
c.put(1, "a"); c.put(2, "b")
print(c.get(1))       # a  (now 1 is most-recent)
c.put(3, "c")         # evicts key 2 (least-recent)
print(c.get(2))       # -1 (evicted)
print(c.get(3))       # c

move_to_end on access and popitem(last=False) on overflow implement true LRU with clean O(1) operations.

✓ Checkpoint — you can move on when you can…

  • Explain the three families and when NOT to use a pattern.
  • Implement creational, structural, and behavioral patterns in Python.
  • Give the Pythonic shortcut for Strategy/Observer.
  • Complete a machine-coding round with clean, self-tested code.

Knowledge check check yourself

✓ Knowledge check

The lesson groups design patterns into three families. Name each family, what it solves, and give one example pattern from each.

Show answer
Creational patterns solve how objects are made (e.g. Factory, Singleton, Builder). Structural patterns solve how objects compose (e.g. Adapter, Decorator, Facade). Behavioral patterns solve how objects interact (e.g. Strategy, Observer, State).
✓ Knowledge check

Why does the lesson inject the clock (passing now into allow) in the token-bucket rate limiter instead of calling time.time() inside it?

Show answer
Passing the current time in as an argument makes the limiter deterministically testable — you can feed fixed/fake times to assert exact behavior. It's an application of Dependency Inversion (SD3), and pairing it with an assert-based self-test is exactly what interviewers want to see in a machine-coding round.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in