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.
What role do linters, formatters, and static analysis play in keeping a codebase clean? Name a couple of representative tools and explain the difference between what runs locally (pre-commit) versus in CI, and why that split exists.
Sample Answer
Direct answer. Linters catch style and correctness-adjacent issues mechanically and instantly (unused variables, obvious bugs, inconsistent formatting); static analysis goes deeper (type errors, security patterns, complexity thresholds) at a real but higher computational cost -- run the fast, cheap checks locally on every save/commit, and the slower, deeper ones in CI where a few extra minutes doesn't block a developer's flow.
The role each plays
- Formatters (Prettier, Black, gofmt): eliminate style debates entirely by making formatting a non-decision -- code is auto-formatted, so nobody reviews or argues about spacing/quote style ever again.
- Linters (ESLint, Pylint, golangci-lint): catch common bugs and style violations fast (unused imports, unreachable code, inconsistent naming) with sub-second feedback, ideal for real-time editor integration and pre-commit hooks.
- Static type checkers (mypy, TypeScript): catch a whole CLASS of bugs (wrong types passed, missing null checks) that a linter typically can't, at the cost of needing type annotations and somewhat longer analysis time.
- Deeper static analysis (security scanners, complexity/duplication analyzers): find subtler issues (SQL injection patterns, high-complexity functions) but can take minutes on a large codebase, so they belong in CI rather than blocking every keystroke.
Why the local-vs-CI split exists
A developer's moment-to-moment feedback loop needs to stay under roughly a second or people disable the tool out of frustration; anything slower belongs in a pre-commit hook (seconds) or CI (minutes), where the cost is amortized against a less time-sensitive point in the workflow. Putting a slow, deep security scan on every keystroke would make the editor unusable; putting NO fast checks locally means every trivial mistake only surfaces after a full CI run, wasting a much longer feedback cycle on something a formatter would have fixed instantly.
Trade-offs and pitfalls
- Running only in CI (nothing locally) means developers discover style/lint issues only after pushing, which is a slower and more frustrating feedback loop than catching them before commit -- invest in local tooling (editor integration, pre-commit hooks) even though it requires more per-developer setup.
- Don't let CI-only checks become 'optional' in practice by making them non-blocking; a security scanner that only warns and never blocks tends to get ignored over time once its warnings become background noise.
You're shown a concrete, messy class (or class hierarchy) from a real codebase. Identify which SOLID principle(s) it violates, explain the concrete symptom that violation causes for callers or maintainers, and propose an incremental fix.
Sample Answer
Direct answer. Read the class for what actually varies together versus independently, name the SPECIFIC principle the mismatch violates (don't just say 'bad design'), and propose the smallest change that removes the violation without restructuring things that are already fine.
Worked example: an LSP violation in a legacy hierarchy
class NotificationCollector:
def collect(self) -> dict: ... # base contract: returns a metrics dict
class ThrottledCollector(NotificationCollector):
def collect(self) -> dict:
if self._rate_limited():
raise RuntimeError("rate limited") # NEW failure mode the base class never had
return super().collect()
Any code written against NotificationCollector that assumed collect() always returns a dict (never raises) will crash the first time it's handed a ThrottledCollector under load -- exactly the LSP failure mode: the subclass type-checks and implements the method, but breaks the base contract's implicit 'never throws' guarantee.
Symptom this causes for callers
The bug is invisible in code review (both classes compile, both implement collect) and invisible in tests that only exercise NotificationCollector directly or exercise ThrottledCollector only in the unthrottled case. It surfaces in production, under load, in whichever caller happened to be handed a ThrottledCollector instance through code that assumed the base type's behavior.
Incremental fix
Don't silently swallow the new failure mode (that would hide a real signal); make it part of the CONTRACT instead: either (a) change the base class's documented contract to say collect() may raise a specific CollectionUnavailable exception, and update every existing caller to handle it, or (b) if callers genuinely can't tolerate exceptions, have collect() return an explicit result type (Result[dict, Unavailable]) so failure is representable in the return value rather than as a surprise exception -- and make BOTH implementations conform, so the base type's substitutability is restored.
General process for spotting these
- Read the SUBCLASS'S overridden methods and ask: does it add a precondition, remove a guarantee, or introduce a new failure mode the base class's callers weren't written to expect?
- Check if a test suite exists that runs the SAME test cases against every subtype through the base type's interface (a 'contract test') -- if not, that's itself the gap that let this ship.
- Fix the contract explicitly (document + update every caller), don't just patch the one call site that happened to crash.
Trade-offs and pitfalls
- Naming the wrong principle (calling this an SRP violation when it's really LSP) sends the fix in the wrong direction -- splitting responsibilities wouldn't have prevented this; only aligning the contract would.
- An incremental fix that only patches the ONE caller that crashed, without updating the base contract or other callers, leaves the same landmine for the next caller who's handed a
ThrottledCollector.
Explain the SOLID principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) at a level you'd give in a screening interview, with one concrete illustrative example per principle.
Sample Answer
Direct answer. SOLID is five principles for keeping object-oriented designs changeable without cascading breakage: Single Responsibility (one reason to change), Open/Closed (extend without modifying), Liskov Substitution (subtypes must honor the base type's contract), Interface Segregation (don't force unused dependencies on a client), Dependency Inversion (depend on abstractions, not concrete details).
One example per principle
- Single Responsibility: a
UserServicethat validates, persists, AND emails users has three reasons to change; split into a validator, a repository, and an email sender so each can change independently (a copy edit to the welcome email shouldn't risk breaking persistence). - Open/Closed: an incident-remediation engine that dispatches through a registered
RemediationStrategylist -- adding a new incident type means adding a new strategy class, not editing the dispatch function every other strategy also relies on. - Liskov Substitution: a
Squarethat overridesRectangle.set_widthto also change height breaks any code written againstRectangle's contract that assumed width and height vary independently -- a subtype must not silently strengthen preconditions or add surprising side effects. - Interface Segregation: a
DataStoreinterface with read, write, backup, and schema-migration methods forces a read-only client to depend on (and mock) admin operations it never calls; splitting intoDataReader/DataWriter/DataStoreAdminlets each consumer depend only on what it uses. - Dependency Inversion: an
OrderService(high-level policy) that directly instantiates aFileLogger(low-level detail) is coupled to that detail; having both depend on aLoggerabstraction, with the concrete implementation injected, lets you swap loggers or substitute a test fake without touchingOrderService.
Why these five, together
They reinforce each other: OCP's extension points are usually built ON TOP of DIP (abstractions you can plug new implementations into); ISP keeps those abstractions from becoming bloated; LSP is what makes substituting a new implementation SAFE rather than just possible; SRP keeps each piece small enough that the other four are tractable to apply at all.
Trade-offs and pitfalls
- SOLID is a set of PRESSURES toward flexibility, not a checklist to satisfy on every class -- applying all five aggressively to a small, stable, rarely-changed module adds indirection with no corresponding benefit.
- The principles can pull against simplicity if applied dogmatically (an interface with one implementation 'for DIP,' a strategy registry for a case that will only ever have two options 'for OCP'); the judgment call is whether the FLEXIBILITY the principle buys you is actually needed yet, not whether it's theoretically possible to apply.
- SOLID emerged from and is best-fit to object-oriented design; several of its concerns (SRP, DIP-as-abstraction-boundary) translate reasonably to functional or module-based code, but LSP specifically assumes a substitutable-subtype relationship that doesn't map cleanly onto every paradigm.
You have a function that mixes pure calculation with side effects (I/O, persistence, notifications), which makes it hard to unit test. Show how you would separate the pure logic from the side effects, and explain what becomes easier to test once they're split.
Sample Answer
Direct answer. Pull the calculation into a function that takes its inputs and returns a value with no side effects, and push the side effect (the write) into a thin, separately-named function that does nothing else -- then the pure part is trivially unit-testable with no mocks at all.
Before
STORE = {}
def process(order_id, price, qty, discount_pct):
total = price * qty
total -= total * (discount_pct / 100)
STORE[order_id] = total # side effect buried inside "calculation"
return total
Testing this requires a real or fake STORE even though the interesting logic is pure arithmetic.
After
def compute_total(price: float, qty: int, discount_pct: float) -> float:
"""Pure: same inputs always produce the same output, no I/O."""
total = price * qty
return total - total * (discount_pct / 100)
def persist_total(store: dict, order_id, total: float) -> None:
"""The only place that touches shared state."""
store[order_id] = total
def process_after(store, order_id, price, qty, discount_pct) -> float:
total = compute_total(price, qty, discount_pct)
persist_total(store, order_id, total)
return total
Verified: compute_total(100, 2, 10) == 180.0, and the full pipeline produces the identical 180.0 and identical stored value as the original.
What becomes easier to test
compute_totalneeds zero setup:assert compute_total(100, 2, 10) == 180.0is the entire test, no store, no mocking, no cleanup.- You can now table-test dozens of price/qty/discount combinations cheaply, because none of them touch shared state.
persist_totalbecomes small enough that its ONE test just confirms the dict got the right key/value -- you're not re-testing arithmetic every time you test persistence, and vice versa.- Bugs get localized: if a total is wrong, the bug is in
compute_total; if the wrong order_id was written, the bug is inpersist_total. In the original, both possibilities are tangled in one function.
Trade-offs and pitfalls
- This split adds one more name and one more call in the composition function -- for a two-line function that's plausibly not worth it, but the moment you have edge cases (negative discounts, currency rounding, tiered discounts) the pure/impure split pays for itself immediately.
- Watch for a subtler version of the same bug: a function that LOOKS pure but secretly reads global mutable state (e.g., a discount rate from a global config) is not actually pure, and hides the same testability problem behind clean-looking code.
- Don't over-purify: a system needs side effects somewhere. The goal isn't zero side effects, it's ISOLATING them so the decision logic can be tested without them.
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.
Unlock Full Question Bank
Get access to all 37 Clean Code, Refactoring, and Maintainability interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.