cswork / libraryOpen practice workspace

Articles

SOLID Interview Questions: Refactor Broken Contracts Without Overengineering

Practise SOLID interview questions with an export-service refactor. Test substitution, capability boundaries, dependency inversion, and minimal abstractions.

SOLID interview questions are most useful when they start with a failing client, not five definitions. A strong answer identifies an observable promise, demonstrates how a change breaks it, and introduces the smallest boundary that protects it. An elaborate diagram without a working contract is not evidence of better design.

This article follows one original export service through read-only storage, a second output format, and a different delivery mechanism. Use Java and Object-Oriented Design Fundamentals to rehearse the vocabulary, then return to the exercise and justify every new interface with a specific client or change.

Start with the contract that the client can observe

Suppose an application exports account records. Its current implementation queries a database, formats CSV, writes a local file, sends an email, and returns a path. Asked to “apply SOLID,” a candidate immediately creates five interfaces and several factories. The interviewer then asks whether an export from a read-only snapshot will work. The diagram does not answer that question.

First write the caller's expectations: the selected account's records are read once; the bytes use the requested format; delivery occurs once; and a successful return identifies the delivered artifact. State the failure contract too. A failed read must not send an empty success email. A failed delivery must not be reported as success. None of these promises requires a particular class hierarchy.

These are exercise requirements, not universal export semantics. A different product might allow partial files or asynchronous delivery. Clarifying that difference is part of the interview. Ask whether retries must be idempotent, whether records fit in memory, and whether the caller needs bytes, a URL, or a job identifier before choosing a design.

Microsoft's architectural principles discuss separation of concerns and dependency inversion. Treat those principles as tools for locating boundaries. They do not establish that every method needs an interface or that dependency injection automatically produces a coherent contract.

Which SOLID principle explains the actual failure?

The five principles describe different questions. Single Responsibility asks which independently changing policy a component owns. Open/Closed asks which established extension axis should accommodate a new case without rewriting stable behavior. Liskov Substitution asks whether a replacement preserves what callers may rely on. Interface Segregation asks whether a client must depend on capabilities it does not use. Dependency Inversion asks whether high-level policy depends on implementation details rather than a suitable abstraction.

Robert Martin's Single Responsibility explanation ties reasons for change to the people or actors requesting changes. For the export service, a compliance change to exported fields and an operations change to email delivery may be separate reasons. “The class has three methods” does not establish three responsibilities.

The foundational Liskov–Wing paper concerns behavioral subtyping. In interview language, check accepted inputs, promised outputs, invariants, and permitted failures—not merely whether the subclass compiles. A replacement should not demand stronger prerequisites or provide a weaker guarantee than the advertised contract.

Avoid forcing every defect into exactly one acronym. An oversized repository interface can create both interface-segregation friction and a substitution failure. Name the concrete symptom first, then explain which principle helps you repair it. That ordering makes your answer defensible when the interviewer changes the example.

Repair the read-only repository without pretending it can write

Imagine a repository abstraction that exposes read(account) and save(records). A snapshot implementation can read but raises “unsupported operation” whenever save is called. Is that automatically a Liskov violation? Only if the abstraction promised that valid saves would work. If the documented base contract explicitly allowed rejection, throwing alone does not prove a violation—although the abstraction may still be inconvenient.

For this exercise, the existing contract promises successful saving for valid records. The snapshot therefore cannot honestly implement that entire capability. Do not fix it by silently discarding writes or by teaching every caller to inspect the repository's concrete type. Both approaches move an unreliable promise into client code.

Instead, make the export policy depend only on reading. A separate importer can depend on writing. The snapshot now satisfies the smaller contract without pretending to be writable. You have changed a capability boundary, not invented a parallel hierarchy for every storage vendor.

The following executable Python example is a compact contract fixture, not a production exporter. Callables keep the relevant behavior visible; a language with interfaces can express the same three dependencies explicitly.

import csv
import io
import json

def export(account, read, encode, deliver):
    records = read(account)
    payload = encode(records)
    return deliver(payload)

def csv_bytes(records):
    out = io.StringIO(newline="")
    writer = csv.writer(out)
    writer.writerow(["id", "name"])
    writer.writerows((r["id"], r["name"]) for r in records)
    return out.getvalue().encode("utf-8")

def json_bytes(records):
    return json.dumps(records, sort_keys=True).encode("utf-8")

events = []
def snapshot_read(account):
    events.append(("read", account))
    return [{"id": 7, "name": "A, B"}]

def capture(payload):
    events.append(("deliver", payload))
    return "artifact:1"

