DebuggingGENERALRUNTIME-SPECIFICFRAMEWORK-SPECIFIC

Backend Code Smells

Ten patterns that predict production trouble — and, for each, the situation where the same pattern is the right answer.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Which patterns in backend code reliably predict incidents, and when is each of them actually fine?

The requirement

Review a service nobody has touched in a year and say what is likely to hurt, without rewriting it and without a checklist that flags every file.

The obvious build

Apply the rules: controllers should be thin, services should be small, everything goes through a repository, and no global state. Flag every violation in review.

Why it breaks

Applied without judgement, the rules generate large refactors that add indirection and change no behaviour — while the two genuinely dangerous smells go unmentioned.

How it breaks in production
  • Applied without judgement, the rules generate large refactors that add indirection and change no behaviour — while the two genuinely dangerous smells go unmentioned.
  • A "thin controller" rule pushes logic into a service layer that is a one-line pass-through, which is the same code with an extra file.
  • Blanket rules lose credibility fast; once a reviewer is seen as a linter, the serious findings get dismissed with the trivial ones.
  • The smells that actually cause incidents — a missing timeout, a swallowed error — are invisible to structural rules because they are one line each.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A smell is a correlation with future pain, not a defect. It says "look here", and the answer is sometimes "this is fine".
  • The structural smells (fat controllers, god services, empty repositories, cross-layer imports) cost changeability: they make the next change slower and riskier, and they surface in review, not in production.
  • The operational smells (no timeouts, swallowed errors, unbounded concurrency, hidden network calls, global mutable state) cost availability: they surface at 02:00 and never in review.
  • The two categories deserve different urgency. An operational smell is a scheduled incident; a structural smell is a tax.
  • Context decides. The same global mutable object is a race in a shared-memory runtime and a per-worker cache in a pre-fork one (Backend Runtime Models).

Ten smells, and when each one is right

The third column is the important one. A smell list without it produces reviewers who flag patterns rather than consequences, and teams learn to ignore them.

SmellWhy it predicts painWhen it is actually fine
Fat controller — parsing, rules, queries, mapping in the handlerUntestable without HTTP; the authorization check drifts away from one of the branches; two endpoints diverge in behaviourA single-purpose endpoint with a few lines of logic. Extracting it buys nothing but a file
God service — one class for a whole domain areaEvery change touches it; merge conflicts; nobody can hold it in their head; tests are slow and coupledA cohesive area genuinely owned by one person or team, still small enough to read in one sitting
Repository that only forwards to the ORMIndirection with no behaviour: the same method, one call deeper, and the ORM types leak through anywayWhen it enforces tenant scoping, hides raw SQL, or gives a data-mapper ORM a domain-shaped surface (The Repository Layer)
Domain logic in ORM lifecycle hooksFires on paths nobody expects — bulk updates, cascades, fixtures — and is invisible at every call siteGenuinely universal record bookkeeping: timestamps, soft-delete flags, audit columns
Hidden network call behind a property or getterBreaks the reader's cost model; produces N+1 in a loop that looks free (The N+1 Query Problem)A documented lazy accessor on an object never used in a loop — and even then, name it so the cost is visible
Global mutable stateShared across concurrent requests, invisible in tests, diverges across instances (Stateless Services)Process-scoped infrastructure: pools, metric registries, compiled schemas, immutable config
No timeout on an outbound callA dependency that hangs consumes a connection, a thread or a loop slot until something else fails (Timeouts)Never, for network calls. A local in-process call needs no timeout because it cannot hang on a network
Swallowed error — empty catch, or catch-and-return-defaultRemoves the signal entirely; the bug persists for months as "occasionally empty" (Error Boundaries: Three Translations, Not One)A deliberate fallback with a metric and a log line — that is a fallback, not a swallow
Unbounded concurrency — map over N items each firing a callN is user-controlled; a 10-item test and a 10 000-item production input are the same code (Unbounded Concurrency)A fixed, small, known N — and put the bound in anyway, because "known" changes
Cross-layer import — handler imports the ORM model directlySchema changes ripple into transport; the DTO boundary erodes and internal fields leak into responses (Schema Leakage)A small service with no layering pretence, where the direct path is the honest one

