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
Connections
Context
Section titled “Context”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.
Options considered
Section titled “Options considered”1. No written principles
Section titled “1. No written principles”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
Decision
Section titled “Decision”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
- 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
- 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,renderprops,cvavariants (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.
Consequences
Section titled “Consequences”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.