assert export("acct", snapshot_read, csv_bytes, capture) == "artifact:1"
assert events[0] == ("read", "acct") and len(events) == 2
rows = list(csv.reader(io.StringIO(events[1][1].decode())))
assert rows == [["id", "name"], ["7", "A, B"]]
events.clear()
export("acct", snapshot_read, json_bytes, capture)
assert json.loads(events[1][1]) == [{"id": 7, "name": "A, B"}]
events.clear()
def failed_read(account):
    raise RuntimeError("storage unavailable")
try:
    export("acct", failed_read, csv_bytes, capture)
except RuntimeError:
    pass
else:
    raise AssertionError("failure was swallowed")
assert events == []

The comma inside the name matters: replacing a real CSV encoder with string concatenation would produce a superficially plausible but invalid result. Contract tests should inspect the artifact, not only count calls. The failed-read assertion also checks sequencing: delivery does not happen after an upstream failure.

Extend the proven axis, not every imagined future

Now the interviewer requests JSON. The export orchestration already accepts an encoder, so the new format does not require editing reading or delivery behavior. That is an Open/Closed argument for the known formatting axis. It is not proof that the service is closed against every possible change.

A format selection branch at the application boundary can remain perfectly reasonable. With two formats, a small map from format name to encoder is easier to explain than a factory registry, plugin loader, and discovery protocol. Add those mechanisms only when requirements such as independently installed extensions justify their cost.

Next replace email with object storage. The delivery function returns the same kind of artifact identifier promised to the caller. If object storage returns a URL while email returns nothing, resolve the mismatch explicitly: either normalize the result or change the public contract. Calling both methods deliver does not make their semantics equivalent.

Dependency inversion appears when orchestration depends on reading, encoding, and delivery contracts rather than constructing a particular database driver or mail client. Dependency injection is the assembly technique that supplies those collaborators. A container can inject a poorly designed concrete dependency; inversion is not guaranteed by annotation syntax.

For larger systems, place the policy-facing abstraction where the policy can define the behavior it needs. An adapter translates that contract into a vendor API. This avoids importing a provider's response objects and exception taxonomy into every caller. Still, translation costs exist; do not add an adapter merely to rename a stable function.

Use a change-impact ledger to defend the refactor

This ledger belongs to the original export problem. It makes your trade-offs inspectable instead of rewarding the number of classes drawn.

Requested changeSmallest justified boundaryEvidence that the repair works
Read from a snapshotReader capability, independent of writingExport succeeds without any save method or type check.
Add JSONEncoder supplied to orchestrationParsed JSON contains the same records; reader and delivery tests remain unchanged.
Deliver to object storageDelivery contract with an explicit resultThe returned identifier follows the caller's documented success semantics.
Enforce tenant accessApplication authorization before readingAn unauthorized request performs no read or delivery.
Support very large exportsStreaming contract, if requiredBounded-memory behavior and resource cleanup are measured separately.

The last two rows are deliberately not “solved” by the small fixture. SOLID does not authorize a tenant or make an in-memory list scalable. Explain that limitation before presenting the code as production-ready. An honest boundary is stronger than claiming that dependency injection fixes security, transactions, and memory usage simultaneously.

Keep a migration path visible as well. Wrap the existing exporter behind the new contract, verify equivalent artifacts for representative inputs, then replace one collaborator at a time. A simultaneous rewrite of storage, formatting, and delivery makes regressions harder to attribute. State which old behavior is preserved and which behavior intentionally changes before comparing the results.

When an interviewer asks whether you have overengineered the solution, identify what you chose not to build. There is no dynamic plugin registration, generalized workflow engine, or repository method unused by the exporter. Each extracted boundary corresponds to an observed change and has a testable promise.

Answer follow-ups with counterexamples

“Does a single responsibility mean one public method?” No. Several methods can serve one coherent policy; one method can contain unrelated policies. Use the compliance-versus-delivery example rather than argue from line count.

“Should all if statements disappear?” No. Some decision must select a format. The useful question is whether selection is localized and whether adding a format forces unrelated stable components to change. Moving the branch into a factory without improving that property is cosmetic.

“Can every dependency be mocked?” Many can, but mocks are not the objective. A fake that always returns valid CSV cannot reveal incorrect escaping. Combine orchestration tests with encoder assertions and adapter integration tests. Decide which failure belongs at which boundary so that a passing mock suite does not hide a broken artifact.

“What happens if delivery times out after accepting the file?” The fixture cannot know whether the side effect occurred. Introduce a delivery identifier and an idempotency policy only if retries are required. Discuss reconciliation or a status lookup rather than blindly repeating a possibly successful send.

“Would inheritance be simpler?” It can be appropriate when variants genuinely preserve a common behavioral contract. Here, composing an encoder and a delivery function avoids multiplying subclasses for each format–transport combination. Explain that product space before claiming composition is universally superior.

Sources and Further Reading