Engineering Principles & Design Patterns
This section is stack-agnostic on purpose. It should read the same whether the frontend framework, database, or hosting provider changes; the current stack (see Section 2) shows up only as an example, never as the point.
8.1 Design for failure
Assume every external dependency, an API, a database, a network call, will fail. Timeouts and retries are a default you add up front, not something you bolt on after the first incident.
- Every external call has a timeout. A call with no timeout can hang a request indefinitely and take down whatever depends on it. There is no acceptable reason for an unbounded external call.
- Retries are the default for transient failures, with backoff, not a tight retry loop. See 10.3 for the specific retry and circuit-breaking conventions.
- Decide up front whether degradation or hard failure is correct, and
make that decision explicit in code, not accidental:
- Graceful degradation (serve stale or partial data) is acceptable when serving something imperfect is better than serving nothing: a dashboard showing a slightly stale metric, a recommendation list falling back to a generic default.
- A hard failure is correct when serving wrong or partial data would be actively misleading or unsafe: a payment amount, a permissions check, a compliance-relevant number. Never silently substitute a fallback value for something that must be correct or absent.
Rule: when you write a call to an external dependency, decide and document (in code or in the PR) which of the two failure behaviors above applies. “I didn’t think about what happens if this fails” is not an acceptable state to ship.
8.2 SOLID
SOLID is a design lens, not an academic exercise. Each principle below is illustrated with both a UI and a backend example, because both teams are held to the same principles.
- Single Responsibility. A module should have one reason to change.
- UI: A component that fetches data, transforms it, and renders it has three reasons to change (the API shape, the transform logic, the layout). Split it into a data-fetching hook, a transform function, and a presentational component.
- Backend: A service function that validates input, applies business rules, and writes to the database has the same problem. Split validation, domain logic, and persistence (see 3.2 on layer responsibility).
- Open/Closed. Code should be open to extension without modifying
existing, working code.
- UI: A button component that takes a
variantprop is open for extension (add a new variant) without changing how existing variants render. - Backend: A pricing calculation that dispatches to a strategy per plan type (see 8.3 Strategy below) lets you add a new plan without editing the logic for existing plans.
- UI: A button component that takes a
- Liskov Substitution. A more specific implementation should be usable
anywhere the general one is expected, without surprising behavior.
- UI: If a
PrimaryButtonandSecondaryButtonboth implement the sameButtoninterface, swapping one for the other should never break the parent’s expectations (e.g. one silently swallowing anonClick). - Backend: If a
MongoUserRepositoryand aInMemoryUserRepositoryboth implement the same repository interface (see 8.3 Repository below), tests using the in-memory one must exercise the same contract as production code using the real one.
- UI: If a
- Interface Segregation. Don’t force a consumer to depend on methods
it doesn’t use.
- UI: A shared
Modalcomponent shouldn’t require every consumer to pass afooterprop if most modals don’t need one, split it into a composable pattern instead (see 5.9). - Backend: A typed external API client (see 8.3 Factory below) shouldn’t force every consumer to import the entire provider SDK just to call one endpoint.
- UI: A shared
- Dependency Inversion. Depend on abstractions, not concrete
implementations.
- UI: A component should receive data through props or a hook interface, not import a specific fetch client directly, so the data source can change without touching the component.
- Backend: Domain logic should depend on a repository interface, not a specific ORM call, so the persistence layer can change without touching business rules (see 8.5 on lightweight dependency injection, below).
Rule: SOLID is a set of pressure-release valves for change, not a checklist to apply uniformly to every file. Apply a principle where its violation is already causing pain (a file that changes for unrelated reasons, a test that can’t run without a real database); don’t restructure working, stable code just to satisfy the letter of a principle.
8.3 Design patterns
Patterns are tools for specific, recurring problems, not a badge of seniority. Use the name so the team has shared vocabulary, but the pattern should earn its place by solving a real problem.
- Singleton. One shared instance for the lifetime of the process, e.g.
a database connection pool or an external API client. In Node, a
module-level export is already a singleton, the module is only
evaluated once and its exports are cached. Hand-rolling a singleton
class with a
getInstance()method is almost always unnecessary ceremony on top of what the module system already gives you for free. - Factory. Building a typed client per external provider (see 7.4) so callers get a consistent, validated interface regardless of which provider they’re calling. Useful when construction logic varies per type and callers shouldn’t need to know the difference.
- Strategy. Swappable business logic behind one interface, e.g. different pricing or scoring rules per plan type or region. Useful when the what varies but the shape of the operation doesn’t, letting new rules be added without touching the code that selects and invokes them.
- Repository. Abstracting data access from business logic behind an
interface (
findActiveUsersInTenant, not a raw query), so domain logic doesn’t depend on a specific database or ORM (see 3.2). This is what makes the Liskov and Dependency Inversion examples above practical to test.
Anti-pattern: applying a pattern where a plain function would do. This is worth naming explicitly as its own anti-pattern, because reading about patterns tends to make engineers reach for one immediately afterward. If a piece of logic has exactly one implementation with no evidence a second is coming, a Factory or Strategy around it is speculative complexity, not design. Write the plain function; introduce the pattern when a second real case shows up.
8.4 YAGNI
You Aren’t Gonna Need It. This is placed immediately after the design patterns section deliberately: the previous section is the section most likely to make an engineer overapply an abstraction right afterward.
Don’t build a configuration system for one config value. Don’t add a plugin architecture for a feature with one implementation. Don’t generalize a function to handle a case no one has asked for. Premature abstraction is not free, it costs the same review time and cognitive overhead as necessary abstraction, without paying for itself.
Rule: if you’re building something to support a future requirement, name the concrete, already-planned use case that requires it in the PR description. If you can’t name one, you’re not building for the future, you’re guessing, and the guess is usually wrong in a way that costs more to undo than to have never built.
8.5 Testing philosophy
The test pyramid (many unit tests, fewer integration tests, few end-to-end tests) is the default model: unit tests are cheap and fast, so most coverage should live there.
The testing trophy (more integration tests than unit tests, with end-to-end tests at the top) is a valid alternative specifically for component-heavy frontends, where a component’s value is mostly in how it integrates with its surroundings, not in isolated unit behavior.
- TDD is scoped, not mandated everywhere. Write tests first for business-critical logic, pricing, scoring, anything with real financial or business stakes, where getting the logic right the first time matters more than iteration speed. Mandating TDD for every line of UI glue code slows the team down for no corresponding benefit.
- BDD-style test naming (
describe('when the cart is empty'),it('shows the empty state')) is optional but encouraged as documentation value: a well-named test suite doubles as a readable spec of expected behavior.
This subsection sets the why. Section 13 sets the how much and where, by layer, with concrete scope and coverage expectations.
8.6 Dependency management
Use lightweight dependency injection: pass clients, config, and collaborators into a service or function rather than importing a singleton directly inside it. Scope this to whatever actually needs to be mockable in tests, not everything.
- Pass what needs to vary in tests. A service that calls an external API should receive that client as a parameter (constructor or function argument) so a test can substitute a fake one, per the Dependency Inversion principle in 8.2.
- Don’t force a full DI framework or container unless the codebase’s complexity actually demands it (deep dependency graphs, conditional wiring per environment). For most services, passing a handful of dependencies explicitly is simpler to read and debug than resolving them through a container.
Rule: if a piece of code is hard to test because it reaches out and grabs a singleton or makes a direct network call internally, that’s a dependency injection gap, fix the seam, don’t reach for a heavier mocking tool to paper over it.
8.7 Idempotency
An operation is idempotent if running it twice has the same effect as running it once. This is required, not optional, for:
- Webhook handlers (the sender may redeliver the same event).
- Payment callbacks (a network retry must never double-charge).
- Any externally retried call, including our own retry logic from 8.1 and 10.3.
Achieve this with an idempotency key (a unique id per logical operation, checked before applying effects) or by making the operation naturally safe to repeat (e.g. an upsert instead of an insert). A retried operation that isn’t idempotent is a latent bug, not an edge case, because retries are a stated default in this document, not a rare occurrence.
Rule: any handler for a webhook, payment callback, or job that our own retry policy can invoke more than once must be idempotent before it ships. This is a review blocker, not a follow-up ticket.
8.8 TOCTOU and race conditions
A race condition is any bug that only exists because of timing: the code is correct if you read it top to bottom, but wrong when two things run concurrently in an order the author didn’t picture. These are easy to miss in review because nothing about the code looks wrong in isolation, and easy to miss in testing because they often don’t reproduce reliably. The patterns below are the common, recurring shapes this takes.
Time-of-check to time-of-use (TOCTOU). Code checks a condition, then acts on it as though nothing could have changed in between, when something else (another request, another instance, another async task) can run in that gap.
- Check-then-act on a record. Reading a record to decide whether an action is allowed (a balance, a seat count, a permission), then writing based on that read, is a TOCTOU race if another request can write to the same record in between. This is exactly what optimistic concurrency (see 6.9) and, for cross-instance coordination, distributed locking (see 6.16) exist to close, this subsection names the general hazard those two subsections give concrete mechanisms for.
- Check-then-act on a file or external resource. Checking whether a file exists, then creating it, is a race if another process can create it in between; checking a permission, then performing the privileged action, is a race if the permission can change in between. Prefer a single atomic operation (an exclusive create, a conditional write) over a separate check followed by an act.
Lost update. Two processes read the same value and both write back
based on what they read, with no check involved at all, just a plain
read-modify-write. count = count + 1 executed concurrently by two
handlers can read the same starting value and both write back the same
incremented result, silently losing one of the two increments. Node’s
single-threaded execution model doesn’t prevent this: an await
anywhere in the middle of a read-modify-write sequence is a point where
another handler can interleave and read the same stale value. The fix is
the same as for check-then-act races, a single atomic operation
($inc, a database-level increment) instead of separate read and write
steps.
Double-submit / duplicate request. A user double-clicks a submit button, or a client retries a request that’s still in flight (see 10.3), and the same logical action is sent twice concurrently. This is not a check-then-act gap, it’s the same operation racing against its own duplicate. This is exactly the case idempotency (8.7 above) is required for: an idempotency key lets the second, concurrent copy of the request recognize it’s a duplicate and no-op, rather than both copies succeeding and duplicating the effect.
Thundering herd / initialization race. Multiple concurrent requests all find a cache entry or resource uninitialized at the same moment, and all trigger the same expensive initialization or fetch simultaneously, instead of one request doing the work while the others wait on it. This turns a single cache miss into N redundant expensive operations happening at once, exactly when load is already high enough to matter. Guard against it by having the first request to notice a miss claim the work (a short-lived lock or an in-flight-request cache keyed on what’s being fetched) so concurrent callers wait on that one result instead of each starting their own.
Two related but narrower patterns worth being aware of without full treatment here: introducing a lock to fix one of the races above can itself deadlock two processes that acquire multiple locks in different orders, and a subscriber that sets up an event listener too late can silently miss an event that already fired, appearing as a missed update rather than a corruption.
Rule: prefer a single atomic operation over separate check-then-act or read-modify-write steps wherever the underlying system offers one (a conditional update, an exclusive create, an atomic increment, a database transaction, per 6.6). Where no atomic primitive exists and a multi-step sequence is unavoidable, treat the gap as a stated risk in the PR description, not an unstated assumption, and design for what happens if the underlying state changed during that gap, don’t assume it can’t.
8.9 Feature flagging
Match the flag tier to what the team actually needs, don’t over-provision a full platform when a simpler tier solves the problem:
- Simple env/config-based flags. A boolean read from environment config at boot. Sufficient for flags that change rarely and don’t need per-user targeting or runtime toggling.
- DB-backed toggle. A flag stored in the database, changeable at runtime without a redeploy, optionally targeted by user or tenant. Appropriate once flags need to change without a deploy or need simple targeting.
- Full platform (e.g. LaunchDarkly, GrowthBook). Appropriate once you need percentage rollouts, experimentation, or enough concurrent flags that a DB-backed approach becomes hard to manage.
Rule: every flag has a named owner and a stated removal date at creation time. Flag debt, flags nobody remembers the purpose of, left in the codebase indefinitely, is a real and common mess. A flag with no removal date is treated as a bug, not a convenience, and should be flagged in review the same way an unhandled error case would be.
8.10 Eventual consistency as a deliberate tradeoff
Several specific rules elsewhere in this document (denormalized fields in 6.6, cache staleness in 7.9, tag-based revalidation in 9.2) already accept that a value can be briefly stale. This subsection is the general principle behind those specific instances: eventual consistency is a deliberate, scoped tradeoff you accept in exchange for something (avoiding a join, avoiding a synchronous cross-service call, serving from cache), not a default you fall into by not thinking about it.
- Accept eventual consistency only where staleness is genuinely tolerable, and where you can state how stale is acceptable (a bound: seconds, until the next cache invalidation, until a background job’s next run) and what closes the gap (a TTL, an event handler, a revalidation trigger). “It’ll probably catch up eventually” with no stated bound or mechanism is not eventual consistency, it’s an unmanaged data integrity gap.
- Require strong consistency where staleness would be actively wrong, not just inconvenient: a financial balance, a permission check, an inventory count where overselling is a real business problem, the same class of case where 8.1 already requires a hard failure over graceful degradation. Eventual consistency and graceful degradation are related but distinct: 8.1 is about behavior under failure, this is about behavior under normal, successful operation where a value is intentionally allowed to lag.
- State the consistency model where it isn’t obvious. A cached value, a denormalized field, or a value derived asynchronously from an event should make it clear to whoever reads it (a comment, a type name, a field name) that it can lag the source of truth, rather than looking indistinguishable from a strongly consistent value and surprising the next engineer who assumes it’s always current.
Rule: a design decision to accept eventual consistency needs an explicit bound and an explicit mechanism that closes the gap, stated at the point the decision is made, not discovered later when a support ticket asks why two values disagree. This is the same discipline 7.9 already requires of a specific cache invalidation owner, generalized to any place staleness is accepted.
8.11 Fail fast on invalid state
A system that detects a problem should say so immediately and loudly, not continue operating on data or state it can’t trust. This is the general principle behind specific instances already in this document: boot-time configuration validation (6.10), schema validation at the API boundary (9.3), and circuit breaking (10.3) are all applications of the same underlying habit.
- Validate an assumption at the point it’s made, not downstream where it’s used. A function that assumes a parameter is non-empty, a positive number, or a specific shape should check that assumption at its entry point and fail immediately and clearly if it doesn’t hold, rather than proceeding and producing a confusing failure three layers deeper, or worse, silently producing a wrong result.
- A loud, immediate failure is preferable to a quiet, delayed one. An exception with a clear message at the moment invalid state is detected is far cheaper to diagnose than a value that’s silently wrong and only surfaces as a symptom somewhere else entirely, sometimes much later, sometimes in a place that gives no clue where the actual problem originated.
- This applies to internal invariants, not just external input. Input validation at a system boundary (per 11.3) is one instance of this principle; it also applies to internal assumptions between your own modules, a function receiving a value from another part of your own codebase deserves the same “does this actually hold” discipline as a value arriving from outside the system, an internal caller can be wrong too.
Rule: silently working around invalid or unexpected state (a default substituted for a missing value with no indication that happened, a caught exception that’s swallowed with no log line) is a review flag, the same class of concern as a swallowed error case in the 4.2 checklist. If invalid state is possible, surface it; don’t paper over it and hope it doesn’t matter.
8.12 Backward and forward compatibility
This is the general discipline behind why 7.7’s additive-only API versioning and 7.8’s expand/contract migration pattern exist: because a rolling deploy briefly runs multiple versions of the same system concurrently, and because a stored record or message written today may be read by code deployed weeks from now, code and data should tolerate a version of themselves that isn’t identical to what’s currently running.
- New code should tolerate old data, a record or message written by a previous version of the application. A field that didn’t exist yet when the record was written should have a sensible default or an explicit “not present” handling, not an assumption that every record was written by the current version of the code.
- Old code, briefly, still has to tolerate new data during a rollout. This is what makes an additive-only change safe and a changed-meaning or removed field unsafe mid-rollout, per 7.7: the previous version of the application is still running against the new schema or contract for the duration of the rollout window.
- A serialized message or event’s shape is a contract with its future readers, not just its current one. A message written to a queue today (see 10.4) may be read by a worker deployed after the schema has already changed again; design the message shape with the same additive-only discipline as an API contract, for the same underlying reason.
Rule: a change that only makes sense if every reader and every piece of stored data is assumed to be on the exact same version at the exact same moment is not compatible with how this document’s own deploy model actually works (7.8), and needs to be redesigned as additive or staged, not shipped as a single atomic cutover.
8.13 Immutability and side-effect discipline
Prefer data that doesn’t change after it’s created, and functions whose only effect is their return value, wherever that’s practical. This is a data-mutation discipline, distinct from SOLID (8.2, which is about structure) and from dependency management (8.6, which is about where collaborators come from).
- Treat data as immutable by default. Instead of mutating an object or array in place, produce a new one with the change applied (spreading, a copy-and-modify operation). A function that mutates a value the caller passed in, instead of returning a new one, is a common source of a bug that’s hard to trace: the caller’s data changed somewhere it didn’t expect.
- A pure function (same input, same output, no side effect) is preferred wherever the logic allows it. Business rules, calculations, and transformations, the kind of logic in a domain service (see 3.2), are natural candidates: they’re easier to test (no setup beyond the input, no mock needed to observe a side effect), easier to reason about, and safe to call more than once.
- Where a side effect is genuinely necessary (a database write, an external API call, a log line), contain it at the boundary, the data access layer, an external provider wrapper (see 7.4), rather than scattered inside logic that’s otherwise pure. A domain function that both computes a result and writes to a database is harder to test and harder to reuse than one that computes the result and lets its caller decide what to do with it.
Rule: a function that mutates an argument passed into it, instead of returning a new value, needs that behavior to be obvious from its name or signature (or better, avoided entirely); a caller should never be surprised that a value they passed in changed underneath them.
8.14 Premature optimization
This is YAGNI’s (8.4) sibling for performance: don’t optimize a code path that was never measured as a problem, at the cost of the readability this document asks for everywhere else (see 4.1). The frontend-specific version of this already exists in 5.20’s “measure before optimizing” rule; this is the same principle stated generally, for backend code as much as frontend.
- Write the clear, straightforward version first. Optimize only once you have a concrete, measured reason: a profiler result, a slow query log, a stated performance budget (12.2) actually being missed. A clever, optimized-looking version of code that was never shown to be a bottleneck usually costs more in readability than it ever returns in actual performance.
- “This might be slow” is a hypothesis, not a justification. Measure it. If it’s genuinely slow, that measurement is the justification for optimizing it, and also tells you which part actually needs the work, optimizing the wrong part of a slow path is a common outcome of skipping this step.
- An optimization that trades away clarity needs to earn that trade, documented at the point it’s made (a comment explaining the non-obvious, optimized approach, per the “why, not what” comment principle in 4.1), so the next engineer knows it’s deliberate and knows why, rather than “simplifying” it back into the slow version without realizing what they undid.
Rule: a PR that trades readability for performance justifies it with what was measured, the same standard 5.20 already requires for a memoization hook. An optimization with no stated measurement behind it is speculative complexity, the same class of issue as an abstraction with no stated concrete need in 8.4.
8.15 Observability as a design-time concern
Whether you can tell what a piece of code is actually doing in production, without redeploying it or attaching a debugger, is a design decision made when the code is written, not a capability bolted on after the first incident that needed it. 6.2 states the concrete, required logging and tracing format; this subsection is the general habit behind why that requirement exists.
- Design for “how would I debug this in production” while writing the code, not after the first time you actually need to. A non-obvious branch, a retry, a fallback path, a value derived from more than one source, these are exactly the places a future incident investigation needs visibility into, and exactly the places it’s cheapest to add that visibility while the logic is still fresh in your head.
- A log line or a trace span earns its place by answering a question someone will actually ask during an incident: what path did this request take, what was the input that led to this branch, why did this retry happen. A log line that just restates what the code already makes obvious from its surrounding context is the observability equivalent of a comment that restates the next line (see 4.1), noise that makes the genuinely useful log lines harder to find.
- This is cheapest at write time, not after the fact. Adding observability to code you no longer remember the details of, under the pressure of an active incident, is a worse position than having designed it in when the logic was written and the tradeoffs were still clear in your mind.
Rule: a non-obvious branch, fallback, or retry path with no way to tell after the fact which way it went is a design gap, not something to patch in in during the next incident that needs it. Ask “how would I know this happened” while writing the code, not while debugging why you can’t tell.
8.16 Minimizing coupling to internals
Related to, but more specific than, Dependency Inversion (8.2): don’t reach through an object or module’s internal structure to get to something several levels deep. Depend on what a collaborator exposes at its own boundary, not on how it happens to be built internally.
- A chain reaching through multiple levels of internal structure (an object’s property, whose property, whose method) couples the caller to every internal detail along that chain, any of which changing breaks the caller, even though the caller’s actual intent was simple. If code needs something from deep inside a collaborator, that collaborator should expose it directly, not require the caller to know its internal shape.
- This is why a data access function returns a domain model, not a raw document (see 6.8): a caller reaching into a raw database document’s nested internal structure is coupled to the storage shape, exactly the coupling the domain-model boundary exists to prevent.
- A component or module’s public surface is the contract; its internal structure is free to change as long as that surface doesn’t. Reaching past the public surface to grab something from inside (accessing a component’s internal state directly instead of through its props, reaching into another module’s private implementation) breaks that contract even when it isn’t caught by a type error.
Rule: a long chain of property or method access reaching into
another module’s internals (service.repository.connection.rawClient)
is a review flag, the module being reached into is missing a method
that exposes what’s actually needed, add that method rather than
reaching past it.