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.
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.
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.
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.
| Family | Solves | Examples |
|---|---|---|
| Creational | how objects are made | Factory, Singleton, Builder |
| Structural | how objects compose | Adapter, Decorator, Facade |
| Behavioral | how objects interact | Strategy, 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.
Config() call returns the one shared instance — two different creational jobs.
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"tomake_storage(blue arrow). The factory's lookup table then dispatches — dashed grey arrows — to either theS3orLocalclass. 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.
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
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).
class Storage(ABC)with@abstractmethod def saveis a contract: any storage must have asavemethod.S3andLocalare two real implementations that fill in that method differently.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 writesS3()directly, so swapping storage means changing one string.class Configis the Singleton. The special__new__method runs before an object is created. It checksif cls._instance is None— the first time it builds and remembers one instance; every laterConfig()returns that same stored object instead of a fresh one.Config() is Config()usesis(same object in memory, not just equal values). It printsTrue, 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.
Caching box sits around the untouched DataSource, intercepts get, and only forwards to the real fetch on a cache miss — Open/Closed made concrete.
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
Cachingwrapper. Inside, the white cache box checks "have I seen this key?" — the greenDataSource(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
getmethod as the thing it wraps, so from the caller's side nothing changed — yet behaviour was added without ever editingDataSource.
In short: the wrapper stands in front, answers from memory when it can, and only bothers the real object on a miss.
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
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).
DataSource.get(key)is the real (slow) worker: it printsfetch keyevery time it runs, so you can see when an actual fetch happens.class Cachingtakes another data source in its__init__(self.inner) and keeps an empty dictionaryself._cas its cache. It has the samegetmethod, so callers can't tell it apart from the real thing.- Inside
get:if key not in self._c— only when the answer is missing does it callself.inner.get(key)and store the result. Otherwise it returns the saved value. ds = Caching(DataSource())wraps the real source. The firstds.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.
sort_items; Observer keeps a list of subscriber functions and fire calls every one — no interfaces, no subclass hierarchy.
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_priceandby_nameare two interchangeable functions; you plug one of them intosort_itemsas itskey. Passing a different function changes the sort order with no change tosort_itemsitself — 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.
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
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.
- Strategy:
by_priceandby_nameare tiny functions that each pull one field out of an item.sort_items(items, key)takes one of them as itskeyand hands it to Python's built-insorted. Passing a different function = a different sorting strategy, with no change tosort_items. - 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. - Observer:
class Eventholds a listself._subsof subscriber functions.subscribe(fn)adds one;fire(data)loops over them all and calls each with the data. order_placed.subscribe(lambda o: ...)registers two one-line functions (alambdais 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.
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
- Clarify requirements + the exact interface (methods, inputs, outputs).
- Pick the core algorithm/data structure.
- Write clean, single-responsibility, testable code (inject the clock!).
- Add a self-test that proves it; mention thread-safety + extensions.
t=1000 pass and the third is refused; one second later the refill puts a token back, so the next request is allowed again.
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 returnsTrue. The third is red — the bucket is empty, so it returnsFalse(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 freshallowreturnsTrueagain — 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.
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
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.
__init__stores therate(tokens added per second) andcapacity(the most tokens the bucket can hold). It starts full (self.tokens = capacity) and takes athreading.Lock()so two threads can't corrupt the count at once.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.- 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 abovecapacity. Then it recordsself.updated = now. if self.tokens >= 1: spend one token (self.tokens -= 1) andreturn True(allowed); otherwisereturn 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.
put(3) overflows capacity 2 and evicts whatever sits at the front — key 2, the least-recently-used — all in O(1).
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
OrderedDictmaintains 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 whyget(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.
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
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.
__init__stores the max sizeself.capand an emptyOrderedDict(). In this dict, the front is the least-recently-used item and the end is the most-recently-used.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.put(key, value): if the key already exists, move it to the end first; then store the value. If the dict is now bigger thanself.cap,self._d.popitem(last=False)removes the item at the front — the least-recently-used one.- The
assertlines 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.
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.
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.
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 (
iscomparison) - 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.
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.
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.
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
nowas 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.
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
OrderedDictand a capacity - On
get, mark the key most-recently-used withmove_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
The lesson groups design patterns into three families. Name each family, what it solves, and give one example pattern from each.
Show answer
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
assert-based self-test is exactly what interviewers want to see in a machine-coding round.