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 is cyclomatic complexity, and what does it (and other maintainability metrics like duplication or coupling) actually tell you versus not tell you about a codebase's health? How would you use it to decide what to refactor next, without letting people game the number?
Sample Answer
Direct answer. Cyclomatic complexity counts the number of independent paths through a function (roughly, one plus the number of branch points) -- it tells you HOW MANY test cases you'd need for full path coverage and correlates loosely with how hard a function is to reason about, but it says nothing about whether the code is doing the RIGHT thing, how well-named it is, or how coupled it is to the rest of the system.
What it does and doesn't capture
- Captures: branching density. A function with cyclomatic complexity 25 genuinely has many distinct paths, which usually does correlate with 'hard to fully test and hard to hold in your head at once.'
- Doesn't capture: a function with complexity 3 that's still unreadable because of terrible names, or a function with complexity 15 that's actually a clean, flat switch/lookup table that's easy to scan even though the METRIC counts every case as a branch.
- Similarly, coupling and duplication metrics each capture ONE dimension of maintainability risk; none of them alone tells the whole story, and all three (plus test coverage) together give a much more honest signal than any single number.
Using it to prioritize refactoring
- Rank functions by complexity CROSSED WITH change frequency (git churn) -- a complex function nobody ever touches is lower priority than a moderately complex function that gets modified every sprint, since that's where the actual pain of hard-to-change code is felt.
- Use it as a TRIAGE signal, then read the actual flagged function before deciding it's worth refactoring -- a high score is a prompt to look, not a verdict to act on blindly.
Avoiding metric-gaming
- If complexity becomes a HARD CI gate ('no PR may exceed complexity N'), expect people to game it: splitting one honest 20-branch function into five 4-branch functions that are individually 'compliant' but collectively no easier to understand, sometimes harder because the logic is now scattered across five places.
- Prefer using the metric to inform PRIORITIZATION and code-review conversation ('this function's complexity jumped from 8 to 22 in this PR, can we talk about why') over using it as an automatic pass/fail gate that people learn to route around mechanically.
Trade-offs and pitfalls
- A codebase with genuinely complex, irreducible business logic (many real regulatory cases, say) will have functions with legitimately high complexity that reflect the DOMAIN'S complexity, not the code's quality -- don't treat every high score as a code smell without checking whether the complexity is inherent to the problem.
- Metrics decay in usefulness the moment they become a target rather than a signal (Goodhart's Law) -- pair any metric-based process with periodic human sampling of whether the numbers still correspond to real pain.
Design a small, composable validator library API for records that supports required and optional fields, type checks, custom predicates, and composition (and/or), with clear structured error reporting that includes field paths. Show example usage, discuss how you keep the implementation small, testable, and performant for streaming workloads, and include one concrete function built on top of it that validates a single record (required keys, a datetime-to-ISO8601 conversion, and a numeric field check) with no side effects.
Sample Answer
Direct answer
A composable validator library separates what to check (field presence, type, a custom predicate) from how checks combine (AND/OR composition), and reports failures as a list of {field path, error} entries rather than a single boolean, so a caller can act on every problem in a payload at once instead of iterating error-by-error.
Structured elaboration
Core API shape. A Field describes one thing to check: a name, whether it is required, an expected type, and an optional predicate for anything a type check cannot express (a numeric range, a regex, a cross-field relationship). A Validator holds a list of fields and, given a record, returns success or raises a ValidationError carrying every failure found, each tagged with its field path.
Composition (and/or). "AND" composition is the default: every field's rule must pass. "OR" composition is useful for polymorphic records, for example "either a user_id or a guest_token must be present": model this as a higher-order rule that wraps two field checks and passes if either one succeeds, rather than trying to force it into a single field's predicate.
Structured error reporting with field paths. For nested records, the path should read like items[2].price, not just price, so the caller (or a UI form) can point the end user at the exact offending value instead of a generic "validation failed" message.
Keeping it small, testable, and fast for streaming workloads. Each Field is a small, independently testable unit: you can unit test the email predicate without constructing a whole record schema. For streaming, the validator must avoid anything that scales with total dataset size (no accumulating state across records) so it can run per-record with constant memory, and predicates should be cheap (avoid a network call inside a predicate; that turns a streaming validator into the bottleneck of the pipeline).
Worked example
class ValidationError(Exception):
def __init__(self, errors):
self.errors = errors
super().__init__(str(errors))
class Field:
def __init__(self, name, required=True, type_=None, predicate=None):
self.name, self.required, self.type_, self.predicate = name, required, type_, predicate
class Validator:
def __init__(self, fields):
self.fields = fields
def validate(self, record: dict):
errors = []
for f in self.fields:
if f.name not in record:
if f.required:
errors.append({"path": f.name, "error": "missing_required_field"})
continue
value = record[f.name]
if f.type_ is not None and not isinstance(value, f.type_):
errors.append({"path": f.name, "error": f"expected_type_{f.type_.__name__}"})
continue
if f.predicate is not None and not f.predicate(value):
errors.append({"path": f.name, "error": "predicate_failed"})
if errors:
raise ValidationError(errors)
return True
Executed against Validator([Field("id", type_=int), Field("email", type_=str, predicate=lambda s: "@" in s), Field("age", required=False, type_=int, predicate=lambda n: n >= 0)]): a valid record {"id": 1, "email": "a@b.com"} returns True. An invalid record {"id": "not-int", "email": "bad"} raises ValidationError with exactly [{"path": "id", "error": "expected_type_int"}, {"path": "email", "error": "predicate_failed"}], both problems reported at once rather than stopping at the first.
On top of this, a single-record convenience function for a streaming pipeline: validate_record({"id": ..., "timestamp": ..., "value": ...}) checks the three required keys, converts a datetime timestamp to an ISO8601 string, coerces an int value to float while rejecting bools and non-numeric types, and returns a new dict without mutating its input, which matters because streaming frameworks often reuse or replay the same record object.
Trade-offs and pitfalls
A fully general rule engine (arbitrary boolean expression trees over predicates) is tempting but usually overkill: most validation needs are AND-of-fields plus the occasional cross-field OR, and a general engine adds complexity that has to be tested and maintained for a case that rarely comes up. The most common real bug in hand-rolled validators like this is accidentally mutating the input record while "normalizing" it; the worked example above returns a new dict specifically to avoid that. For streaming specifically, resist the temptation to add a predicate that does I/O (a database lookup to check foreign-key validity): that turns a should-be-cheap per-record check into the throughput bottleneck of the whole pipeline.
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 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.
How would you structure a complex analytical SQL query to maximize readability and maintainability? Rewrite a hard-to-follow query using CTEs, descriptive aliases, and modular views, and explain what made the original version hard to follow.
Sample Answer
Direct answer. Break the query into named, single-purpose stages using CTEs (WITH clauses), give each stage a name that states what it computes, and use descriptive aliases throughout -- so a reader can follow the query top-to-bottom as a sequence of clearly-labeled steps instead of untangling nested joins and aggregations in one dense block.
Before (hard to follow)
SELECT c.region, SUM(o.amount) / COUNT(DISTINCT o.customer_id)
FROM orders o JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'completed' AND o.order_date >= '2026-01-01'
GROUP BY c.region HAVING SUM(o.amount) > 50;
A reader has to mentally track what the un-named SUM(o.amount) / COUNT(DISTINCT o.customer_id) expression MEANS, that the HAVING clause is filtering on a DIFFERENT (unaliased) aggregate than the one being selected, and hold the whole join/filter/aggregate/filter pipeline in their head simultaneously.
After (CTEs, named steps)
WITH completed_orders_this_period AS (
SELECT customer_id, amount
FROM orders
WHERE status = 'completed' AND order_date >= '2026-01-01'
),
revenue_by_customer AS (
SELECT customer_id, SUM(amount) AS total_revenue
FROM completed_orders_this_period
GROUP BY customer_id
),
revenue_by_region AS (
SELECT c.region,
SUM(rc.total_revenue) AS region_revenue,
COUNT(DISTINCT rc.customer_id) AS paying_customers,
SUM(rc.total_revenue) / COUNT(DISTINCT rc.customer_id) AS avg_revenue_per_customer
FROM revenue_by_customer rc
JOIN customers c ON c.customer_id = rc.customer_id
GROUP BY c.region
)
SELECT region, avg_revenue_per_customer
FROM revenue_by_region
WHERE region_revenue > 50
ORDER BY region;
Verified: both versions return identical results (EU|200.0, US|90.0) against the same test data -- the CTE version is purely a readability refactor, not a behavior change.
Why this is easier to follow
Each CTE name IS documentation: completed_orders_this_period tells you the filter scope without reading the WHERE clause; revenue_by_customer tells you the grain of the next step (per customer, not per order). The final HAVING-equivalent filter is now an explicit WHERE region_revenue > 50 referencing a NAMED column, rather than a repeated, unaliased aggregate expression a reader has to recognize is 'the same thing computed twice.'
Trade-offs and pitfalls
- Breaking a query into many small CTEs can, in some database engines, affect the query planner's optimization choices (materialization behavior varies by engine/version) -- verify performance on realistic data volumes after this kind of refactor, especially on large tables, since readability and performance aren't always aligned.
- Over-fragmenting into CTEs that are each trivially small (a single
SELECT *) can add MORE noise than it removes -- aim for each CTE to represent one genuine conceptual STEP in the computation, not an arbitrary line-count-driven split.
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.