Skip to content

ADR-0027 Engineering principles

Company standard for how we shape code — KISS first, YAGNI second, SOLID where code has a real reason to change.

Decision
KISS, YAGNI, SOLID, in that orderBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

ADRs 0001–0025 choose the tools, and ADR-0026 says where files live. None says how much structure the code inside them should have, so that is settled again in every review, by taste.

  • Many products, one team: a developer moving between products should find the same shapes — where a rule lives, where data is fetched, where an interface is worth having
  • The principles are already here, case by case: hooks own data and state while components render (ADR-0018); one adapter boundary for model providers, passed in as a parameter (ADR-0025); Better Auth plugins are opt-in per product (ADR-0009); Zustand only when cross-tree state really appears (ADR-0012); a shared component registry only once two products duplicate a component (ADR-0011)
  • Extra structure is cheap to write and expensive to keep. AI coding tools draft quickly and lean toward more layers, options, and wrappers than the task needs; a reviewer needs a rule to point at

What is open: which principles we name, and which one wins when they disagree.

Pros: nothing to maintain; every team uses its own judgment. Cons: the same argument in every pull request, with a different outcome per reviewer; nothing to hand a new hire or an AI tool.

2. A mandated architecture (Clean / Hexagonal)

Section titled “2. A mandated architecture (Clean / Hexagonal)”

Fixed layers everywhere: entities, use cases, repositories, ports and adapters.

Pros

  • Every codebase has the same folders and the same seams
  • Business rules are testable without infrastructure

Cons

  • An interface with one implementation, for every class — ceremony that outweighs the CRUD most of our apps are
  • Works against choices we already made: Drizzle is SQL-first and used directly (ADR-0008), and integration tests run on real Postgres and Valkey (ADR-0014), so a repository layer that exists to be mocked buys nothing
  • More files to read before finding the line that does the work

3. Short named principles with a priority order

Section titled “3. Short named principles with a priority order”

KISS, YAGNI, and SOLID, with a rule for conflicts.

Pros

  • Known names: taught everywhere, searchable, and understood by AI tools without explanation
  • Small enough to remember and to cite in a review comment
  • The order settles the usual conflict — SOLID asking for an abstraction that YAGNI says is not needed yet

Cons

  • “Simple” is a judgment, not a measurement; enforcement is code review, not a linter
  • SOLID was written for class-based object-oriented code and needs translating to functions, hooks, and components
  • Less uniform than a mandated architecture: two products can look different

KISS, then YAGNI, then SOLID — applied in that order — is the company standard for all new code.

Concretely:

  • KISS — the simplest design that fully solves the problem. Prefer, in order: what the codebase already has, the platform or standard library, a dependency these ADRs already chose, then new code. Readable beats clever; a one-liner that needs decoding is not simple
  • YAGNI — build for the requirement we have. No option, config flag, wrapper, or extension point until a second real case exists. Extract a shared abstraction when the second case arrives, not before. Delete unused code instead of keeping it “for later” — git remembers
  • SOLID — where code has a real reason to change or a real second implementation, never as speculative structure
  • Backend:
    • Single responsibility: one module or function owns one reason to change. A server function or API route handler (ADR-0036) parses, authorizes, and calls a feature function; validation is Zod (ADR-0005), business rules are plain functions in server/services/ (ADR-0026), persistence is Drizzle (ADR-0008)
    • Open/closed: add behavior by adding a case (a handler, a strategy, a table row) instead of editing working branches — once a second variant exists
    • Liskov substitution: every implementation of an interface honors the same contract: inputs, outputs, errors, side effects. No implementation that throws “not supported”
    • Interface segregation: interfaces are small and shaped by the caller. Split a service when its callers use different halves
    • Dependency inversion: business logic receives what crosses a real boundary — the Drizzle db, the Valkey client, third-party API clients, the clock — as parameters, so a test can pass its own. Internal pure code imports concrete code directly
  • UI:
    • Single responsibility: a component renders or orchestrates. Data fetching and state logic live in hooks (ADR-0012); presentational components stay pure
    • Open/closed: extend by composition — children, slots, render props, cva variants (ADR-0011) — not by forking a component or piling on boolean flags
    • Liskov substitution: a wrapper or variant accepts and forwards the base component’s props (ref, className, ARIA attributes, event handlers) and behaves the same way
    • Interface segregation: pass the fields a component needs, not the whole domain object
    • Dependency inversion: components get API clients, stores, and services through props, context, or hooks, never by importing a concrete singleton deep in the tree
  • When they disagree, the simpler design wins, and the pull request notes the trade-off in one line
  • What simplicity never removes: validation at system boundaries (ADR-0005), authentication and authorization (ADR-0009), rate limits (ADR-0010), error handling that prevents data loss, accessibility, and the tests of ADR-0014
  • Enforced in code review. For any new abstraction, option, or dependency the reviewer asks one question: which current requirement needs this? No answer, no merge

The deciding factors: names every developer and AI tool already knows, a list short enough to cite in a review, and an explicit order that stops SOLID from becoming a reason to add layers. We accept that “simple” stays a judgment call made in review.

Easier:

  • Review arguments have a shared vocabulary and a tie-break rule
  • Less code to read, test, and upgrade: abstractions appear when a second case proves them
  • The same shapes across products — thin handlers, rules in plain modules, hooks that own state, components that render
  • Tests need few mocks: dependencies arrive as parameters, and the database is the real one (ADR-0014)

Harder:

  • Judgment is required; two reviewers can still disagree on what “simplest” is
  • Extracting an abstraction late means touching the first copy again — a small, known cost
  • Holding the line against generated code that arrives with layers nobody asked for

Given up: the uniformity of a mandated architecture, and DRY as a rule of its own. Accepted — duplication is cheaper than the wrong abstraction, and YAGNI already says when to extract.