Code Quality, Error Handling, and Defensive Programming Questions
Writing robust, high-quality code that fails safely. Covers defensive programming, input validation, error handling and fault tolerance, logging for diagnosability, and general engineering-quality standards. Includes anticipating failure modes and making code resilient to bad inputs and unexpected states.
Describe the role of assertions and invariants in maintaining code correctness. When should assertions be used versus throwing exceptions? Provide an example where an assertion detects a developer error early and avoids a costly runtime check in production, and describe how this maps to design-by-contract thinking (preconditions, postconditions, invariants).
Sample Answer
Direct answer
An assertion checks a condition that your own code's logic guarantees should always be true if nothing upstream has a bug; an exception handles a condition that the outside world (a user, a file system, a network) can legitimately produce regardless of whether your code is correct. Assertions catch developer errors early and cheaply; exceptions handle the world being unpredictable.
Structured elaboration
What an assertion is for. An assertion encodes an invariant: a statement that, given correct code, must hold at this point in the program no matter what valid input arrives. If it fails, the bug is in the code that led to this point, not in the input or the environment. Because of this, assertions are cheap to reason about (you never need a recovery path for them, the correct response to a failed assertion is to fix the bug) and, in several languages, can be compiled out entirely in optimized production builds, which is precisely why they must never be relied on for something the outside world can trigger.
What an exception is for. An exception handles a condition that is a normal, expected possibility given a correct program: a file that does not exist, a network call that times out, a user who submits invalid input. These require an actual recovery path (retry, a default, informing the user) because they will happen in production no matter how correct the code is.
Preconditions, postconditions, and invariants (design by contract). A precondition is what a function requires to be true of its inputs to behave correctly; a postcondition is what it guarantees to be true of its output if the precondition held; an invariant is a condition that must hold at every observable point in an object's lifetime. Assertions are the natural implementation mechanism for all three inside a single codebase's own internal logic: asserting a precondition at function entry catches a caller who violated the contract due to a bug in their own code, which is different from validating an input that arrived from outside the trust boundary and might be malformed for entirely legitimate reasons.
The line between the two. The test is not "is this input bad", it's "could this input be bad even if every line of my own code is correct". A negative array length passed internally between two functions you wrote and control, where nothing external can produce that value if your code is right, is an assertion case. A negative quantity field parsed from a JSON request body is an exception (or validation-error) case, because a malicious or buggy client can produce it no matter how correct your server code is.
Worked example
A function withdraw(account, amount) internal to a ledger system might assert assert account.balance >= 0, "invariant violated: account balance went negative" right after debiting, because if the debit logic is correct, the balance should never go negative; if this assertion fires, there's a bug in the debit logic itself, and the fix is to find that bug, not to add a check that reacts gracefully to a negative balance in production. Contrast this with the same function's very first line, which must instead RAISE an exception (not assert) if the caller passes a negative amount: a negative withdrawal amount is exactly the kind of thing an upstream caller (a request handler parsing user input) can produce, whether or not the ledger's own internal logic has any bugs at all, so it needs a real, always-active check, and the right response is to reject the request, not to crash a debug build's assertion and silently no-op in production.
Trade-offs and pitfalls
The most costly mistake is using an assertion to validate something a real caller can trigger: because assertions can be disabled in optimized builds in several languages (C's NDEBUG, Python's -O flag), a check that only exists as an assertion can silently vanish in production, meaning the exact case it was meant to guard against reaches production code entirely unchecked. The opposite mistake, wrapping every internal invariant in a full exception with a try/catch elsewhere in the codebase, adds real performance and readability cost for something that, if your own code is correct, should genuinely never happen and does not need a recovery path at all, only a way to fail loudly and immediately during development and testing.
Design a small set of custom static-analysis checks to detect common defensive-programming anti-patterns that make debugging harder, for example swallowing exceptions, broad try/catch blocks, and empty catch blocks. For each check, explain how you would implement it and give a sample warning message.
Sample Answer
Direct answer
A custom static-analysis check for defensive-programming anti-patterns works by pattern-matching the SHAPE of a code construct in an abstract syntax tree, not by textual pattern matching, and each check should target one specific, well-defined anti-pattern (an empty catch block, a catch that only logs and swallows, a catch of the broadest possible exception type) with a clear, actionable warning message rather than trying to build one general-purpose "bad error handling" detector.
Structured elaboration
Empty catch blocks. The simplest and highest-confidence check: find any catch/except block whose body is empty, or contains only a comment, or contains only a pass/no-op statement. This is almost never intentional and almost always indicates an error that was silently discarded during debugging and never properly handled afterward. Implementation: walk the AST for exception-handler nodes and check whether the handler's body block contains zero meaningful statements.
Broad exception catching. Flag a catch clause that catches the broadest possible exception type (except Exception, catch (Exception e), a bare except: with no type at all) UNLESS it is the outermost handler in an entry point specifically responsible for preventing a whole process crash (a top-level request handler, a main loop), which is a legitimate and common exception to the rule; implementation-wise, this means the check needs a configurable allowlist of file paths or function names where broad catching is intentional, rather than flagging every instance uniformly.
Catch-log-swallow. A subtler variant of the empty-catch problem: a catch block that logs the exception (so it isn't literally silent) but then continues execution as if nothing happened, without either re-raising, returning an error indicator to the caller, or taking an explicit recovery action. This requires slightly more sophisticated analysis than the empty-catch case: the check looks for a catch block whose only statements are a logging call, with no re-raise, no return of an error value, and no explicit recovery logic.
Sample warning messages. For an empty catch block: "Empty catch block silently discards <ExceptionType>. Either handle it explicitly or, if this really is intentional, add a comment explaining why swallowing this specific exception is safe here." For overly-broad catching: "Catching the base Exception type here will also catch bugs unrelated to what this block is meant to handle (a NullPointerException from an unrelated coding mistake, for instance). Catch the specific exception type(s) you actually expect from this call."
Integrating into developer workflow without excessive noise. Run as a fast, incremental check on changed files in CI (not a full-codebase scan on every commit, which would be slow and would re-flag pre-existing violations that aren't part of this change), and provide a narrow, explicit suppression mechanism (an inline comment like # noqa: empty-except with a required justification) for the genuine, rare cases where the pattern is intentional, rather than either blocking every legitimate exception or having no escape hatch at all.
Worked example
A simplified AST-based check for empty catch blocks, sketched in Python-like pseudocode operating over a parsed syntax tree:
def check_empty_except(tree):
violations = []
for node in tree.walk():
if node.type == "except_handler":
body_statements = [s for s in node.body if s.type not in ("comment", "pass")]
if len(body_statements) == 0:
violations.append({
"line": node.line,
"message": f"Empty except block for {node.exception_type or 'bare except'}; "
f"handle it explicitly or document why swallowing it is safe."
})
return violations
Applied to try: risky_call()\nexcept Exception:\n pass, this flags line 2 with a message naming the caught type and asking for either real handling or an explicit, documented justification, rather than a generic "bad code" warning that gives the developer no specific next step.
Trade-offs and pitfalls
A check that flags EVERY broad exception catch with no allowlist mechanism will immediately generate noisy, unwanted warnings on legitimate top-level handlers (a web framework's outermost request handler, which SHOULD catch broadly to prevent one request's unexpected error from crashing the whole server process), and a team that gets flooded with warnings on code that's actually correct will quickly learn to ignore the tool entirely, defeating its purpose; a configurable allowlist for known-legitimate broad-catch locations is what keeps the signal-to-noise ratio high enough that the warnings are still trusted. The most common implementation mistake is building this as a simple TEXT-based regex search over source files rather than an AST-based check: a regex looking for except Exception will miss a semantically-identical broad catch written slightly differently (a caught type stored in a variable, or a multi-line catch clause), and will also produce false positives on the string appearing inside a comment or a string literal that has nothing to do with actual exception-handling code.
Production just exhausted its error budget due to cascading 5xx errors triggered by a downstream change, and you must ship defensive changes quickly to prevent a repeat. Which mitigations do you prioritize first and why: request timeouts, retries with backoff and jitter, circuit breakers, bulkheads/isolated thread pools, backpressure, or graceful degradation? Explain how you would measure whether each change is actually working.
Sample Answer
Direct answer
Under active error-budget exhaustion from cascading 5xx errors, prioritize the mitigation that stops the cascade fastest with the least new risk: circuit breakers and request timeouts first (they cut the feedback loop immediately and are usually already-tested code paths), then backpressure/bulkheads to protect what's left, with retries-with-backoff and graceful degradation as the follow-up once the bleeding has stopped, not the first move.
Structured elaboration
- Circuit breakers, first: if a downstream change is causing cascading failures, the fastest way to stop the cascade is to stop CALLING the failing dependency; a circuit breaker (or a manual, config-driven kill switch if none exists yet for this path) halts the cascade immediately, faster than any code change can ship.
- Timeouts, immediately after: if calls to the failing dependency are hanging rather than failing fast, tightening the timeout (even a temporary, aggressive config change) frees up resources (threads, connections) being held hostage, which is often what's actually driving the cascade beyond the original failing dependency.
- Bulkheads/isolated thread pools: if the resource exhaustion has already spread to starve unrelated requests (see the bulkhead survivor), isolating pools limits further blast radius, though retrofitting a bulkhead mid-incident is a bigger, riskier change than flipping an existing breaker or timeout config.
- Retries with backoff and jitter: valuable for RECOVERY once the dependency is coming back, but retries added or left ENABLED during the active cascade make it worse, not better, by adding more load onto an already-struggling dependency; this is often the first thing to actively DISABLE, not add, during the incident.
- Backpressure: shedding load at the edge (rejecting a percentage of incoming requests outright, with a clear 503) protects the system's remaining capacity for the traffic it CAN serve, at the direct, visible cost of intentionally failing some requests.
- Graceful degradation: the longer-term fix (serve a fallback/cached response instead of failing) usually requires a code change that can't ship instantly during an active incident, so it's the follow-up hardening work, not the immediate mitigation.
- Measuring effectiveness: track the downstream dependency's own error rate and the error BUDGET burn rate in real time as each mitigation is applied; a mitigation is working if the burn rate visibly slows within minutes, not hours.
Worked example
During the incident: (1) immediately flip the circuit breaker for the failing downstream to force-open (or disable retries against it if no breaker exists) to stop the cascade; (2) tighten the client timeout for that dependency from 30s to 2s to stop threads from being held hostage; (3) if capacity is still degraded, enable load shedding (reject 20% of lowest-priority traffic) to protect the rest; (4) once the dependency confirms recovery, re-enable the breaker and retries gradually, watching the error rate as you do, rather than flipping everything back on at once.
Trade-offs and pitfalls
The instinctive first move during many outages is to add MORE retries ('the requests are failing, let's retry them harder'), which is exactly backwards during a cascading-failure incident: more retries onto an already-overloaded dependency deepens the cascade. The discipline that prevents this: stop calling the failing thing first, THEN worry about graceful recovery.
Explain patterns for handling missing or null values in strongly-typed languages like Java and dynamically-typed languages like Python or JavaScript. Include examples of Option/Maybe-style types, exceptions, and sentinel values, and explain when you would use an assertion compared to throwing a recoverable error.
Sample Answer
Direct answer
Strongly-typed languages let you make "this can be absent" part of the type itself (an Optional, Maybe, or nullable type), which forces every caller to handle the absent case at compile time; dynamically-typed languages have no such enforcement, so the same discipline has to be applied by convention, through explicit checks, sentinel values, or exceptions, and it is far easier to forget.
Structured elaboration
Option/Maybe types (Java's Optional<T>, Kotlin's T?, Rust's Option<T>). These make "might not have a value" visible in the type signature itself. A function returning Optional<User> cannot be called and have its result used as a User without the caller explicitly unwrapping it (via .get(), .orElse(default), or a null check), so the compiler catches the case where a developer forgot that the value might be absent.
Sentinel values. A special value from within the same type used to mean "nothing" (returning -1 for an index not found, or an empty string). These predate Optional types and are still common, particularly in older or lower-level codebases, but they are a real hazard: a sentinel is indistinguishable from a legitimate value of the same type unless every caller remembers to check for it, and nothing enforces that they do. indexOf returning -1 is the classic example: if a caller forgets to check and uses the result directly as an array index, it silently wraps or throws far from the actual bug.
Exceptions. Appropriate when absence represents an actual error condition the caller must react to (a required config value is missing), not merely a normal possible outcome (a user has no middle name). Throwing for something that is a completely normal case forces every caller into try/catch for ordinary control flow, which is a sign the wrong tool was chosen.
Dynamically-typed languages (Python, JavaScript). There is no compiler to force a null check, so None/null/undefined handling depends entirely on discipline: explicit is not None checks at the boundary where a value enters the system, defensive defaults (value = data.get("key", default)), and, where the codebase uses type hints, tools like mypy can catch some cases statically even though the language itself does not enforce them at runtime.
Assertions versus recoverable errors. An assertion says "this should be logically impossible given my own code's invariants; if it happens, my code has a bug", and is appropriate for catching a developer error early (an internal invariant that should never be violated if the code upstream is correct). A recoverable error (an exception or an Optional/error-result) is for a condition that is possible in the outside world regardless of whether the code is correct (a user did not provide their middle name; a file does not exist). Do not use an assertion for something a real caller can legitimately trigger, since assertions can be stripped in optimized production builds in several languages and are not guaranteed to run.
Worked example
A Java method Optional<User> findById(String id) forces every caller to write findById(id).map(User::getName).orElse("unknown") or similar, and the compiler will not let a caller treat the return value as a bare User. The equivalent Python function find_by_id(user_id) might return None on a miss, and nothing stops a caller from writing find_by_id(user_id).name and getting an AttributeError: 'NoneType' object has no attribute 'name' at runtime, potentially in a code path that only executes rarely, long after the function was written and long after the original author has moved to another project.
Trade-offs and pitfalls
Overuse of Optional/Maybe wrapping for values that are realistically always present adds ceremony without benefit; reserve it for genuinely-optional data. The most damaging mistake in dynamically-typed languages specifically is treating None/null handling as optional discipline rather than a hard rule at every boundary where external data enters the system (an API response, a database read, a config file): that is precisely where a missing null-check turns into a production incident, because it is exactly the boundary where the type system (if any) has the least information about what's actually there.
Implement a React ErrorBoundary component that logs errors to a provided logger (for example, an error-tracking service like Sentry) and displays a localized fallback UI when a child component throws during render. Then write a React Testing Library test that asserts the logger was called and that the fallback text is rendered. Explain what an ErrorBoundary will and will not catch, and discuss the trade-off between showing a retry UI and surfacing the raw error to the user.
Sample Answer
Direct answer
A React error boundary catches rendering errors thrown by any component in its subtree during render, in lifecycle methods, and in constructors, logs the error with enough context to diagnose it, and shows a fallback UI instead of leaving the user with a blank screen or React's own default error overlay; it does not catch errors in event handlers, asynchronous code, or errors thrown in the boundary component itself.
Structured elaboration
What it catches. Errors thrown during the render phase of any component below the boundary in the tree, including errors in lifecycle methods (componentDidMount, etc.) and in constructors. This is React's mechanism for preventing one broken component from crashing the entire application.
What it does NOT catch, and why that matters. Event handlers (a click handler that throws is a normal JavaScript exception, not a React rendering error, and needs its own try/catch); asynchronous code (a .then() callback or an async function's rejection happens outside React's render cycle entirely); server-side rendering errors; and errors thrown by the error boundary component itself (a boundary cannot catch its own failures, which is why the boundary component should be kept as simple as possible, with minimal logic that could itself throw).
Logging to an error-tracking service. componentDidCatch(error, info) receives both the error object and a componentStack describing which component tree led to the failure; sending both to a service like Sentry, tagged with any available user or session context, turns "a customer reported a blank page" into "we can see exactly which component threw, with what stack, for which user" without waiting for the customer to describe what they were doing.
Retry UI versus surfacing the raw error. A "try again" button that resets the boundary's state and re-attempts rendering the subtree is appropriate when the failure might be transient (a component that failed because of a momentary bad prop from a slow API response); it is misleading for a deterministic bug that will fail identically on every retry, where a generic "something went wrong, we've been notified" message (with no false promise that retrying will help) is more honest to the user, even though it is less satisfying than a button that appears to offer control.
Worked example
class ErrorBoundary extends React.Component {
constructor(props) { super(props); this.state = { hasError: false }; }
static getDerivedStateFromError(error) { return { hasError: true }; }
componentDidCatch(error, info) {
if (this.props.logger) this.props.logger.logError(error, info.componentStack);
}
render() {
if (this.state.hasError) {
return <div role="alert">{this.props.fallbackText || 'Something went wrong.'}</div>;
}
return this.props.children;
}
}
Executed (React Testing Library, verified): rendering <ErrorBoundary logger={logger} fallbackText="We hit a snag. Please retry."><Boom /></ErrorBoundary>, where Boom throws during render, confirms screen.getByRole('alert') shows the fallback text and logger.logError was called exactly once with the thrown Error object. A second test confirms that when no child throws, the boundary renders its children normally and logger.logError is never called, so the boundary is confirmed to be transparent in the non-error case, not just functional in the error case.
Trade-offs and pitfalls
A single application-wide error boundary at the root catches everything but takes down the ENTIRE page for a failure in one small, non-critical widget; placing boundaries around individual independent sections (a sidebar widget, a comments section) means one broken component degrades gracefully to just that section showing a fallback, while the rest of the page keeps working, which is almost always the better default for anything with multiple independent sections. The most common mistake is assuming an error boundary catches an async data-fetching failure inside a useEffect: it does not, since that error occurs outside the render phase entirely, and needs its own explicit error state managed by the component, separate from the boundary mechanism.
Unlock Full Question Bank
Get access to all 12 Code Quality, Error Handling, and Defensive Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.