04 Β· Software Engineering
Clean Code & Design Patterns β Judgment, Not Dogma
Pattern questions test whether you can reason about coupling and change, not whether you memorized the GoF book. The winning register: name the principle, show a small real example, and state when it's overkill. Python throughout β your language.
01First principles: what is "clean" optimizing for?
Code is read 10x more than it's written, and changed by people without the original context. So clean code optimizes for exactly two things:
1 Β· Time-to-understanding
How fast the next reader (including future-you) builds a correct mental model.
2 Β· Cost-of-change
How cheaply the code absorbs the requirement shifts that will come.
02Naming β the cheapest documentation
- Name the intent, not the mechanics.
days_until_expiry>date_diff2.is_retryable(error)>check(error). - Precision beats brevity:
unverified_webhook_payloadtells you something safety-critical thatdatahides. - Lie-free names: a function called
get_userthat also creates one on miss is a landmine β call itget_or_create_user. Misleading names cause more bugs than bad names. - Booleans read as assertions:
is_stale,has_approval,should_retry. Functions are verbs; classes/variables are nouns. - Scope-proportional length:
iis fine in a 3-line loop; module-level names earn full words. - Encode units and types where ambiguity kills:
timeout_seconds,size_bytes,deadline_utc.
03Functions
- One job, one level of abstraction. A function should read like a paragraph at a single altitude β either orchestration (
validate(); enrich(); persist(); notify()) or detail, not both interleaved. - Small-ish, not tiny-for-sport. "Extract until each function is 3 lines" fragments logic into a call-graph scavenger hunt. Extract when a block has a nameable purpose or is reused β not by line count.
- Few parameters; 4+ suggests a missing object (
RetryPolicy(max_attempts, base_delay, jitter)instead of three loose args). Keyword-only args (*,) for booleans βsend(alert, urgent=True)beatssend(alert, True). - No hidden effects: a function named like a query must not mutate. Commandβquery separation as a habit, not a law.
Guard clauses over nesting:
def approve(pr, reviewer):
if pr.state != "open":
raise InvalidState(pr.state)
if not reviewer.can_approve:
raise Forbidden(reviewer.id)
# happy path proceeds flat, at indent level 1
except: pass; catch where you can handle (retry, fallback, translate to a 4xx/5xx), let the rest propagate to a boundary handler that logs once with context.04SOLID β practical Python, honest caveats
SOLID was coined for 2000s Java; in Python, duck typing and first-class functions satisfy several of these letters almost for free. Present it as coupling management:
S β Single Responsibility
"One reason to change" β one stakeholder axis per module. A ReportGenerator that computes numbers and formats HTML and emails changes for three unrelated reasons; split by axis of change.
caveat Taken to extremes it yields 40 two-line classes and unreadable indirection. The unit of responsibility can be a function or module, not always a class.
O β Open/Closed
Extend behavior without editing tested code β usually via a registry/strategy rather than editing an if-chain per new case (code below).
caveat Don't build extension points speculatively (YAGNI). Add the seam on the second or third variant, when the axis of change is proven real. Modifying code is fine β that's what tests are for; OCP mainly earns its keep at plugin-style boundaries.
L β Liskov Substitution
Subtypes must honor the base type's behavioral contract, not just its signatures. If callers of Storage.save() rely on it being durable when it returns, an AsyncBufferedStorage subclass that returns before flushing breaks LSP even though the code type-checks.
caveat Smells: overrides that raise NotImplementedError, strengthen preconditions, or weaken postconditions. In Python: if you subclass or implement a Protocol, honor the semantics, or you break every isinstance-free caller.
I β Interface Segregation
Small, client-shaped interfaces. Depend on a tiny Notifier, not a god-MessagingService with 30 methods your caller doesn't use.
caveat Duck typing gives you ISP culturally already; Protocol just gives it to mypy too β don't add interface ceremony where a plain function suffices.
D β Dependency Inversion
High-level policy shouldn't import low-level detail; both depend on an abstraction. Your incident-analysis logic depends on a LogSource protocol, not the concrete Loki/CloudWatch client. Details become swappable and β the real daily payoff β testable.
caveat Inverting every dependency yields interface-per-class Java cosplay. Invert at volatile or external boundaries (I/O, vendors, things you'll fake in tests); keep stable internal calls direct.
O in code β registry dispatch
HANDLERS: dict[str, Callable[[Event], None]] = {}
def handles(event_type):
def deco(fn):
HANDLERS[event_type] = fn
return fn
return deco
@handles("issues.opened")
def on_issue_opened(e): ...
@handles("pull_request.closed")
def on_pr_closed(e): ...
def dispatch(e): HANDLERS[e.type](e)
# new events = new functions,
# dispatch never edited
I in code β Protocol, no inheritance
from typing import Protocol
class Notifier(Protocol):
def notify(self, message: str) -> None: ...
class TelegramNotifier: ...
# no inheritance needed β
# structural typing
class SlackNotifier: ...
# depend on Notifier, not on a
# god-MessagingService with 30 methods
05Composition over inheritance
Inheritance is the tightest coupling in OO: subclasses inherit implementation, so base-class changes ripple invisibly (the fragile-base-class problem), hierarchies are static (one axis of variation, chosen at class-definition time), and deep trees become archaeology.
Composition = build behavior by containing collaborators:
Inheritance trap
# combinatorial subclassing
class RetryingCachingGitHubClient(
RetryingClient,
CachingClient,
GitHubClient): ...
# n behaviors β 2^n classes,
# MRO puzzles
Composition β behaviors are values
@dataclass
class ApiClient:
transport: Transport
retry: RetryPolicy
cache: Cache | None = None
client = ApiClient(
HttpTransport(base_url),
RetryPolicy(max_attempts=3),
RedisCache())
Each piece is independently testable and swappable at runtime. Rules of thumb: inheritance is fine for genuine is-a with a stable, shallow contract (framework hooks, exception hierarchies, ABCs defining interfaces); prefer composition whenever you're inheriting to reuse code rather than to be a subtype.
self state.06Dependency injection β the pattern you use daily
DI is just: pass dependencies in; don't construct or import them inside. No framework required in Python.
Hard-wired: untestable, unswappable
class IncidentAnalyzer:
def __init__(self):
self.llm = OpenAIClient(
os.environ["KEY"]) # welded in
self.logs = LokiClient()
Injected: seams everywhere
class IncidentAnalyzer:
def __init__(
self,
llm: LLMProvider,
logs: LogSource,
clock: Callable[[], datetime]
= datetime.utcnow):
self.llm, self.logs, self.clock = \
llm, logs, clock
Now tests pass fakes; prod passes real clients; a provider swap touches one composition point. The composition root idea: wiring happens in one place (your main/app factory), everything below receives its collaborators.
FastAPI makes DI a first-class citizen β this is your daily reality, name it:
def get_llm() -> LLMProvider: return app.state.llm
@router.post("/analyze")
async def analyze(req: AnalyzeRequest, llm: LLMProvider = Depends(get_llm)):
...
# tests: app.dependency_overrides[get_llm] = lambda: FakeLLM()
07Patterns actually used in backend work
Frame for interviews: patterns are names for recurring shapes, valuable mostly as vocabulary. These are the ones that genuinely appear in a Python/FastAPI/LLM backend, with when-not-to.
Factory
Centralize "which concrete thing do I build?" β config-driven construction.
def make_provider(cfg: Config) -> LLMProvider:
match cfg.provider:
case "anthropic": return AnthropicProvider(cfg.anthropic_key)
case "openai": return OpenAIProvider(cfg.openai_key)
case "local": return VLLMProvider(cfg.base_url)
raise ValueError(cfg.provider)
In Python a factory is usually just a function β no AbstractFactoryFactory. Used constantly for provider/gateway selection. skip when there's one implementation and no configurability (YAGNI).
Strategy
Interchangeable algorithms behind one interface β in Python, often literally passing a function.
class ChunkStrategy(Protocol):
def chunk(self, doc: str) -> list[str]: ...
def ingest(doc: str, strategy: ChunkStrategy): ...
# or simply: def ingest(doc, chunk: Callable[[str], list[str]])
Real uses: retry policies, chunking strategies, routing rules ("cheap model vs frontier model"), scoring functions. Strategy + registry dict = the Open/Closed dispatch shown earlier.
Observer (pub/sub)
Decouple event producers from reactions. In-process: a list of callbacks/signals (Django signals, FastAPI background tasks). At system scale this is Kafka/webhooks β the pattern you live in: GitHub emits, Sentinel subscribes; Sentinel emits to Telegram.
Repository
Put persistence behind a domain-shaped interface:
class IncidentRepo(Protocol):
async def get(self, id: int) -> Incident | None: ...
async def add(self, inc: Incident) -> Incident: ...
async def open_incidents(self) -> list[Incident]: ...
class PgIncidentRepo: ... # SQLAlchemy inside
class InMemoryIncidentRepo: ... # tests
Wins: domain logic never imports SQLAlchemy; tests use the in-memory fake; queries have names (open_incidents) instead of scattered filter spaghetti. honest caveat With an ORM already abstracting SQL, a thin repo can be pure ceremony for simple CRUD apps β it earns its keep when domain logic is rich or you genuinely fake persistence in tests.
Adapter
Wrap an external interface so your code sees your interface. This is the pattern of every integration layer β and precisely what an LLM gateway is: Anthropic and OpenAI have different request/response shapes; your adapters normalize both to one internal LLMProvider protocol so the rest of the system is vendor-agnostic. Also: MCP servers are adapters as an architecture β each one adapts a tool/data source to a common protocol. You have unusually good real examples here; use them.
Singleton β the pitfalls section
Intent: one instance, globally reachable. In practice it's global mutable state with a design-pattern alibi:
- Hidden dependency: every user reaches for it invisibly β nothing in signatures reveals the coupling.
- Test poison: state leaks across tests; parallel tests race; you end up monkeypatching module internals.
- Concurrency traps: lazy init needs locking; forked workers (gunicorn!) each get their own copy β "the" singleton isn't single across processes anyway, which also breaks people's caching assumptions.
app.state, inject via Depends. Single-instance benefits, zero global-state costs. Legitimate near-singletons: stateless module-level constants, loggers, connection pools managed by lifecycle β note they're either immutable or lifecycle-owned.Patterns to know exist, one line each:
- Decorator β wrap to add behavior: Python's
@decoratorand every middleware stack. - Facade β one simple front over a messy subsystem: your gateway again.
- Builder β rare in Python: kwargs + dataclasses cover it.
- Circuit Breaker β distributed-systems pattern: stop calling a failing dependency; relevant to provider fallback.
- Unit of Work β transaction boundary around repos.
08Refactoring smells β recognizing rot early
Smells are symptoms warranting a look, not violations. High-signal ones with their usual fix:
| Smell | What it looks like | Typical fix |
|---|---|---|
| Duplicated knowledge | Same rule encoded in 3 places | Extract single source of truth. Caution: DRY is about knowledge, not text β two accidentally-similar snippets that evolve independently should stay separate. "Wrong abstraction is costlier than duplication." |
| Long function / god class | Scroll-scroll-scroll; "Manager", "Util", "Helper" names | Extract by responsibility; the class-name vagueness is the diagnosis |
| Feature envy | Method spends its life on another object's data (order.customer.address.city...) | Move the behavior to where the data lives |
| Primitive obsession | str user_ids, float money, dict payloads everywhere | Small types: dataclasses, NewType, Pydantic models β the type system starts catching bugs |
| Shotgun surgery | One conceptual change β edits in 9 files | Consolidate the scattered concern (often the inverse of over-split SRP) |
| Long parameter lists | 6 args, half booleans | Parameter object; kwargs-only flags |
| Boolean flag params | process(data, True, False) | Two functions, or enums |
| Speculative generality | Interfaces with one impl, hooks nobody calls | Delete it. YAGNI. Interviewers love hearing "I'd remove code" |
| Comments as deodorant | Paragraph explaining a confusing block | Refactor until the comment is unnecessary; keep comments for why (invariants, workarounds, links to incidents), not what |
| Deep nesting | Arrow-shaped code | Guard clauses, extraction, early returns |
09Rapid-fire interview Q&A
Q: Is a comment a code smell?
Q: When would you use inheritance?
Q: DRY vs duplication?
Q: Which design pattern do you actually use most?
Q: How do you keep a codebase clean under deadline pressure?
10Self-test
What two things does clean code optimize for?
Time-to-understanding for the next reader, and cost-of-change when requirements shift. Every rule is downstream of those; when a rule fights them, the rule loses.
Give the one-sentence SOLID summary and one caveat per letter.
"Five angles on the same idea β isolate the things that change from the things that don't; apply where change is real, not ceremonially." Caveats: S β over-splitting yields 40 two-line classes; O β no speculative seams, add on the 2nd/3rd variant; L β honor behavioral contracts, not just signatures; I β duck typing already gives it culturally; D β invert only volatile/external boundaries, not every call.
Explain LSP with the Storage.save() example.
If callers rely on save() being durable when it returns, an AsyncBufferedStorage subclass that returns before flushing breaks the behavioral contract even though it type-checks β subtypes must honor semantics, not just signatures.
Why is a classic singleton dangerous, and what do you use instead?
It's global mutable state: hidden dependency (nothing in signatures reveals it), test poison (state leaks, races, monkeypatching), and concurrency traps β under gunicorn each forked worker gets its own copy anyway, breaking caching assumptions. Use "one instance, injected": build at startup in the lifespan handler, store on app.state, inject via Depends.
Name six smells and their fixes from memory.
Boolean flags β two functions/enums; deep nesting β guard clauses; primitive obsession β small types (dataclasses, NewType, Pydantic); shotgun surgery β consolidate the scattered concern; speculative generality β delete it (YAGNI); feature envy β move behavior to where the data lives; comments-as-deodorant β refactor until the comment is unnecessary, keep "why" comments.
State the refactoring discipline that separates seniors.
Small, behavior-preserving steps under green tests; never mix refactoring with feature change in one commit/PR (keeps review fast and bisect useful); Boy-Scout rule over big-bang rewrites, which routinely fail.