Skip to content

DRY vs deliberate duplication

Some concepts are defined twice on purpose: a database row and a domain entity, a service input and a wire request. This page answers when a second copy is allowed, when it must become one definition, and what guards a copy so it cannot drift.

Impact: HIGH decides your schemas, wire shape and errors

  • Ask: “would a change to one copy always have to reach the other, for the same reason, in the same commit?” Yes means it is one thing with one definition. No means two decisions that only look alike today.
  • Leaves are always shared, envelopes may be copied. A leaf is a field-level schema or value: a branded id, an enum, a nested value such as Evidence, a size limit. An envelope picks leaves for one boundary: the Row, the domain entity, the Public* wire struct.
  • A nested type is a leaf. A JSON column or wire field that carries a domain value reuses the domain schema; it is never re-declared as an interface.
  • Shares, not copies: a hash used in two places, a single get and its batch getMany, an old method and its replacement. A domain error is not mirrored per error on the wire; it maps onto a shared status-shaped error such as NotFound (see HttpApi).
  • Accepted cost: “same reason or different” is a judgement call. If a “different” copy turns out to be the same thing, merge it rather than defend the old verdict.

❌ Incorrect — one schema for storage, model and wire ties three change-drivers together:

export const LabelingRule = Schema.Struct({
id: RuleId,
evidence: Evidence,
internalScore: Schema.Number, // now on the wire by default
})
// Row, service and handler all use LabelingRule:
// every domain change is also an API change

✅ Correct — shared leaves, one envelope per boundary:

// leaves: one definition each, imported everywhere
export const RuleId = …
export const Evidence = Schema.Struct({ … })
// envelopes: one per boundary, built from the same leaves
const Row = Schema.Struct({ id: RuleId, evidence: Schema.fromJsonString(Evidence), … }) // storage
export const LabelingRule = Schema.Struct({ id: RuleId, evidence: Evidence, … }) // model
export const PublicLabelingRule = Schema.Struct({ id: RuleId, evidence: Evidence, … }) // audience

Source: notes/02-design-principles/dry-and-duplication.md · Decision 1 (amended)

Move a shared thing at the second consumer

Section titled “Move a shared thing at the second consumer”

Impact: HIGH stops security logic drifting across copies

  • While one place uses it, it lives next to its type. When a second consumer appears, it moves to the nearest common owner in that PR, not at the third. Nobody counts copies, so “wait for three” means waiting forever.
  • Security and invariant logic is never copied, not even once: token hashing, signature checks, authorization, anything that moves money. It lives with its owner from the first line.
  • A share between two domain features goes down into foundation (infra/, identity/, orgs/, authz/, @app/domain, contracts), never sideways into a third domain. It goes only when the two need the same meaning, not just a similar shape.
  • A copy forced by an import cycle is a graph bug: fix the edge, don’t copy around it (see coupling & cohesion). Partial test fakes are the exception to the threshold: shared at three test files.

❌ Incorrect — the same hash, copied into a second caller:

// identity/…
const hashToken = (token: string) => …
// a background job
const hashToken = (token: string) => … // a copy; the next change reaches only one

✅ Correct — the nearest common owner, chosen by who consumes it:

consumers where it goes
1 next to its type, in its feature
2 files, 1 feature a file in that feature labeling/repo-slug.ts
2 features packages/domain/src/shared/ or a foundation feature
2 apps a package

Source: notes/02-design-principles/dry-and-duplication.md · Decision 2 (amended)

Impact: MEDIUM turns “can drift” into a checked choice

  • The note that decides a copy names its guard, from strongest to weakest: (1) not a copy, share or derive; (2) a compile error; (3) a test that runs both copies together; (4) a CI diff against a committed artifact.
  • Prose is not a guard. “When you change X, also change Y” in a doc has already drifted in the repos we read. A copy with only prose behind it is not accepted: find a guard or share it.
  • Example guard: one test per deployable loads .env.example through every feature’s config, so placeholder values must decode like real ones. Defaulted tunables stay commented out, so the test also proves their defaults decode.
  • Accepted cost: that test’s list of feature configs is still kept by hand.

❌ Incorrect — a reminder in a doc is the only thing holding two copies together:

AGENTS.md
When you add a config key, also add it to `.env.example`.

✅ Correct — a test that fails when the copies disagree:

apps/server/src/env-example.test.ts
it.effect(".env.example satisfies every feature config", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const provider = ConfigProvider.fromDotEnvContents(yield* fs.readFileString(".env.example"))
yield* Labeling.config.parse(provider)
yield* Billing.config.parse(provider)
// … one line per feature that has a config
}).pipe(Effect.provide(NodeFileSystem.layer)))

Source: notes/02-design-principles/dry-and-duplication.md · Decision 3

Impact: HIGH a dropped field is silent data loss

  • fromRow and toRow destructure every field and assert the rest is empty. A field added on either side, optional or required, is then a compile error until the mapper handles it.
  • A column read on purpose but not mapped gets a leading underscore (deleted_at: _deletedAt). Nested values such as evidence pass through whole; the shared leaf schema guards their keys. Exhausted lives in packages/db/src/row.ts.
  • Only for row mappers. toPublic* stays an allowlist: its silence about a new domain field is what keeps that field off the wire.
  • The lint rule app/exhaustive-row-mapper catches a mapper written without the destructure and the satisfies line. The full mapper is in SQL & transactions.

❌ Incorrect — a new optional priority compiles, and is never stored:

const toRow = (orgId: OrgId, rule: LabelingRule): typeof Row.Type => ({
id: rule.id, org_id: orgId, repository_id: rule.repositoryId, enabled: rule.enabled,
evidence: rule.evidence, client_name: rule.client.name, client_ip: rule.client.ip,
created_at: rule.createdAt, deleted_at: null,
})

✅ Correct — rest must be empty, so priority is a compile error here:

export type Exhausted = Record<PropertyKey, never> // packages/db/src/row.ts
const toRow = (
orgId: OrgId,
{ id, repositoryId, enabled, evidence, client, createdAt, ...rest }: LabelingRule,
): typeof Row.Type => {
rest satisfies Exhausted
return { id, org_id: orgId, repository_id: repositoryId, enabled, evidence,
client_name: client.name, client_ip: client.ip, created_at: createdAt, deleted_at: null }
}

Source: notes/02-design-principles/dry-and-duplication.md · Decision 4 (amended)

  • Merging a copy judged “different” — trigger: both copies are changed in the same PR 3 times.
  • Moving a same-driver thing to its nearest common owner — trigger: a second consumer appears.