Skip to content

KISS, YAGNI, and when to abstract

Every extra layer, option or service costs something today and pays only if its need arrives. This page answers what we build up front, what counts as a signal to build more, and how a deferral is written so anyone can tell when it has fired. Each page on this site lists its own deferrals under Deferred; the full index, with every trigger’s kind, owner and status, lives in the note’s ledger.

Start minimal; build ahead only what retrofits badly

Section titled “Start minimal; build ahead only what retrofits badly”

Impact: HIGH the default every other note leans on

  • Build what is needed now. Build ahead only three things, because changing them later costs far more than getting them right first:
    • stored data formats (live rows cannot be rewritten for free): keyId + AAD in SecretBox, the migration ledger, table ownership prefixes;
    • wire identity (an old client or a rolled-back server reads it): error _tags, /api/v1, api.compat.test.ts, because production runs at least two replicas and every rolling deploy mixes versions;
    • security boundaries (the first violation is the incident): the Public* allowlist, policy.ts in the service, authz/, two DB roles, repo privacy, header redaction.
  • Two things are free and need no justification: rules that wait for their subject (they apply the first time it appears) and conventions with no code, such as a naming glossary.
  • Anything else built ahead must name its signal (next rule), or it waits.

❌ Incorrect — an options path on a service that has no options, “just in case”:

export const make = (options?: LabelingRulesOptions) =>
Effect.gen(function* () { … })
export const layerWithOptions = (options?: LabelingRulesOptions) =>
Layer.effect(LabelingRules, make(options))
export const layer = layerWithOptions()

✅ Correct — make and layer only; layerWithOptions arrives in the PR that adds the first option:

export const make = Effect.gen(function* () { … })
export const layer = Layer.effect(LabelingRules, make)

Source: notes/02-design-principles/kiss-yagni-and-abstraction-timing.md · Decision 1, 5

Impact: HIGH lets anyone check whether a trigger fired

  • The valid signals are a closed list:
    • 2C a second real consumer (the second import in a diff);
    • 2A a second audience: an API consumer that is not our web client and is not deployed from this repo;
    • 2D a second deployable, process kind or replica;
    • M# a measured cost, with the number written in the trigger;
    • I an incident or bug, including copies that drifted;
    • X an external event: a vendor or platform chosen, an upstream release;
    • F the thing’s subject appears for the first time.
  • Not signals: “we might need it”, “survives growth”, “sooner than expected”, “before any job needs it”, “if it hurts” with nothing to measure.
  • One exception, for lead time: a prerequisite that needs time to become useful may trigger on a planned event, if the plan is written down (a scheduled issue or a dated decision).
  • Numbers are first guesses, such as the test job’s p50 > 10 min or root tsc --noEmit > 60 s locally. Correct them when real data arrives.

❌ Incorrect — triggers only their author can judge:

Split the CI job if it starts to dominate.
Add sub-groups once the group gets that big.

✅ Correct — observable, with a number:

**Trigger (M#):** the `test` job's p50 > 10 min.
**Trigger (M#):** > 12 endpoints in one group.

Source: notes/02-design-principles/kiss-yagni-and-abstraction-timing.md · Decision 2, 4

Write every deferral as one fixed-form line

Section titled “Write every deferral as one fixed-form line”

Impact: MEDIUM deferrals stay findable and get acted on

  • Every deferral is one line, in the note that owns the deferred thing, in its Decision or Rejected section. That line is the source of truth. The KISS/YAGNI note keeps an index of all of them; a commit that adds, changes or fires a trigger updates the index in the same commit.
  • The PR that crosses a trigger acts on it: it builds the thing, or adds one sentence under the line saying why not and what the next trigger is.
  • A trigger that cannot be made observable is not a deferral. It becomes a plain rejection, with no “revisit”.
  • Index statuses: open, fired (condition holds, thing not built: a bug to fix), built, dropped.

❌ Incorrect — a vague “later” with no condition anyone can check:

We can add API keys later if we need them.

✅ Correct — the fixed form, with a signal kind:

**Deferred:** <the thing>. **Trigger (<kind>):** <observable condition, with a number for M#>.
**Deferred:** the `ApiKey` principal variant and the API-key subsystem.
**Trigger (2A):** the first machine client.

Source: notes/02-design-principles/kiss-yagni-and-abstraction-timing.md · Decision 3

Ship the smaller day-one shape, and write down what brings the bigger one back

Section titled “Ship the smaller day-one shape, and write down what brings the bigger one back”

Impact: MEDIUM removes work built ahead of any signal

  • Applying the rules above cut back several earlier decisions. Each smaller shape ships now; the bigger one stays written as the plan and returns on its trigger.
  • An old method is replaced with all its callers in one PR. Coexistence (old delegates to new) applies only when the replacement touches more than 3 slices.
  • cause goes only on an error that wraps another failure. runMain’s default last-resort log is the day-one boot-failure report.

❌ Incorrect — the full shape built before its first user:

Principal = User | ApiKey | System + ApiKeys service, 3 endpoints, a table
@app/http/<feature>/<Error> one mirrored wire class per domain error
previews pr-<n> built for reviewers who do not exist yet

✅ Correct — the day-one shape, with the trigger beside it:

Principal = User | System back with: the first machine client (2A)
NotFound / Conflict / Forbidden back with: a client must branch on one error (2A/F)
production + staging only back with: a second human reviewer (2A)
PITR + one drill before launch quarterly drills from: the first customer data (X)

Source: notes/02-design-principles/kiss-yagni-and-abstraction-timing.md · Decision 5

  • The ApiKey principal and API-key subsystem — trigger: the first machine client.
  • A wire class for one specific error — trigger: a client must branch on that error.
  • pr-<n> preview environments — trigger: a second human reviewer who does not run branches locally.
  • Quarterly restore drills — trigger: production holds its first customer data.
  • The three demoted lint rules — trigger: the same mistake is caught in review a second time.
  • @deprecated coexistence for a replaced method — trigger: the replacement touches more than 3 slices.