Clean Code, Refactoring, and Maintainability Questions
Writing code that other people can read, change, and keep alive over time: naming, function and module decomposition, avoiding duplication, readability, disciplined use of language idioms and design patterns, and recognizing code smells, extending into working effectively in large, aging, or unfamiliar codebases through safe incremental change, refactoring under test coverage, and managing technical debt. Covers both authoring professional-grade code beyond mere correctness and improving code you cannot rewrite without breaking it. Spans the coding-round quality signal and the seniority signal of leaving a codebase healthier than you found it.
You are given a function that has grown to do five unrelated things (for example: parsing input, validating it, running business rules, persisting results, and sending notifications) in a single 400+ line block. Walk through how you would decompose it into small, well-named, independently testable pieces, and what you would check before and after to confirm you did not change behavior.
Sample Answer
Direct answer. Decompose by responsibility, not by line count: pull out one function per distinct concern (parse, validate, compute, persist, notify), give each a name that states its single job, and compose them in a thin orchestrator that reads like the original steps.
Before (one function, five jobs)
def handle_order(raw):
order_id = raw["id"]; email = raw["email"]; items = raw["items"] # parse
if not items: raise ValueError("order has no items") # validate
if "@" not in email: raise ValueError("bad email")
total = sum(i["price"] * i["qty"] for i in items) # compute
if total > 100: total *= 0.9
DB[order_id] = {"email": email, "total": total} # persist
OUTBOX.append(f"receipt to {email}: ${total:.2f}") # notify
return total
After
def parse_order(raw: dict) -> dict:
return {"id": raw["id"], "email": raw["email"], "items": raw["items"]}
def validate_order(order: dict) -> None:
if not order["items"]: raise ValueError("order has no items")
if "@" not in order["email"]: raise ValueError("bad email")
def compute_total(items, *, bulk_discount_threshold=100, bulk_discount_rate=0.9) -> float:
total = sum(i["price"] * i["qty"] for i in items)
return total * bulk_discount_rate if total > bulk_discount_threshold else total
def save_order(db, order_id, email, total) -> None:
db[order_id] = {"email": email, "total": total}
def send_receipt(outbox, email, total) -> None:
outbox.append(f"receipt to {email}: ${total:.2f}")
def handle_order_v2(raw, db, outbox) -> float:
order = parse_order(raw)
validate_order(order)
total = compute_total(order["items"])
save_order(db, order["id"], order["email"], total)
send_receipt(outbox, order["email"], total)
return total
Verified against two cases: a two-item order totaling 120 (discounted to 108.0) and a single-item order totaling 20 (no discount) -- both versions return identical totals.
How to confirm you didn't change behavior
- Run the OLD function and the NEW orchestrator against the same set of inputs (including edge cases: empty items, exactly-at-threshold totals, malformed email) and diff the outputs.
- If there's an existing test suite, run it unchanged against the new code first -- a passing suite that never exercised the discount boundary is itself a gap worth flagging, not just a green check mark.
- Where no tests exist yet, write characterization tests against the OLD function first, then refactor, then confirm the same tests still pass -- this way the safety net exists before you touch anything.
Trade-offs and pitfalls
compute_total's discount threshold is now a named, testable, overridable parameter instead of a buried magic number -- a deliberate improvement, but note it as a design decision so a reviewer knows it wasn't accidental scope creep.- Over-decomposing (a function per line) makes you trace five files to understand one flow; stop splitting when each piece maps to one clear reason to change.
- The orchestrator itself now has a job: sequencing. Keep it free of business logic so it doesn't quietly become a sixth responsibility.
What do you look for in a code review beyond 'does it work'? Walk through your top checks and give a concrete example of feedback you'd leave for one of them.
Sample Answer
Direct answer. Look past 'does it pass tests' to readability, failure handling, API/contract impact, and test coverage of the CHANGE itself -- and phrase feedback as a specific, actionable suggestion rather than a vague complaint.
The core checks, with an example each
- Readability: would a teammate unfamiliar with this PR understand the intent from the code and names alone? Example feedback: 'This function does three unrelated things (parses, validates, persists) -- can we split it so each has one job? Happy to pair on this if useful.'
- Test coverage of the CHANGE, not just the file: does the diff itself have tests exercising the new behavior, especially edge cases the author might not have thought of? Example: 'This handles the happy path well -- what happens if
itemsis empty? Could we add a test for that?' - API/contract impact: does this change a public function's signature, a response shape, or a database schema in a way that breaks existing callers? Example: 'This response now omits the
statusfield that the mobile client depends on -- can we confirm nothing downstream reads it, or keep it for backward compatibility?' - Failure handling: what happens when a dependency is slow, unavailable, or returns something unexpected? Example: 'If this external call times out, does the caller get a clear error, or does this hang indefinitely?'
- Scope: is this PR doing ONE reviewable thing, or is a refactor bundled with a behavior change, making it hard to tell which lines matter for the actual bug fix? Example: 'Could we split the formatting-only changes into a separate PR so the logic change is easier to review on its own?'
Why 'beyond correctness' matters
A PR that passes its own tests can still be a liability: a correct-today function that's unreadable becomes the next person's bug because they misunderstand it while extending it; a correct-today API change that silently breaks a downstream caller is correct in isolation and wrong in context. Reviewing for maintainability is reviewing for the NEXT change, not just this one.
Trade-offs and pitfalls
- Reviewing everything at maximum scrutiny on every PR doesn't scale -- calibrate depth to risk (a payment-path change deserves more scrutiny than a copy tweak) rather than applying a uniform checklist regardless of stakes.
- Vague feedback ('this could be cleaner') is nearly useless; specific, actionable feedback with a suggested alternative respects the author's time and moves the review forward instead of triggering a guessing game.
- Nitpicking style that a formatter/linter should catch automatically wastes review bandwidth on human judgment that should be spent on the checks above; automate what can be automated so reviewers focus where judgment actually adds value.
How do you decide between clearer, more readable code and a micro-optimized version, when a function runs extremely frequently (millions of times per second, or on a hot path)? What would make you accept the less-readable version, and what would you require alongside it if you did?
Sample Answer
Direct answer. Accept the less-readable version only when you have MEASURED evidence the hot path actually needs it (not a guess), and only alongside documentation, tests, and isolation that keep the cost contained to exactly that one function -- readability should be the default everywhere else in the same codebase.
What would make me accept the trade-off
- Profiling evidence, not intuition: a flamegraph or benchmark showing this specific function is a measurable bottleneck under realistic load, not a guess that 'this looks slow.'
- The gain is worth the readability cost: a 2x improvement on a function called millions of times per second matters; a 2x improvement on something called once a minute doesn't justify the trade at all, regardless of how 'hot' the code technically is.
- No simpler alternative gets you there: readability-preserving options (a better algorithm, caching, batching) have been considered and don't close the gap -- micro-optimization is the last resort, not the first idea.
What I'd require alongside the less-readable version
- A comment explaining WHY, with the profiling evidence referenced (a link to the benchmark or flamegraph), so a future reader doesn't 'clean up' the optimization back into the slower, clearer form without realizing why it exists.
- A test that pins the PERFORMANCE characteristic, not just correctness -- so a future change that silently reintroduces the slow path is caught, not just a change that breaks output.
- Isolation: the optimized code lives behind a clearly-named function/module boundary with a normal, readable interface, so the complexity is contained to one place rather than leaking into every caller.
- A note in the PR/commit explicitly flagging the trade-off for reviewers, so it's a deliberate, reviewed decision rather than something that slips through because 'the benchmark numbers looked good.'
A worked framing
Say a hashing function is called on every request in a load-balancer's hot path. Profiling shows a straightforward, readable implementation costs 40ns per call versus 8ns for a bit-twiddling optimized version, and this function alone accounts for 15% of total CPU under peak load. That's worth it: document the bit-twiddling with WHY, pin the benchmark in CI, and keep the optimized version behind a small, well-named function (fast_hash()) so callers still read cleanly.
Trade-offs and pitfalls
- The single most common mistake is skipping the measurement step and optimizing based on intuition -- many 'obviously slow' patterns turn out to be irrelevant to actual bottlenecks once profiled, and the readability cost was paid for nothing.
- Don't let one legitimately-optimized hot path set a PRECEDENT that spreads unmeasured 'clever' code elsewhere in the codebase under the banner of 'performance matters here too' -- keep the exception genuinely exceptional and visibly justified.
What does 'intent-revealing naming' mean, and why does it matter more as a codebase and team grow? Give two examples of a poor name and a clearer alternative, and explain what made the better name easier to work with.
Sample Answer
Direct answer. Intent-revealing naming means a name tells you what a thing is or does without needing to read its implementation or a comment. daysSinceLastLogin beats d; isEligibleForDiscount beats flag. It matters more as a codebase grows because you spend far more time reading names than writing them, and a bad name actively lies to the next reader instead of just failing to help.
What makes a name intent-revealing
- It answers what, not how:
activeUsersnotusersWhereStatusEquals1. - It avoids disinformation: don't call a
ListauserMap, don't call somethingtempif it's the actual result. - It's precise about units and shape:
timeoutMsnottimeout;userIds(plural) for a collection. - It's searchable: single letters and abbreviations (
d,usrCnt) can't be grep'd for meaningfully across a large repo. - It's consistent: pick one verb per concept (
fetchvsgetvsretrieve) and use it everywhere.
Two examples
- Poor:
def calc(u, d)whereuis a user anddis a number of days. Better:def days_until_renewal(user: User) -> int. The better name tells you the return value's meaning and unit without opening the function body. - Poor:
if (flag2) { ... }whereflag2toggles whether a discount applies. Better:if (is_first_time_customer_discount_eligible). The better name turns a branch you'd otherwise have to trace back to its assignment into something readable in place.
Why it matters more at scale
On a small script you hold the whole thing in your head, so a bad name costs you nothing. On a codebase with dozens of contributors and files you'll never open again, a name is the ONLY interface most future readers get before they decide whether they understand enough to change something safely. A clear name is effectively free documentation that can't go stale the way a comment can.
Trade-offs and pitfalls
- Longer isn't always clearer:
numberOfActiveUserSessionsCurrentlyOpenis worse thanactiveSessionCount. Aim for precise, not verbose. - Don't encode types in names (Hungarian notation) in a language with a type system already doing that job; it just adds noise that can drift out of sync with the actual type.
- Renaming is cheap with modern IDE tooling (safe rename across the codebase), so there's little excuse to leave a name you already know is misleading; the excuse 'it's used everywhere so I can't change it' is usually solvable with an automated rename, not a reason to give up.
Implement a strategy for gracefully handling missing or extra fields in JSON requests to a model-prediction API. Include code showing validation, defaulting, and the distinction between a warning and an error, and show how you would return a helpful client error response while keeping detailed server-side logs for debugging.
Sample Answer
Direct answer
A model-prediction API receiving a request with missing or extra fields should distinguish a genuinely-required field being absent (a hard failure with a specific client error) from an optional field being absent or an unexpected extra field being present (tolerated, with a warning logged), rather than treating every deviation from the exact expected shape as either a total failure or something silently ignored without a trace.
Structured elaboration
Required versus optional, and what happens on each. A feature the model genuinely cannot produce a meaningful prediction without (a required input feature with no sensible default) should reject the request outright with a specific 400 naming the missing field, since attempting to predict on a made-up default for a genuinely-required feature can silently produce a plausible-looking but meaningless prediction, arguably worse than an honest rejection. A feature with a sensible, documented default (an optional field the model can reasonably assume a value for when absent) should be defaulted, with the substitution logged as a warning, not an error, so the request still succeeds.
Warnings versus errors, as a distinct, structured concept. A warning indicates the request succeeded but something about it deviated from the ideal shape (an optional field defaulted, an extra field ignored); this should be surfaced back to the caller in the response itself (a warnings array) so a client integrating against this API can notice and fix its own request shape over time, not just silently succeed forever with a defaulted value it doesn't realize it's getting. An error means the request could not be fulfilled at all and gets a 4xx with no prediction returned.
Client-facing response versus server-side logs. The client-facing error or warning should be specific enough to be actionable ("feature_b is required") without leaking anything about the model's internals (no mention of the model's architecture, feature importances, or internal thresholds). The full detail, including the raw request, is logged server-side with a request ID so an engineer debugging a client integration issue can see exactly what was received, without that raw detail ever appearing in the client-facing response.
Worked example
REQUIRED_FEATURE_KEYS = {"feature_a", "feature_b"}
OPTIONAL_FEATURE_DEFAULTS = {"feature_c": 0.0}
def handle_predict_request(payload: dict) -> dict:
features = payload.get("features")
if not isinstance(features, dict):
raise PredictionRequestError("missing or invalid 'features' object", 400)
missing = REQUIRED_FEATURE_KEYS - features.keys()
if missing:
raise PredictionRequestError(f"missing required feature(s): {sorted(missing)}", 400)
warnings, normalized = [], {}
for key in REQUIRED_FEATURE_KEYS:
value = features[key]
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise PredictionRequestError(f"feature '{key}' must be numeric", 400)
normalized[key] = float(value)
extra_keys = set(features.keys()) - REQUIRED_FEATURE_KEYS - OPTIONAL_FEATURE_DEFAULTS.keys()
if extra_keys:
warnings.append(f"ignored unexpected field(s): {sorted(extra_keys)}")
for key, default in OPTIONAL_FEATURE_DEFAULTS.items():
value = features.get(key, default)
if not isinstance(value, (int, float)) or isinstance(value, bool):
warnings.append(f"optional feature '{key}' had a non-numeric value, using default {default}")
value = default
normalized[key] = float(value)
prediction = sum(normalized.values()) # placeholder for the real model call
return {"prediction": prediction, "warnings": warnings, "normalized_features": normalized}
Executed and verified: a request missing feature_b raises a 400 naming exactly that field. A request with feature_a, feature_b, and an unrecognized unexpected field succeeds, returns a prediction, and includes a warning naming unexpected as ignored, rather than silently dropping it with no trace. A request where the optional feature_c is present but non-numeric succeeds by substituting the documented default and logging a warning naming which field was defaulted and why, rather than either failing the whole request over an optional field or silently using a wrong value with no signal to the caller. A request where a REQUIRED field has the wrong type still hard-fails, since a required feature demands a genuine value, not a best-effort guess.
Trade-offs and pitfalls
Defaulting an optional field silently, with no warning surfaced to the caller, is a common shortcut that quietly lets a client's integration bug (consistently omitting a field it should be sending) go unnoticed indefinitely, since the request always appears to succeed; surfacing the warning in the response, even though the request still succeeds, gives the client's own team a chance to notice and fix it. The opposite mistake, rejecting the entire request over any unexpected extra field, is overly brittle for a public or evolving API, where a client sending one additional field it happens to track for its own purposes should not be a hard failure if that field is genuinely irrelevant to the prediction itself.
Unlock Full Question Bank
Get access to all 16 Clean Code, Refactoring, and Maintainability interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.