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.
When should you write a comment versus refactor the code so it explains itself? Given a trivial restating comment like // increment i by 1 above i += 1, explain whether it should be removed, and give one example each of a comment that legitimately belongs (explains WHY) and one that's a smell (explains WHAT).
Sample Answer
Direct answer. Comment when the code can't express WHY (a business rule, a workaround, a non-obvious trade-off); refactor instead of commenting when the comment only restates WHAT the code already says -- a comment that duplicates the code is guaranteed to drift out of sync with it eventually.
The trivial case
# increment i by 1
i += 1
This comment is pure noise: it tells you nothing i += 1 doesn't already say faster to read. Delete it; if i needs a better name to convey intent (e.g., retry_count += 1), fix the name instead of commenting around it.
A comment that legitimately belongs (explains WHY)
# Stripe requires idempotency keys to be reused for retries within 24h,
# otherwise it treats a retry as a new charge. See INC-4021.
idempotency_key = order_id # intentionally NOT time-based
No amount of renaming makes 'why we chose this specific value, tied to an external API's undocumented-until-we-got-burned behavior' obvious from the code alone -- this is exactly the kind of context a comment should preserve, ideally with a link to the incident/ticket for anyone who wants the full story.
A comment that's a smell (explains WHAT, redundant with the code)
# loop through all users
for user in users:
The code already says this as clearly as English could; the comment adds a second thing that has to be kept in sync every time the loop changes, for zero reader benefit.
A simple test to apply
Ask: 'if I deleted this comment, would a competent reader lose information, or just lose a restatement?' If deleting it loses nothing, delete it. If deleting it loses the REASON something non-obvious is true, keep it (and consider whether the reason belongs in a commit message / ticket link too, for permanence).
Trade-offs and pitfalls
- Comments that explain why are still at risk of going stale if the underlying reason changes (the external API behavior gets fixed) but nobody removes the now-obsolete comment -- treat comments as code that also needs maintenance, not a write-once artifact.
- Don't over-correct into a 'no comments ever' culture; some domains (financial regulations, security-sensitive code, deliberately non-obvious performance tricks) genuinely need WHY documented, and a codebase that bans comments entirely just pushes that knowledge into people's heads (or nowhere), which is worse.
- A comment that says 'TODO: fix this properly' with no ticket link or date is close to noise too -- if it's worth flagging, it's worth tracking somewhere more durable than an inline string that nobody searches for.
A game occasionally fails to load textures or audio on low-memory devices, causing crashes or visual glitches. Propose a robust error-handling and recovery strategy for resource loading that preserves the user experience and makes debugging easier in production builds. Mention fallback policies, telemetry, and how you would test this behavior.
Sample Answer
Direct answer
When a texture or audio asset fails to load, most commonly on low-memory devices under real-world conditions, the game should substitute a lightweight placeholder immediately rather than crash or leave a visual gap, retry the real asset in the background if appropriate, and report the failure through telemetry so the team can see which assets and devices are actually affected without relying on player bug reports.
Structured elaboration
Fallback policy. Every asset load path needs a defined fallback asset (a low-resolution placeholder texture, silence or a minimal sound cue for audio) that is always available in memory or bundled with the app, so a failed load degrades visually or audibly rather than crashing the render pipeline or leaving an object with no texture at all, which on some engines can itself cause a crash or an undefined-behavior render state rather than simply looking wrong.
Retry strategy, tuned to the cause. A load failure caused by transient memory pressure (the OS reclaimed memory the game was using, common on mobile under multitasking) is often worth retrying once the memory pressure clears, whereas a load failure caused by a corrupted or missing asset file is not going to succeed on retry and should fall back permanently rather than retrying in a loop that wastes cycles and can itself contribute to further memory pressure.
Making this debuggable in production builds. A production build typically has no attached debugger and limited logging visibility, so the failure needs to be captured via telemetry: which specific asset failed to load, on which device model and OS version, at what point in the memory-pressure lifecycle, and whether the fallback was used or the load eventually succeeded on retry. Aggregated across all players, this turns "players on some devices see broken textures sometimes" into a specific, actionable signal ("asset X fails to load on 40% of attempts on device model Y specifically").
Preserving the user experience. The fallback should be visually or audibly unobtrusive rather than jarring: a low-res placeholder texture that still roughly matches the object's silhouette and color palette is much less noticeable than a bright pink "missing texture" placeholder or, worse, an invisible object; similarly for audio, a brief silence is usually less disruptive to the player than an abrupt, jarring alternate sound cue.
Worked example
A texture atlas fails to fully decompress on a low-memory Android device mid-gameplay. The asset-loading system catches the decode failure, immediately swaps in a pre-bundled low-resolution placeholder for the affected object so the frame renders without a gap or crash, logs a telemetry event (asset_load_failed, tagged with the asset ID, device model, available memory at time of failure, and "fallback_used: true"), and schedules one retry of the full-resolution asset after a short delay, on the theory that the memory pressure causing the initial failure may have been transient. If the retry also fails, the system does not retry further in a loop; it stays on the placeholder for the remainder of the session and logs that the retry also failed, which is a distinct, more concerning telemetry signal than a single transient failure that self-resolved.
Trade-offs and pitfalls
Retrying aggressively in a loop on a device that is genuinely, persistently memory-constrained can make the underlying memory pressure worse, competing with the game's own attempt to free memory elsewhere, which is why a bounded, single retry (or none, for a known-corrupted asset) is safer than an unbounded retry loop that optimistically assumes the condition will clear. A common mistake is designing a fallback asset that is visually jarring (bright, obviously-a-placeholder textures) specifically to make the bug easy to notice during development, and then shipping that same jarring placeholder to production, where it should instead be as visually unobtrusive as possible so a real player's experience degrades gracefully rather than looking broken.
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.
Compare dependency injection and the service-locator pattern. What do you gain and lose with each in terms of testability, discoverability of dependencies, and runtime cost, and which would you default to for a new module?
Sample Answer
Direct answer. Dependency injection gives you compile-time (or construction-time) visibility into what a class depends on and lets a test substitute a fake trivially; the service locator hides dependencies inside a global lookup, so a reader (and a test) can't tell what a class actually needs without reading its full body.
Dependency injection
class OrderService:
def __init__(self, payment_client, notifier):
self.payment_client = payment_client
self.notifier = notifier
Every dependency is visible in the constructor signature. A test constructs OrderService(fake_payment_client, fake_notifier) with zero global state to set up or tear down, and the signature itself documents the class's needs.
Service locator
class OrderService:
def charge(self, amount):
payment_client = ServiceLocator.get("payment_client") # dependency is hidden
payment_client.charge(amount)
A reader has to open the METHOD BODY (not just the constructor) to discover this class needs a payment client at all, and a test has to configure the global locator before running, then remember to reset it afterward or risk leaking state into the next test.
What you gain and lose with each
- Testability: DI wins clearly -- dependencies are explicit and swappable per-test with no shared global state. Service locator tests are more fragile (order-dependent, need setup/teardown discipline) because the locator itself is global mutable state.
- Discoverability: DI wins -- you can tell a class's dependencies from its constructor without reading every method. Service locator dependencies are invisible until you trace every call site that hits the locator.
- Runtime cost: essentially a wash for typical DI (constructor injection is just normal object construction); a poorly implemented service locator that does string-keyed lookups on every call adds a small runtime cost DI avoids, though a well-cached locator narrows this gap.
- Convenience for deep call chains: service locator can look more convenient when a dependency is needed 10 layers deep and you don't want to thread it through every intermediate constructor ('parameter drilling') -- but that's usually a signal the layering itself needs rethinking, not a case for hiding the dependency.
Default recommendation
Default to constructor-based DI for new code; it makes the dependency graph an explicit, reviewable part of the design and keeps tests simple and isolated. Reach for a locator (or a DI container that resolves dependencies FOR you, which is a more disciplined middle ground) mainly in frameworks/plugin systems where the set of implementations is genuinely dynamic and not known at construction time.
Trade-offs and pitfalls
- A DI container (Spring, Guice, etc.) can itself become a soft service locator if code reaches into the container directly at arbitrary points rather than only at composition roots -- the discipline that matters is WHERE resolution happens, not just which mechanism you use.
- Excessive constructor parameters from over-injecting is itself a smell (see the parameter-object survivor) -- if a class needs eight injected dependencies, that's often a sign it has too many responsibilities.
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.
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.