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.
Explain the Strangler Fig pattern for retiring a legacy system incrementally. Walk through applying it to extract a single business capability out of a monolith into a new service, one seam at a time, while the old and new paths coexist.
Sample Answer
Direct answer. The Strangler Fig pattern retires a legacy system gradually by growing a new implementation AROUND the old one -- routing traffic for one capability at a time to the new path while the old path keeps serving everything else -- until the old system has nothing left to do and can be switched off, named after the fig vine that grows around a host tree until the tree is no longer structurally necessary.
Applying it to extract one capability from a monolith
- Pick a single, well-bounded capability to extract first (e.g., 'password reset'), ideally one with a clear API boundary and low coupling to everything else, so the first cut proves the pattern works before tackling something harder.
- Introduce a routing seam in front of the monolith (a reverse proxy rule, a facade layer, or a feature-flagged branch in the monolith's own request handling) that can send traffic for this capability to either the OLD code path or the NEW service.
- Build the new implementation behind that seam without touching the monolith's existing code for this capability yet -- both paths coexist.
- Shadow or dual-run: route a copy of real traffic to the new path and compare its output against the old path's actual response, without yet trusting the new path's answer, to build confidence before it's live.
- Cut over gradually (percentage rollout, or one customer segment at a time), with the old path still available as an instant rollback target.
- Once the new path is fully trusted and handling 100% of this capability's traffic, delete the OLD code for that capability from the monolith -- this deletion step is important and often skipped; skipping it just leaves dead code and a growing new system alongside an undiminished old one.
- Repeat for the next capability, using lessons from the first migration to go faster on subsequent ones.
Why this beats a big-bang rewrite
At every step, the system in production is either the old path (fully proven) or the new path (proven via shadow testing before it takes real traffic) -- there's no moment where you're betting the whole system's correctness on an untested wholesale replacement. Rollback at any point is 'route back to the old path,' not 'revert a multi-month rewrite.'
Trade-offs and pitfalls
- Maintaining BOTH the routing seam and (temporarily) both implementations has real overhead; if a migration stalls partway (deprioritized, or the team moves on), you're left maintaining two systems indefinitely -- treat the coexistence period as time-boxed and track it as active technical debt with an owner, not an open-ended state.
- Choosing the FIRST capability to migrate badly (something deeply coupled to everything else) can make the pattern look like it doesn't work; start with the most cleanly bounded piece even if it's not the highest business priority, to prove the mechanics before tackling harder cuts.
- Don't skip step 6 (deleting the old code) -- an incomplete strangler migration that never finishes is often worse than either a clean monolith or a clean new system, since it's now two systems to reason about with unclear ownership of which one is authoritative.
Define a robust error-handling and escalation policy for production data pipelines. Classify transient errors (retry), data-validation failures (dead-letter), and infrastructure failures (alerts and runbooks). Explain how you would implement these classifications in code, how you would surface rich context in alerts, and how you would ensure the operational runbooks are discoverable and linked from the alerts themselves.
Sample Answer
Direct answer
A production data pipeline needs three distinct error categories, each with its own automatic response: transient errors get retried, data-validation failures get routed to a dead-letter store for later inspection rather than blocking the pipeline, and infrastructure failures trigger an alert with enough context that an on-call engineer does not have to reconstruct what happened from scratch.
Structured elaboration
Transient errors: retry. A timeout calling an external API, a momentary database connection blip, a temporary rate limit: these are expected to resolve on their own, so the pipeline should retry with exponential backoff and a bounded number of attempts, and only escalate to an alert if retries are exhausted, since alerting on every transient blip that self-resolves trains the on-call rotation to ignore alerts.
Data-validation failures: dead-letter, don't block. A record that fails schema validation, has an out-of-range value, or is missing a required field should never be allowed to halt processing of every OTHER record behind it in the same batch. Instead, route it to a dead-letter store (a separate table, topic, or file) tagged with the specific validation failure reason and enough of the original record to investigate later, and continue processing the rest of the batch. This is a deliberate trade: a single malformed record from a flaky upstream source should not turn into a full pipeline outage.
Infrastructure failures: alert with rich context, linked to a runbook. A failure that is not transient and not a data-quality issue (the destination database is unreachable, a required credential has expired, a disk is full) needs a human, and the alert needs to carry enough context to start investigating immediately: which stage failed, the error message, the affected record count or time range, and a direct link to a runbook for that specific failure category, discoverable FROM the alert itself, not requiring the on-call engineer to already know where the runbook lives or to search for it while an incident is active.
Implementing the classification in code. A small, shared exception hierarchy (for example TransientError, ValidationError, InfrastructureError, each a distinct type) lets the pipeline's top-level error handler dispatch by type rather than by parsing error message strings, which is both more reliable and easier to extend as new failure modes are identified.
Ensuring runbooks are actually discoverable from an alert. The alerting rule itself should embed a direct link to the specific runbook for that failure category as part of the alert payload (not merely "see our wiki"), and that runbook should be short and specific to this failure mode, since a long, generic "data pipeline troubleshooting" document that isn't specific to the failure just happened is much less useful during an actual incident at 2 a.m.
Worked example
class TransientError(Exception): pass
class ValidationError(Exception):
def __init__(self, record, reason):
self.record, self.reason = record, reason
super().__init__(reason)
class InfrastructureError(Exception): pass
def process_batch(records):
dead_letters = []
for record in records:
try:
validate(record) # raises ValidationError on bad data
write_with_retry(record) # raises TransientError after exhausting retries
except ValidationError as e:
dead_letters.append({"record": e.record, "reason": e.reason})
continue # keep processing the rest of the batch
except TransientError:
raise # escalate: retries already exhausted upstream, this needs an alert
if dead_letters:
persist_dead_letters(dead_letters) # tagged with reason, queryable later
A record missing its timestamp field is caught as a ValidationError, written to the dead-letter store with the reason "missing required field: timestamp", and the batch continues; the pipeline's success metric reflects "N of M records processed, K dead-lettered" rather than a hard failure. If the destination database itself is unreachable, write_with_retry exhausts its retries and raises InfrastructureError, which propagates out of process_batch, triggering a page with a link to the "destination database unreachable" runbook.
Trade-offs and pitfalls
The most damaging mistake is treating a validation failure as an infrastructure failure by accident, which halts the entire batch over one bad record from a single flaky upstream source, turning a minor, expected data-quality issue into a full pipeline outage. The opposite mistake is equally damaging: silently dead-lettering an infrastructure failure (treating a database-unreachable error as if it were just one bad record) means the pipeline appears to succeed while quietly failing to write most of its output, which can go unnoticed until someone downstream asks why a report is missing data.
Describe a defensive-programming strategy to detect and handle schema drift for JSON records arriving in a streaming pipeline. Include which runtime checks you would implement (field presence, types, unexpected fields), when you would fail fast versus degrade gracefully, and how you would surface schema issues to monitoring and alerts without overwhelming the team with noisy spikes.
Sample Answer
Direct answer
A streaming JSON pipeline needs to check every incoming record against an expected shape (field presence, types, and unexpected new fields) at the point it is consumed, decide per check whether a violation should fail the record fast or be tolerated and degraded gracefully, and surface schema issues to monitoring in a way that highlights a genuine new drift without paging someone for the same known, tolerated deviation over and over.
Structured elaboration
The runtime checks. Presence: are all required fields there. Types: does each field match its expected type (a timestamp that arrives as a number instead of a string is a common, easy-to-miss drift). Unexpected fields: did a new field appear that the pipeline doesn't yet know about; this one is not necessarily an error (many schema evolutions are additive) but should still be tracked, since an unexpected field appearing en masse is itself often the first visible sign of an upstream schema change before anyone has announced it.
Fail-fast versus degrade, decided per field, not uniformly. A required field that goes missing entirely for a genuinely-required piece of the record (the primary key, or a field the downstream consumer cannot function without) should fail that specific record into a dead-letter path rather than silently continuing with a corrupted or incomplete record, since propagating bad data downstream compounds the problem, is often harder to detect than an upfront rejection, and can be much harder to unwind later. A field whose absence is survivable (a new optional field, or one the current pipeline stage doesn't use) should be tolerated and degrade to a sensible default or simply be dropped, rather than treated as a hard failure, and this per-field policy should be an explicit, documented decision rather than an accidental byproduct of whichever exception happened to be thrown first.
Surfacing schema issues without noisy spikes. A single record failing a schema check occasionally (from an upstream retry sending a slightly different shape, or a genuinely malformed one-off record) is a normal, low-level background rate that dead-lettering already handles without needing a page. What should actually alert someone is a RATE CHANGE: schema-violation counts spiking well above the pipeline's normal background rate within a short window, which indicates a genuine upstream schema change rather than the usual scattered noise, and alerting on the rate (or a percentage of total volume) rather than on any single violation avoids paging someone for one bad record while still catching a real drift quickly.
Worked example
A streaming order-events pipeline expects {order_id: string, amount: number, status: string, metadata?: object}. A record arrives missing order_id entirely: this fails the required-field check and is dead-lettered immediately, since a downstream aggregation keyed by order_id cannot function without it, and continuing to process this record would either crash the aggregation or silently corrupt it with a null key. Separately, a batch of records starts arriving with a new field discount_code that the schema doesn't define: this is treated as tolerated (the extra field is dropped before the record is passed to strict-schema downstream consumers) but is also logged as an "unexpected field observed" metric, tagged with the field name; if the RATE of records carrying this new field crosses 5% of total volume within an hour, that crosses an alert threshold specifically calibrated to catch a genuine schema rollout rather than one stray record, prompting the team to investigate and formally add the field to the expected schema (or route it into a purpose-built handling path) before the eventual all-records-have-it state arrives.
Trade-offs and pitfalls
Paging on every single schema-violating record produces enough noise that a team will eventually mute or ignore the alert entirely, which defeats its purpose the moment a genuine, large-scale drift actually occurs; alerting on a rate or a percentage-of-volume threshold, calibrated against the pipeline's own historical background noise level, avoids this. The most common design mistake is applying the same fail-fast-versus-degrade policy uniformly to every field regardless of how critical it actually is to downstream consumers, which either dead-letters far too aggressively (rejecting records over a genuinely-optional field) or, in the opposite direction, silently tolerates the loss of a field that a downstream aggregation actually needed, corrupting results without any visible failure at all.
What is idempotency in the context of ETL and data pipelines? Give two concrete strategies to make a batch job idempotent, for example file-based output versus a database upsert, explain how each strategy achieves idempotency, and discuss the trade-offs. Then extend this to a distributed streaming system with at-least-once delivery, where multiple retries can create duplicate downstream writes: design a robust approach to deduplicated writes that accounts for idempotent keys, transaction support, late arrivals, and eventual compaction, and describe the trade-offs and failure modes.
Sample Answer
Direct answer
Idempotency in ETL and data-pipeline jobs means that running the same job twice with the same input produces the same end state, not double the output; the two most common strategies are writing to a deterministic, content-addressed output location (so a re-run overwrites rather than duplicates) and using a database upsert keyed on a stable identifier (so a re-run updates the existing row rather than inserting a second one). In a distributed streaming system with at-least-once delivery, the same principle extends to deduplicating writes using idempotent keys and a compaction step to bound storage growth over time.
Structured elaboration
File-based output, made idempotent through deterministic naming. If a batch job's output filename is deterministic and derived from its input parameters (for example orders_2026-07-23.parquet, derived from the job's run date, not a timestamp of when the job happened to execute), then re-running the job for the same date simply overwrites the same file rather than creating orders_2026-07-23_run2.parquet. This achieves idempotency for free from the storage layer's own overwrite semantics, but only if nothing about the filename depends on execution time or a random ID.
Database upsert, keyed on a stable identifier. Rather than a plain INSERT, the job performs an INSERT ... ON CONFLICT (natural_key) DO UPDATE, keyed on a natural or business key (an order ID, not an auto-incrementing row ID assigned at insert time). A re-run with the same input data updates the existing row to the same values rather than creating a duplicate; this is more flexible than the file-overwrite approach (partial re-runs, incremental updates) but requires the key to be genuinely stable and known before the write, not generated by the write itself.
Trade-offs between the two. File-overwrite is simpler and requires no coordination beyond deterministic naming, but is inherently all-or-nothing per file: a partial re-run cannot cheaply update just the changed subset. Database upsert supports partial and incremental re-runs naturally, but requires a genuinely stable natural key to exist for every record, which is not always available (some source systems only provide a surrogate key assigned at ingestion time, which defeats the purpose if it changes between runs).
Extending to at-least-once streaming delivery. In a distributed streaming system, a message can be delivered more than once (a consumer crashes after processing but before acknowledging, and the message is redelivered), so downstream writes need the same idempotent-key discipline as the batch case, but continuously rather than per-run: each message carries or is assigned a stable idempotency key (often derived from its own content or a producer-assigned sequence number), and the write path checks whether that key has already been applied before writing again. Because this check-then-write path runs continuously and forever, the store of "already-applied keys" needs a compaction or expiry policy (keys older than the maximum plausible redelivery window can be safely forgotten), or the deduplication store itself grows without bound.
Late arrivals. A message that arrives very late (after its logical time window has already been processed and even compacted) needs an explicit policy: either it is accepted and triggers a targeted reprocessing of the affected downstream aggregate, or it is dropped with a metric tracking how often this happens, but silently ignoring late arrivals with no visibility at all risks a slow, undetected accuracy drift in whatever the pipeline aggregates over time.
Worked example
A nightly batch job aggregates daily order totals and writes to daily_totals keyed by (order_date), using INSERT ... ON CONFLICT (order_date) DO UPDATE SET total = EXCLUDED.total. Running the job twice for the same date produces the same final row, not two rows: idempotent by construction. The equivalent streaming case: a real-time order-events consumer maintains a Redis set of event_ids it has already applied to the running total, with a 24-hour expiry (since redelivery beyond 24 hours is not expected from this particular message broker's retry configuration); on each event, it checks membership before applying and adding the write, so a redelivered event within the 24-hour window is a no-op rather than double-counted, while an event replayed after 24 hours (a rare, explicit backfill scenario) is treated as new and requires the operator to explicitly flag it as a backfill rather than have it silently double-count.
Trade-offs and pitfalls
The deduplication store in the streaming case is itself a piece of state that needs its own reliability guarantee: if it is lost or reset (a Redis instance restarting without persistence, for example), every subsequent "already seen" check will incorrectly return false, and previously-applied events can be double-counted on the next redelivery, which is a subtle failure mode that only manifests during an actual dedup-store outage, precisely the moment it's hardest to notice. The most common mistake in the batch case is choosing a filename or a upsert key that seems stable but actually incorporates something incidental to the run (a job execution ID, a timestamp with second-level precision) rather than something derived purely from the logical content being processed, which silently breaks idempotency the first time the job needs to be re-run for legitimate reasons like a backfill.
Describe a real situation where you accepted a more complex, less readable implementation for a genuine performance gain. How did you document the trade-off in the code itself so a future reader (including you) understands why the 'ugly' version is there on purpose?
Sample Answer
Direct answer. A real trade-off is documented not with a vague 'optimized for performance' comment but with the SPECIFIC evidence that justified it: the measured gain, the alternative that was rejected and why, and a pointer future maintainers can use to re-evaluate whether the trade-off still holds.
A representative situation
A hot-path function serializing millions of small messages per second was rewritten from a clear, idiomatic object-based approach to a manually packed byte-buffer format, because profiling showed serialization was consuming 30% of total CPU under peak load and the byte-buffer version cut that to 9%.
How I'd document the decision
def pack_message(msg: Message) -> bytes:
"""
Manually packs fields into a byte buffer instead of using the standard
(slower) serialization library.
WHY: profiling on 2026-06-01 (see PERF-118) showed the library-based
version consumed 30% of CPU at peak load (180k msgs/sec); this version
cuts that to 9%, confirmed via the benchmark in bench/serialize_bench.py.
IF YOU ARE ABOUT TO 'CLEAN THIS UP': re-run that benchmark first. If
peak load characteristics have changed enough that this optimization
no longer matters, prefer reverting to the clearer library-based version
below (kept in git history at commit abc123) over maintaining this.
"""
- The comment names the SPECIFIC evidence (the ticket, the measured numbers, the benchmark script) rather than asserting 'this is faster' unfalsifiably.
- It explicitly invites future re-evaluation rather than treating the optimization as permanent, which matters because the conditions that justified it (load characteristics, hardware, library performance) can change.
- A runnable benchmark in the repo means the claim is checkable by anyone, not just trusted on the original author's word.
Why this matters more than the code itself
The optimized code, by construction, is HARDER to read than the alternative -- the entire justification for accepting that cost lives in the documentation. Without it, a future engineer has no way to distinguish 'this is deliberately optimized, don't simplify it' from 'this is just badly written,' and the natural instinct (correctly, in the absence of context) is to clean up code that looks unnecessarily complex.
Trade-offs and pitfalls
- A comment claiming a performance benefit that's never re-verified becomes exactly the kind of unfalsifiable folklore that accumulates in old codebases ('don't touch this, it's for performance' with no evidence anyone can check) -- a runnable benchmark, not just prose, is what keeps the justification honest over time.
- Don't over-document routine, low-stakes optimizations with this much ceremony; reserve the full treatment for cases where the readability cost is real and the code will likely tempt a future 'cleanup.'
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.