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.

Every rule below is downstream of those two. When a rule fights them β€” e.g. an abstraction that makes code harder to follow β€” the rule loses. That's the entire "not dogma" stance in one sentence.

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_payload tells you something safety-critical that data hides.
  • Lie-free names: a function called get_user that also creates one on miss is a landmine β€” call it get_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: i is 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.
The refactoring tell: if you can't name it well, you don't understand what it does yet β€” naming difficulty is a design smell, usually meaning the function does two things.

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) beats send(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
Errors: raise specific exceptions, never 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
One-liner you can say: "SOLID, to me, is five angles on the same idea β€” isolate the things that change from the things that don't. I apply it where change is real, not ceremonially."

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.

Python-specific: mixins are inheritance-flavored composition β€” fine in small doses (Django), a nightmare when five mixins share 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()
Honest caveats: DI containers (the heavyweight framework kind) are usually unnecessary in Python β€” constructor args + a composition root cover 95%; and over-injection (12 constructor params) signals the class does too much, not that you need a fancier container.

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.

Tradeoff to name: observers make control flow implicit β€” great decoupling, harder debugging ("who reacts to this event?"), and at-least-once delivery pushes idempotency onto every consumer.

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.
What you actually want is usually "one instance, injected": build the client once at startup (lifespan handler / composition root), store on 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 @decorator and 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.

Pattern matcher β€” which pattern fits?

Six one-line scenarios. Click the pattern that fits; instant verdict.

1. Config decides at startup whether you build an Anthropic, OpenAI, or local vLLM client.

2. Two vendors return differently-shaped responses; you normalize both behind one internal protocol.

3. Retry policy, chunking rule, or cheap-vs-frontier model routing must be swappable per call.

4. GitHub emits an event; three unrelated services react without the emitter knowing any of them.

5. Domain logic should never import SQLAlchemy, and tests want an in-memory stand-in for the database.

6. Each MCP server wraps a different tool or data source but presents the same protocol to the agent.

08Refactoring smells β€” recognizing rot early

Smells are symptoms warranting a look, not violations. High-signal ones with their usual fix:

SmellWhat it looks likeTypical fix
Duplicated knowledgeSame rule encoded in 3 placesExtract 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 classScroll-scroll-scroll; "Manager", "Util", "Helper" namesExtract by responsibility; the class-name vagueness is the diagnosis
Feature envyMethod spends its life on another object's data (order.customer.address.city...)Move the behavior to where the data lives
Primitive obsessionstr user_ids, float money, dict payloads everywhereSmall types: dataclasses, NewType, Pydantic models β€” the type system starts catching bugs
Shotgun surgeryOne conceptual change β†’ edits in 9 filesConsolidate the scattered concern (often the inverse of over-split SRP)
Long parameter lists6 args, half booleansParameter object; kwargs-only flags
Boolean flag paramsprocess(data, True, False)Two functions, or enums
Speculative generalityInterfaces with one impl, hooks nobody callsDelete it. YAGNI. Interviewers love hearing "I'd remove code"
Comments as deodorantParagraph explaining a confusing blockRefactor until the comment is unnecessary; keep comments for why (invariants, workarounds, links to incidents), not what
Deep nestingArrow-shaped codeGuard clauses, extraction, early returns
Refactoring discipline (the part that separates seniors): refactor in small, behavior-preserving steps under green tests; never mix refactoring and feature change in one commit/PR (reviewers can verify "pure mechanical" quickly, and bisect stays useful); the Boy-Scout rule (leave it slightly better) beats big-bang rewrite campaigns, which routinely fail.

Refactor reveals β€” 5 rounds

Each round shows a smelly snippet. Refactor it in your head first, then reveal the improved version and the principle at play.

Smelly

def send_report(report, is_summary):
    if is_summary:
        body = summarize(report)
    else:
        body = render_full(report)
    mail(body)

send_report(r, True)  # True what?

Refactored

def send_summary(report):
    mail(summarize(report))

def send_full_report(report):
    mail(render_full(report))

# or keyword-only:
def send_report(report, *, summary: bool):
    ...
send_report(r, summary=True)
Principle Boolean flag params β†’ two functions, or a keyword-only flag. The call site now says what it does.

09Rapid-fire interview Q&A

Q: Is a comment a code smell?

"What" comments usually are β€” the code should say that. "Why" comments are gold: intent, constraints, links to the incident that motivated a weird guard. My rule: comment the things the code cannot express.

Q: When would you use inheritance?

Genuine is-a with a stable contract I control: exception hierarchies, ABCs/Protocols defining an interface, framework extension hooks. For code reuse, I compose β€” inheriting for reuse is how fragile base classes happen.

Q: DRY vs duplication?

DRY is about single sources of knowledge. I deduplicate business rules aggressively, but I let textually-similar code live in two places until I'm sure they change for the same reason β€” a premature shared abstraction couples things that wanted to diverge.

Q: Which design pattern do you actually use most?

Adapter and strategy, without ceremony β€” my LLM gateway is adapters normalizing providers behind one protocol, with strategy-style routing between models, injected at the composition root. Repository where domain logic is rich enough to deserve a persistence seam. I use singletons almost never β€” "one instance, injected via app lifespan" gives the benefit without global state.

Q: How do you keep a codebase clean under deadline pressure?

Small PRs with the boy-scout rule; a hard line on the load-bearing stuff (naming, tests for changed behavior, no lying interfaces); an explicit tech-debt list for the corners I consciously cut β€” debt is fine when it's chosen and tracked, toxic when it's silent.

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.