skip to content

Which layer should translate a database driver's error in a nightly report generator with a storage adapter, a service layer and a CLI?

level: seniorimportance: should knowfreq 45%

answer

  1. Ask which module imports the driver
  2. The layer above should not need that import
  3. One type per distinct caller reaction
  4. Too deep loses meaning, too high loses containment
  5. Top-level handler only sets the exit status

basics

~20 s

The storage adapter - the only module that imports the driver. It maps driver failures onto domain error types; the service layer handles those types without knowing the driver exists, and the CLI turns whatever escapes into a message and an exit status.

solid answer

~50 s

Translate in the module that owns the dependency: the adapter that imports the driver is the only place with the knowledge to say which failure means "no report for that date" and which means "the store is unreachable", and it is the boundary you would rewrite if the driver changed. The service layer above it should be able to run without importing the driver at all; it catches domain types and decides what the nightly run does about them. The CLI catches whatever reached the top, prints one operator-facing message and sets an exit status. Translating too early - inside a generic helper wrapped around every call - loses the distinctions, because at that depth every failure looks alike. Translating in more than one layer produces wrappers around wrappers whose useful message is several `__cause__` links down.

code

python · 33 lines
python
import sqlite3


class ReportStorageError(Exception):
    pass


class ReportNotFound(ReportStorageError):
    pass


def read_report(conn, name):
    try:
        row = conn.execute("SELECT body FROM reports WHERE name = ?", (name,)).fetchone()
    except sqlite3.DatabaseError as exc:
        raise ReportStorageError("report store is unavailable") from exc
    if row is None:
        raise ReportNotFound(name)
    return row[0]


def build_nightly_summary(conn, name):
    try:
        return read_report(conn, name).upper()
    except ReportNotFound:
        return "NO DATA"


conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE reports (name TEXT, body TEXT)")
conn.execute("INSERT INTO reports VALUES ('mon', 'ok')")
print(build_nightly_summary(conn, "mon"))
print(build_nightly_summary(conn, "tue"))

go deeper

for a junior

Learn the direction of the rule: the module that imports the library is the module that catches its errors. The layers above should only ever see your own exception types.

for a middle

Be able to explain why a single try/except at the top of the run is not enough, and why translating deep inside a per-call helper loses the information that distinguishes missing data from a broken store.

for a senior

Demonstrate the mapping judgement in a real run: how many domain types the caller's reactions justify, how the original stays on __cause__ for the operator, and how you keep the driver import from leaking upward.

for a principal

Own where the boundaries sit across the system and who pays to maintain each mapping, including how far an internal error chain may travel toward an external consumer and what the top-level handler is contractually required to do.

## The rule: translate where the dependency is owned In a nightly report generator with three layers - a storage adapter over a database driver, a service layer that assembles the report, and a CLI entry point run by a scheduler - the choice of catching layer is not a matter of taste. The adapter imports the driver. It is therefore the only module that can name the driver's exception classes, the only one that knows what each of them means in this application, and the only one you would rewrite if the driver were replaced. That makes it the translating layer. The consequence to state out loud in an interview: after translation, **the service layer should be able to run with the driver uninstalled at import time.** If it needs the driver's module in its own imports just to write an `except` clause, the boundary is not real. ## What the adapter actually does A good adapter does three things at once. **It catches the driver's base error class.** Naming one leaf type means the next failure mode escapes raw. Catching the driver's base error keeps the boundary closed even for failures you have not seen yet. **It maps conditions onto domain types the caller can act on.** "Yesterday's report row does not exist" and "the store cannot be reached" call for different reactions from the nightly run - substitute an empty section versus abort and alert - so they deserve different types. Failures nobody reacts to differently share one type. The count of domain exceptions is driven by the number of distinct caller reactions, never by the number of error classes the driver defines. **It keeps the original.** `raise ReportStorageError(...) from exc` leaves the driver's message on `__cause__`, so the operator reading tomorrow's log still sees "database is locked" underneath your sentence. Note also that a missing row is usually not an exception from the driver at all - a query simply returns no rows - so the adapter raises the domain error itself. Translating is not only about catching; it is about presenting one consistent error vocabulary for a resource, whatever shape the underlying failure arrived in. ## Why not the service layer Catching in the service layer requires it to import the driver, which is the dependency you were trying to contain. It also puts the mapping decision in a module that lacks the context to make it: by the time a failure has crossed the adapter it has usually lost the query, the table and the connection state that made it interpretable. ## Why not only the top A single handler at the CLI is necessary but not sufficient. It is the right place for the last-resort behaviour - print one line, exit non-zero, so the scheduler notices - but a top-level handler cannot make the report degrade gracefully. Deciding to emit the report without last night's rows is a decision only the service layer can take, and it can only take it if it received a type specific enough to distinguish "missing" from "broken". ## Translating too early The opposite failure is a helper that wraps every driver call in a `try` and raises one generic error. It looks like good hygiene and destroys exactly the information the layers above need: at that depth the code does not know whether it was reading an optional section or the report's primary key. Push the translation out to where the operation has a name and a meaning. ## Translating twice Wrapping again on the way up gives you `ReportError` caused by `StorageError` caused by the driver error - three sentences, one fact. Reserve a second translation for a genuine second boundary: a queue consumer or a public API that must not expose internal types either. Inside one process, once is right. ## The state trap in a translation helper A related bug worth recognising, because it survives review easily: a shared translation or error-collecting helper that keeps state across calls. A collector written as `def collect(exc, seen=[])` binds that list once at function-definition time, so in a long-lived process every nightly run appends to the same list - Monday's failures reappear in Tuesday's report and memory grows without bound. The same applies to a domain exception whose `__init__` takes `details={}` as a default and mutates it. Translation code should be stateless: build a fresh container per call and pass it explicitly. ## What to say when asked Name the layer, then give the test that makes it checkable: which module imports the driver, and could the layer above it run without that import? Then describe the mapping as a function of caller reactions, and mention that the original stays on `__cause__` so operability is not the price of the abstraction.

  • The nightly run must continue when one section's data is missing but abort when the store is unreachable. How does that shape your exception types?
    It gives you exactly two types, because there are exactly two reactions. The adapter raises a not-found type when a query returns no rows and a store-unavailable type when the driver errors, both under one shared base so a caller that does not care can catch the base. Anything the service layer would treat identically stays merged; inventing a third type nobody branches on only adds a class to maintain.
  • A colleague adds an error-collecting helper with a mutable default argument to gather translated failures. What goes wrong?
    The default container is created once when the function is defined, so it is shared by every call for the life of the process. In a long-running generator each night's failures pile onto the previous night's list: the report shows stale errors and the list grows without bound. Take `None` as the default and build a fresh list inside, or have the caller pass the collection in explicitly.
  • How do you stop the driver's import from spreading back into the service layer over time?
    Make it mechanical rather than cultural. Keep the driver import in one adapter package, and add a check - an import-graph rule or a small test that scans module imports - that fails when anything outside that package imports the driver. Pair it with a test asserting the public adapter function raises the domain type against a broken store, so both halves of the boundary are covered.

saying these in an interview costs you the question

  • Puts one try/except around the whole nightly run and calls it done
  • Catches the driver's error in the service layer
  • Wraps every driver call in a generic error deep in a helper
  • Collapses missing data and an unreachable store into one type
  • Re-wraps the domain error again in each layer above
  • Invents a domain type for every driver error class

context