The two that are not style questions

LANGUAGE-SPECIFICWritten for a TypeScript runtime with AbortController; the shape is identical elsewhere with different names — a context deadline in Go, a timeout on the client in Python, a CompletableFuture timeout on the JVM. The engineering point, an explicit bound plus an observable failure, does not change.

A missing timeout and a swallowed error are different in kind from the rest of the list. They are single lines, they never appear in a design discussion, and each one converts a small dependency problem into an outage or a silent data bug.

Both have the same shape: code that behaves correctly when everything works and disastrously when something does not — which is precisely the code that testing does not cover.

Fallback versus swallow
Swallow
try { return await enrich(id) }
catch { return {} }
Bounded, observable fallback
const ctl = new AbortController()
const t = setTimeout(() => ctl.abort(), 800)
try {
  return await enrich(id, ctl.signal)
} catch (err) {
  metrics.inc('enrich.failed', { reason: classify(err) })
  log.warn({ err, id }, 'profile enrichment failed; serving without it')
  return PROFILE_UNAVAILABLE   // an explicit, typed absence
} finally {
  clearTimeout(t)
}

Both continue serving the request. Only the second one is *visible*: the failure has a bounded duration, a counter that alerts, a log line with the correlating id, and a return value the caller can distinguish from "this user has an empty profile".

Two lines that decide how the service fails
1async function enrich(userId: string) {
2 try {
3 // no timeout: if the provider hangs, this request hangs,
4 // holding a pool connection and a loop slot until something
5 // upstream gives up. Under load, every slot ends up here.
6 const res = await fetch(`${PROFILE_API}/users/${userId}`)
7 return await res.json()
8 } catch {
9 // and if it fails, we return nothing, forever, silently.
10 // No metric increments. No log line. The dashboard is green.
11 return {}
12 }
13}

The catch block is the more dangerous of the two. A hang eventually pages someone; a silent empty object produces a product bug that is reported months later as "profiles sometimes do not load" and cannot be reproduced.

Reviewing for consequence, not for pattern

The useful review question is never "does this follow our layering". It is "what happens to this code when the thing it depends on is slow, absent, or returns a thousand times more rows than you expected". Those questions find the operational smells and let the structural ones be judged on whether they are currently costing anything.

  • What happens if this never returns? — finds missing timeouts and unbounded holds.
  • What happens if this throws? — finds swallowed errors and half-completed writes (Where the Transaction Boundary Goes).
  • What is the largest N here, and who controls it? — finds unbounded concurrency and unpaginated queries.
  • How many network calls does this line make? — finds hidden lazy loads and N+1.
  • Is this shared between concurrent requests? — finds global mutable state and per-request context leaks.
  • Where is the authorization check, and does every branch pass through it? — finds the gap fat controllers create.
  • What change would this structure make hard next quarter? — the only honest test for a structural smell.

How to build it

Most important first.

  • Triage operational smells first — timeouts, swallowed errors, unbounded concurrency. They are small changes with direct availability value.
  • For structural smells, ask what change is currently hard. If nothing is hard, the structure is adequate regardless of how it looks (Transport, Application, Domain, Infrastructure).
  • Make hidden network calls visible: a property access that triggers a query, or a getter that calls an HTTP API, breaks every reader's cost model (What an ORM Actually Does).
  • Bound everything that can be many: concurrency, batch size, result set, retry count, queue length (Resource Limits).
  • Where domain logic lives inside ORM lifecycle hooks, move it into an explicit call the reader can find — hooks fire on paths nobody expects, including bulk operations and test fixtures.
  • Write the review comment as a question about consequence — "what happens if this call never returns?" — rather than as a rule citation.

