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 need to add unit tests to legacy code that talks directly to a third-party client (a database, a cloud API) with no seam for a test double. Explain how you'd introduce a seam so you can test the logic without hitting the real dependency.
Sample Answer
Direct answer. Introduce a seam -- a thin interface or wrapper between your logic and the hard-to-test dependency -- so tests can substitute a fake implementation without touching the real database/API, then test your logic against that seam instead of the real thing.
The problem
def sync_user(user_id):
conn = ThirdPartyDB.connect() # real network call, no seam to intercept
row = conn.query(f"SELECT * FROM users WHERE id={user_id}")
return transform(row)
There's no place to inject a test double: the function reaches OUT and grabs its own dependency internally, so any test either needs a real (or realistically faked) ThirdPartyDB running, or it can't test transform's logic in isolation at all.
Introducing a seam
class UserRepository:
def __init__(self, db_client):
self.db_client = db_client # injected, not grabbed internally
def fetch_user_row(self, user_id):
return self.db_client.query(f"SELECT * FROM users WHERE id={user_id}")
def sync_user(user_id, repo: UserRepository):
row = repo.fetch_user_row(user_id)
return transform(row)
A test now passes a UserRepository wrapping a FAKE db_client (an in-memory stub returning a canned row) -- sync_user's actual logic (transform) is tested without any real network dependency, and the seam (UserRepository) can separately get a thin integration test against the real client if desired.
Choosing where the seam goes
Put the seam at the boundary between YOUR logic and the THIRD-PARTY specifics -- not deeper. Here, UserRepository isolates 'how we talk to this specific database'; everything on the sync_user side of that seam is pure application logic, testable with a simple fake that doesn't need to reimplement the third party's actual query semantics.
Trade-offs and pitfalls
- A seam that's too thin (barely wrapping the third-party call) doesn't buy you much; a seam that's too thick (re-implementing significant third-party logic in your fake) risks the fake diverging from real behavior and giving false test confidence -- calibrate the fake to return realistic shapes for the cases your logic actually branches on.
- Introducing a seam is itself a small refactor of legacy code with no tests yet -- do it carefully (ideally under a characterization test on the OUTER function first) so the seam-introduction step itself doesn't silently change behavior.
- Keep at least one lightweight integration test that exercises the REAL third-party client occasionally (even if slow/flaky and run less often), since fakes can drift from reality if the third party's behavior changes and nothing ever re-validates the assumption.
What does good version-control hygiene look like day to day: commit granularity and messages, branch naming and PR size, and how you'd handle large binary or generated files if your project has them? Give one example of a commit message that helps a future reader and one that doesn't.
Sample Answer
Direct answer. Good version-control hygiene means every commit and PR tells a clear, minimal, reviewable story: small commits with messages that explain WHY, PRs scoped to one reviewable change, and a branching approach the whole team actually follows consistently.
Commit granularity and messages
- Each commit should represent ONE logical change that could, in principle, be reverted independently without breaking something unrelated -- a commit that mixes a bug fix with an unrelated formatting sweep makes both harder to review and harder to revert cleanly later.
- Good message:
Fix race condition in order total calculation (see INC-204)\n\nTwo concurrent requests could both read the stale total before either\nwrite committed. Wrap the read-modify-write in a transaction.-- explains WHY (the bug, the mechanism) not just WHAT (which is visible in the diff already). - Bad message:
fix bug-- tells a future reader (includinggit blamesix months from now) nothing they couldn't already see from the diff itself, and gives zero context for WHY this change was needed.
PR size and branch/PR conventions
- Smaller PRs get reviewed faster and more thoroughly -- a reviewer can hold a 100-line diff in their head; a 2,000-line diff gets a rubber-stamp approval because nobody can meaningfully review it in one sitting.
- A consistent branch-naming convention (
fix/order-total-race,feature/bulk-export) makes it easy to scan open branches and understand what's in flight without opening each one. - Rebasing versus merging is a team-level convention choice (rebase keeps history linear and easier to bisect; merge preserves the exact chronological record) -- the specific choice matters less than the TEAM actually agreeing on and following one consistently, so history reads predictably regardless of who wrote it.
Handling large binary or generated files
- Committing large binaries (design assets, model weights, video fixtures) directly into a normal git history bloats every future clone and slows down operations like
git logandgit blamefor the whole team, forever, even after the file is deleted, since git history keeps every version. - Use Git LFS (or an equivalent large-file extension) for binaries that genuinely need to be version-controlled alongside code, so the main repository only stores a lightweight pointer.
- For anything that can be REGENERATED from source (build output, compiled assets, lockfile-derived artifacts), keep it out of version control entirely via
.gitignorerather than committing it and then fighting merge conflicts on a file nobody hand-edits. - If large files were already committed by mistake, history-rewriting tools (
git filter-repo) can remove them retroactively, but that rewrites shared history and needs the same coordination caution as any other history rewrite on a branch others have pulled.
Why this matters for maintainability specifically
Git history is a maintainability tool in its own right: git blame and git log are often the FASTEST way to understand why a confusing piece of code exists, but only if commit messages actually explain the why -- a history of 'fix bug', 'wip', 'more fixes' gives future maintainers nothing to work with when they're trying to understand a decision made months or years ago.
Trade-offs and pitfalls
- Enforcing small PRs can pressure people to under-scope a change that's genuinely indivisible (e.g., a schema migration that must ship atomically with the code that depends on it) -- the goal is REVIEWABLE size, not an arbitrary line-count limit that ignores what a change actually requires.
- Rewriting history (interactive rebase, squashing) before merging to clean up a messy WIP trail is generally good practice, but doing it on a SHARED branch others have already pulled causes real pain -- keep history rewrites scoped to your own not-yet-shared branch.
Design a pragmatic code-review checklist for your team. What would you deliberately leave OFF the checklist to keep review from becoming a rubber-stamp exercise or, conversely, a bottleneck?
Sample Answer
Direct answer. A pragmatic checklist covers correctness, readability, test coverage of the diff, and deploy safety -- and just as importantly, it deliberately EXCLUDES anything a linter/formatter/CI check can enforce automatically, so human review time is spent on judgment calls, not mechanical nitpicks.
The checklist
- Does it do what the PR description claims? (Trace the change against the stated intent, not just 'does it compile.')
- Are names, structure, and comments clear to someone who didn't write this?
- Does the diff include tests for the new/changed behavior, including at least one edge case?
- Does this change any public contract (API shape, DB schema, config format) that existing callers depend on?
- What happens on failure (a dependency times out, an input is malformed) -- is it handled, or does it fail silently/loudly in an unintended way?
- Is the PR scoped to one reviewable change, or does it bundle refactoring with behavior changes in a way that obscures what actually matters?
- Deploy safety: does this need a feature flag, a migration plan, or a rollback path, given what it touches?
What to deliberately leave OFF
- Formatting/style (spacing, import order, line length) -- enforce via an auto-formatter in CI so it's never a human review comment.
- Naming conventions that a lint rule can check (e.g., enforced casing) -- same reasoning.
- Whether every possible edge case is tested exhaustively -- reviewing for 'reasonable' coverage, not 100%, keeps review from becoming a bottleneck; deeper edge-case work belongs to the test-strategy conversation, not blocking every PR.
Avoiding rubber-stamp AND avoiding bottleneck
- Rubber-stamp risk: a checklist that's just checkboxes ticked without engagement becomes theater. Counter this by requiring at least one substantive comment or an explicit 'nothing to flag, LGTM because X' rather than a bare approval, so reviewers engage with the actual content.
- Bottleneck risk: requiring EVERY item to be perfect before merge turns review into a stalling tactic. Counter this by distinguishing 'must fix before merge' (contract breaks, missing tests on risky logic, security issues) from 'nice to have, can be a follow-up' (a slightly awkward name, a minor readability nit) and saying so explicitly in the review.
Trade-offs and pitfalls
- A checklist that's too long gets skimmed, not followed -- keep it to the handful of items that actually catch real problems in your codebase's history, and retire items that never fire.
- Deploy-safety questions (feature flags, rollback) matter enormously for high-risk changes and are often irrelevant noise for a low-risk copy fix -- calibrate which items apply based on the CHANGE, not apply all seven uniformly to every PR.
Design an approach to detect duplicated or near-duplicated code across a repository (or across repositories), going beyond simple text matching. What representation (AST-based similarity, token sequences) would you use, and how would you tune it to avoid flooding the team with false positives on boilerplate?
Sample Answer
Direct answer. Go beyond text diffing: represent each function/method as a normalized abstract syntax tree (or a token sequence with identifiers abstracted away), then compare structural similarity so renamed variables and reformatted whitespace don't hide a real duplicate.
Why plain text matching fails
Two functions that are logically identical but use different variable names, different whitespace, or a different but equivalent loop construct (for vs while) look completely different as raw text, yet they're exactly the duplication you want to catch. Conversely, two SHORT snippets that happen to share text (e.g., a common three-line boilerplate header) look identical as text but aren't meaningful duplication.
A workable approach
- Parse each file into an AST using the language's own parser (or a library like tree-sitter for multi-language support).
- Normalize: strip identifier names, literal values, and comments, replacing them with positional placeholders, so
x = a + bandtotal = price + taxnormalize to the same shape. - Fingerprint subtrees: compute a hash for each subtree of at least N nodes (a minimum size threshold avoids flagging trivial one-line matches like a lone
return None). - Cluster matching fingerprints across the repo (or across repositories, if you're centralizing the index) and rank clusters by combined size and number of occurrences.
- Report with context: show the actual (non-normalized) code for each match so a human can judge whether the duplication is meaningful or coincidental (e.g., two unrelated functions that both happen to validate a non-empty string).
Tuning against false positives
- A minimum subtree size (e.g., 5+ meaningful statements) filters out trivial matches like getters/setters or short validation checks that are naturally similar without being 'duplication' worth extracting.
- Allowing a small edit-distance tolerance (near-duplicates, not just exact structural matches) catches copy-paste-and-tweak cases, which are the MORE common and MORE costly form of duplication in practice, at the cost of more false positives to triage.
- Excluding generated code and test fixtures (which are often intentionally repetitive) avoids drowning real signal in noise.
Trade-offs and pitfalls
- Cross-language duplication (the same logic reimplemented in Python and in a Go microservice) is invisible to a single-language AST approach; if that's a real risk in your org, you'd need a language-agnostic semantic representation, which is a much harder problem.
- A tool that only reports 'these two blocks are similar' without a merge-effort estimate (are the small differences meaningful business logic, or just parameter values?) puts all the analysis work back on the human, which limits adoption if the report is too noisy.
- Running full AST diffing across a large monorepo on every PR can be slow; a common compromise is running it as a periodic batch job with results feeding a dashboard, rather than a blocking pre-merge check.
Design a bulk-update endpoint that accepts an array of items to update. Walk through every edge case you can think of for an endpoint like this, and propose a clean, testable approach for handling each one and communicating the results back to the client.
Sample Answer
Direct answer
A bulk-update endpoint accepting an array of items needs an explicit policy for every one of a handful of predictable edge cases (an empty array, duplicate items in the same request, some items succeeding while others fail, an oversized payload, rate limits, and repeated identical requests), because a design that only handles the all-succeed happy path will behave unpredictably the first time real traffic hits any of these, which for a bulk endpoint specifically tends to happen often, not rarely.
Structured elaboration
Empty array. Decide explicitly whether an empty items array is a no-op success (200 with an empty results array) or a validation error (400, since a client presumably meant to send something); either is defensible, but it must be a deliberate choice documented in the API contract, not an accident of whatever the loop implementation happens to do when handed zero items.
Duplicate items in the same request. If the same item ID appears twice in one request, decide whether the second occurrence is an error, is silently ignored (last-write-wins or first-write-wins), or produces genuinely duplicate side effects; silently allowing duplicates to double-apply an update is the most common accidental behavior and is rarely what's actually wanted.
Partial failures. For an array of N items where some succeed and some fail, the response needs to report per-item results (which specific items succeeded, which failed and why), rather than either a single boolean for the whole batch (which loses which items actually failed) or an all-or-nothing transaction (which is sometimes the right choice, but is a very different guarantee that must be explicitly chosen and documented, not assumed).
Payload size. An unbounded array size lets a single request consume disproportionate server resources or take an excessively long time to process; enforce an explicit maximum item count per request, rejecting requests over that limit up front with a clear error, rather than accepting an arbitrarily large array and hoping it processes acceptably.
Rate limits. A bulk endpoint effectively lets a client perform the work of many individual requests in one call, which can circumvent a naive per-request rate limit; rate limiting for a bulk endpoint should account for the number of ITEMS being processed, not just count the endpoint call itself as one request.
Idempotency. As with any mutating endpoint, a client that doesn't receive the response to a bulk-update request (due to a timeout or a dropped connection) needs a safe way to retry without risk of double-applying updates that already succeeded; an idempotency key covering the whole batch, or per-item idempotency depending on the use case, addresses this the same way as for a single-item endpoint.
Worked example
A PATCH /items/bulk endpoint accepting {items: [{id, changes}, ...]} with a documented policy: an empty array returns 200 with an empty results array (a deliberate no-op, not an error). A duplicate id appearing twice in the same request returns a 400 rejecting the whole request before any updates are applied, since silently picking one of the two conflicting update instructions for the same item is more likely to hide a client bug than to reflect real intent. The maximum array size is capped at 500 items per request, enforced before any processing begins, with requests over that limit rejected with a clear "reduce batch size" error. Each item's update is attempted independently, and the response is {results: [{id, status: "success"|"failed", error?}]}, one entry per input item, so a client can see exactly which of its 500 items succeeded and which failed, and only needs to retry the failed subset rather than the whole batch. The endpoint's rate limiter counts each item in the batch toward the client's per-minute item-processing quota, not just counting the single HTTP call, so ten bulk requests of 500 items each are correctly counted as 5,000 units of work rather than 10.
Trade-offs and pitfalls
The most common design mistake is choosing all-or-nothing transactional semantics by default without considering the cost: for 500 independent item updates, requiring every single one to succeed or none do means one malformed item in an otherwise-valid batch blocks 499 legitimate updates, which is rarely the actual business intent for a bulk operation and should be a deliberate choice, not an accidental consequence of wrapping the whole batch in one database transaction for convenience. A second common mistake is returning only a single aggregate success/failure boolean for the whole batch, which forces a client that gets a partial failure to guess which items actually succeeded, typically by re-fetching everything to compare state, rather than simply reading which items failed from a properly-shaped, per-item response in the first place.
Unlock Full Question Bank
Get access to all 19 Clean Code, Refactoring, and Maintainability interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.