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.
Copy when the reasons to change differ
Section titled “Copy when the reasons to change differ”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: theRow, the domain entity, thePublic*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
getand its batchgetMany, 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 asNotFound(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 everywhereexport const RuleId = …export const Evidence = Schema.Struct({ … })
// envelopes: one per boundary, built from the same leavesconst Row = Schema.Struct({ id: RuleId, evidence: Schema.fromJsonString(Evidence), … }) // storageexport const LabelingRule = Schema.Struct({ id: RuleId, evidence: Evidence, … }) // modelexport const PublicLabelingRule = Schema.Struct({ id: RuleId, evidence: Evidence, … }) // audienceSource: 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 jobconst 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 packageSource: notes/02-design-principles/dry-and-duplication.md · Decision 2 (amended)
Give every deliberate copy a guard
Section titled “Give every deliberate copy a guard”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.examplethrough every feature’sconfig, 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:
When you add a config key, also add it to `.env.example`.✅ Correct — a test that fails when the copies disagree:
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
Make row mappers exhaustive
Section titled “Make row mappers exhaustive”Impact: HIGH a dropped field is silent data loss
fromRowandtoRowdestructure 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 asevidencepass through whole; the shared leaf schema guards their keys.Exhaustedlives inpackages/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-mappercatches a mapper written without the destructure and thesatisfiesline. 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)
Deferred
Section titled “Deferred”- 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.