What can go wrong

Failure modes
  • Refactoring a fat controller into a fat service and calling it layering (Fat Controllers).
  • Introducing a repository interface with one implementation and no test double, adding indirection and removing nothing (When the Repository Is Just Indirection).
  • Adding timeouts everywhere with values chosen arbitrarily, so a healthy-but-slow dependency now fails outright (Timeouts).
  • Replacing swallowed errors with rethrows that surface internal messages to callers (Not Leaking Your Internals).
  • Bounding concurrency with a limit so low that throughput collapses under normal load.
What can race
  • Global mutable state shared by concurrent requests is the canonical backend race, and it is invisible in single-request testing (Backend Races).
  • Unbounded concurrency turns a mild burst into simultaneous writes on the same rows (Optimistic Concurrency).
Security
  • A swallowed error is a swallowed authorization failure. catch { return [] } on a permission check turns a denial into an empty page and hides the bug indefinitely (Object-Level Authorization).
  • Global mutable state that holds per-request identity is a cross-request data-leak mechanism: one request's user context served to another (Request Context Propagation).
  • Hidden network calls are hidden SSRF surface when any part of the destination derives from user input (SSRF — When the Backend Fetches a URL).
  • Fat controllers tend to skip the authorization step for one branch, because the branch was added later and the check lives at the top (Where the Check Belongs).
Misreads
  • "Controllers must be thin." A controller that validates, calls one service and maps the result is thin *enough*; adding a layer to satisfy a rule adds nothing (What a Handler Is Responsible For).
  • "Every entity needs a repository." A repository that forwards to the ORM with the same signature is ceremony (The Repository Layer).
  • "Global state is always wrong." A process-wide connection pool, a compiled schema cache or a metrics registry are global by design and correct.
  • "Swallowing errors makes the service resilient." It makes the service *silent*. Resilience is a fallback plus a metric, not an empty catch block.
  • "ORM hooks keep the model consistent." They also fire on paths nobody was thinking about — seeds, bulk updates, cascades — and they are the hardest place in the codebase to find logic.

Operating it

How you see it in production
  • Query count per request: the reliable detector for hidden lazy-loading network calls.
  • Outbound calls with no configured timeout — an inventory of client configurations is more useful than any code review.
  • Error counters that never increment. A catch block with no metric is invisible by construction (An Error Taxonomy That Maps Cause to Response).
  • Peak in-flight outbound requests per dependency; unbounded concurrency shows as a spike with no ceiling (Unbounded Concurrency).
  • File-level churn and defect density: the modules that change most and break most are where structural smells actually cost something.
What changes at 10x and 100x
  • Structural smells matter more as team size grows: a god service is survivable for one author and a merge-conflict factory for six.
  • Operational smells matter more as dependency count grows: with twenty downstreams, one missing timeout is close to a certainty of an outage.
  • Unbounded concurrency is harmless at low volume and is exactly how a small service takes down a large dependency at high volume.
What this costs
  • Every smell removed adds structure, and structure is not free: more files, more indirection, more to learn before making a change.
  • Fixing operational smells often makes behaviour *less* forgiving in the short term — real timeouts turn silent slowness into visible errors, which is correct and unpopular.
  • Chasing structural purity in a service that is about to be replaced is pure cost.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe ten patterns appear across languages; only the idiom differs.
  • RUNTIME-SPECIFICGlobal mutable state means different things: in Node one process serves all in-flight requests, so a module-level object is shared across every concurrent request; under a pre-fork Python or PHP model each worker has its own copy, so the same code silently becomes a per-worker cache that diverges between workers.
  • FRAMEWORK-SPECIFICWhether a repository adds value depends on what the ORM already provides: an active-record style ORM already is a repository, whereas a data-mapper ORM leaves a genuine gap that a repository can fill.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

API Designerror-model
Domains that do not exist yet
  • Testing & Reliability Engineering — fault injection as the way to prove the fallback path is real rather than aspirational.