Design Rubric
A forcing function. Answer the questions — don't just recite the principles.
If a conflict arises between rules, the Tie-Breakers section wins.
1. Tie-Breakers (resolve conflicts first)
| Tension |
Default winner |
Why |
| KISS vs DRY |
KISS until 3 occurrences |
Two occurrences ≠ a pattern. Premature DRY couples unrelated things. |
| YAGNI vs OCP |
YAGNI |
Don't open for extensions you can't name. Add seams when the second need arrives. |
| Composition vs inheritance |
Composition |
Inherit only when LSP genuinely holds and you need subtype polymorphism. |
| Explicit vs clever |
Explicit |
Read 10×, write 1×. Clever loses. |
| Abstraction vs duplication |
Abstract only if it removes more complexity than it adds |
A wrong abstraction is costlier than duplication. |
| Decomposition vs readability |
Readability |
Split for responsibilities, never to hit a line count. Five fragments you must chase across a file read worse than one cohesive method that flows top-to-bottom. |
| Consistency vs correctness |
Correctness |
Don't propagate a bad pattern for symmetry. Fix the pattern. |
| Correctness vs performance |
Correctness |
Measure before optimizing. A fast wrong answer is still wrong. |
2. SOLID — as questions, per component
Apply to every non-trivial component in the design.
- SRP — What is the one reason this changes? If you list two, split. "Changes for reason A and reason B" = two components.
- OCP — What variation is actually coming? If you can't name a concrete next requirement, no abstraction yet. Add the seam with the second use case, not the first.
- LSP — Does every subtype honor the base contract in behavior, not just signature? Throwing
UnsupportedOperation, tightening preconditions, or weakening postconditions = violation.
- ISP — Would any client use <50% of this interface's methods? Split. Fat interfaces force fake implementations.
- DIP — Does this module depend on something more volatile than itself? Invert. Policy should not depend on mechanism; domain should not depend on I/O.
3. Clean Code (Uncle Bob) — concrete checks
Size & complexity — reason from principles, not line counts
Length is a symptom. Cohesion, single responsibility, and one-level-of-abstraction are the diseases. Ask which principle a too-big unit is breaking; don't gate on LOC.
Methods / functions — Uncle Bob: "small, then smaller." A method does one thing at one level of abstraction. Diagnostic: can you extract a chunk and give it a meaningful name that pulls its weight? Then it was doing more than one thing. Fowler's working heuristic: if it doesn't fit on screen without scrolling, your working memory is already fragmented reading it. Line counts are orientation, not a gate — a long method that does one thing and reads cleanly top-to-bottom is fine; a short one interleaving three concerns is not. The opposite mistake is just as real: shredding a cohesive sequence into a scatter of single-use helpers you must chase across the file hurts readability more than length ever did. Extract for a distinct responsibility, never to hit a number.
Classes — length is the wrong question. The right one: describe the class's responsibility in one sentence without "and" or "or." Can't? Split. Cohesion test: do most methods use most fields, or do you see method clusters using disjoint field clusters? Disjoint = two classes pretending to be one (LCOM smell). Reason-to-change test: list every reason this class would change; more than violation. A class hitting several hundred LOC almost always means SRP is broken — but treat the SRP failure as the bug, not the line count.
Files — follow class size. Default: one public class per file. Acceptable exceptions: tightly coupled types — sealed hierarchies, value object families, internal helpers used only by the file's main class. Multiple unrelated types in one file → split.
Nesting depth — each level is an obscured decision the reader must hold in working memory. Past ~2 levels the mental stack overflows. Cure is structural: Guard clauses for input validation, Extract Method to give a chunk a name and a single return value, Replace Conditional with Polymorphism when the nesting follows a runtime type discriminator.
Function parameters — each parameter is a coupling point. Uncle Bob: "ideal is zero, three is a lot." Two specific smells: Data Clumps (parameters that always travel together → Parameter Object) and flag arguments (hidden if-statements → split the function).
Cyclomatic complexity — each branch multiplies the test surface and the cognitive load. High CC is the math behind why Long Method is a smell. Same refactorings: Extract Method, Replace Conditional with Polymorphism (only when the type varies at runtime — otherwise you're trading branches for class explosion).
The diagnostic question for all of these is the same: what principle is the size violating? Fix the cause; length normalizes downstream.
Functions
- Do one thing at one level of abstraction
- No flag arguments (
foo(x, true)). Split into fooA(x) and fooB(x)
- Command-Query Separation: a function either does something or returns something, not both
- Extract until you cannot extract any more meaningful name
- Use guard clauses and early returns to flatten nesting
- Inline logic inside
if conditions — e.g. if (user.age > 18 && user.country === "US" && !user.banned && user.subscription.status === "active") — extract to a named predicate (isEligibleForDiscount(user)). The condition is asking to become a name.
- Nested function declarations inside methods — a declared
function foo() or const foo = () => used only once inside its parent that carries real logic → Extract Method to the enclosing class/module scope. A method must not hide other methods in its body. Trivial inline callbacks passed to higher-order functions are fine.
Names
- Reveal intent; if a comment explains the name, rename instead
- Pronounceable and searchable (
elapsedTimeMs, not etm)
- No type/scope encoding (no Hungarian, no
m_, no I prefix on interfaces)
- Classes = nouns, methods = verbs
- Same concept → same word everywhere in the codebase
- Replace magic numbers and magic strings with named constants
Comments
- Default: don't write one. A good name makes it redundant.
- Legitimate comments: non-obvious why, legal/licensing, public API contracts, warnings about subtle invariants
- Illegitimate: restating the code, commented-out code, changelog-in-file, "added by X for ticket Y"
- Never leave commented-out code. Delete it — version control remembers.
Error handling
- Prefer exceptions / Result types over error codes (in languages that support them)
- Never return
null for collections — return empty
- Don't let
null cross module boundaries; validate at the edge
- Never swallow exceptions — handle, wrap with context, or rethrow
- Exception messages state what was attempted, with what inputs, why it failed
- Define exception types around the caller's needs, not the thrower's internals
- Never use exceptions for control flow
- Fail fast. Make illegal states unrepresentable in the type system where possible.
Formatting
- Related concepts vertically close; unrelated separated by blank lines
- Declare variables near first use
- Caller above callee — top-down reading order
Boy Scout Rule: leave the campsite cleaner. Small cleanups inside the scope you're already touching. Not a license for drive-by refactors. Never mix refactoring with behavior change in the same commit.
4. Fowler's Code Smells → Refactoring
Scan design and code for these. Each smell has a canonical move.
| Smell |
Fix |
| Long Method |
Extract Method with intention-revealing name |
| Large Class |
Extract Class / Extract Subclass — usually hides SRP violation |
| Long Parameter List |
Introduce Parameter Object / Preserve Whole Object |
| Divergent Change (one class, many reasons to change) |
Extract Class — split by reason-to-change |
| Shotgun Surgery (one change touches many classes) |
Move Method/Field — find the missing abstraction |
| Feature Envy (method uses another class's data more than its own) |
Move Method |
| Data Clumps (same fields travel together) |
Extract Class / Introduce Parameter Object |
| Primitive Obsession (strings/ints carrying domain meaning) |
Replace Primitive with Value Object (UserId, Money, EmailAddress) |
| Switch Statements (branching on type, repeated) |
Replace Conditional with Polymorphism — only if type varies at runtime |
| Speculative Generality (unused hooks, "just in case" abstractions) |
Collapse Hierarchy / Inline Class |
| Temporary Field (field set only sometimes) |
Extract Class for the when-set state |
Message Chains (a.b().c().d()) |
Hide Delegate (Law of Demeter) |
| Middle Man (class just delegates) |
Remove Middle Man |
| Data Class (fields + getters/setters, no behavior) |
Move behavior to where data lives (anemic domain model fix) |
| Refused Bequest (subclass ignores parent's API) |
Replace Inheritance with Delegation |
| Comments (explaining what) |
Extract Method / Rename — the comment is asking to become a name |
| Dead Code |
Delete it. Unused = noise. |
| Mysterious Name |
Rename. If you can't name it, you don't understand it yet. |
5. Architectural principles
- Dependency direction: inward. Stable abstractions at the core; volatile details (DB, HTTP, time, filesystem) at the edges. Domain never imports infrastructure.
- Separation of concerns: UI ≠ domain ≠ persistence. Put them in separate modules and keep them there.
- Isolate volatility: wrap I/O, time, randomness, and third-party SDKs behind a thin seam so tests don't need them.
- Pure core, imperative shell: business rules as pure functions where possible; push mutation, I/O, and time outward. Makes the core trivially testable.
- Tell, don't ask: send commands to objects; don't pull their state out to decide for them.
- Law of Demeter: talk to friends, not strangers. One dot per expression as a soft target.
- Composition root: wire dependencies in exactly one place (main / factory / DI container). Business code never news up its collaborators.
6. Anti-patterns to actively reject
- God objects / utility dumps (
Helpers, Utils, Manager) — naming is the tell; the class has no cohesion
- Anemic domain model — behavior orbiting data classes instead of living with them
- Leaky abstractions — repository returns SQL rows; HTTP client exposes response objects
- Circular dependencies between modules — always a design error, never "fine for now"
- Temporal coupling —
init() then start() then use(); ordering not enforced by types. Make illegal states unrepresentable.
- Premature generalization — generics, plugins, config knobs, strategy patterns with one strategy
- Over-mocking — if tests mock everything, the design leaks. Test with real collaborators where cheap; mock only process/network boundaries.
- Stringly-typed APIs —
doThing("create", "user", "v2") instead of types/enums
- Global mutable state — singletons holding data, module-level mutables. Pass dependencies explicitly.
7. Self-critique — answer before finalizing
A Medium/Complex design is not ready until these have concrete answers:
- Reason to change per component — list the one reason for each. Any with two? Split.
- Change-impact radius — pick the two most likely future requirements. How many components change? >2 signals wrong boundaries.
- Dependency direction — does any arrow point from stable → volatile? Invert.
- Abstraction justification — for each interface/base class: name the concrete second implementation. If none exists or is planned, delete the abstraction.
- Deletability test — which components could be deleted with minimal loss? Delete them.
- Name smell — any
Manager, Helper, Utils, Processor, Handler, Data, Info? Rename to the actual responsibility, or admit it's a god class and split.
- Testability — for each piece of logic, how is it tested without booting the whole system?
- What would a reviewer flag? Write 3 concerns a strict reviewer would raise. Address or accept them consciously.
8. When to escalate the design
Stop and push back on the requirement itself when:
- The feature requires breaking a core boundary (domain imports DB, etc.)
- Implementing it would require weakening LSP on a widely-used hierarchy
- The "simplest" implementation introduces a circular dependency
- No amount of refactoring yields a design where SRP holds
The right fix may be to renegotiate scope, not to engineer around a broken requirement.
9. Tests
Tests are production code — same cleanliness bar.
- F.I.R.S.T. — Fast, Independent, Repeatable, Self-validating, Timely
- AAA — Arrange, Act, Assert, visually separated
- One assertion concept per test. Names describe behavior (
shouldRejectNegativeAmount, not test1)
- No
sleep. No order dependencies. No shared mutable state between tests.
- Test behavior, not implementation. Don't assert on private internals — you lock in the shape, not the contract.
- Mock only at process / network / time boundaries. Use real collaborators elsewhere (see §6: over-mocking).
- If a unit is hard to test, the design is wrong. Fix the design, not the test.
10. Agent behavior when applying this rubric
- Make the minimum diff that satisfies the request. No reformatting or restructuring of unrelated code in the same change.
- Boy Scout applies only to files you are already editing. Flag problems elsewhere; do not silently expand scope.
- Never mix refactoring with behavior change in a single commit. Separate them.
- When this rubric conflicts with existing project style, match project style and note the deviation — do not propagate it further without explicit agreement.
- When uncertain whether a rule applies, stop and ask. Do not guess.
- Never introduce a new rubric violation to ship faster. If a shortcut is the only viable path, name it explicitly in the PR/commit message so it is visible and tracked.
- State trade-offs out loud when two rules genuinely pull opposite ways — don't silently pick one.
1---2name: design-rubric3description: Design Rubric4---56# Design Rubric78A forcing function. Answer the questions — don't just recite the principles.910If a conflict arises between rules, the **Tie-Breakers** section wins.1112---1314## 1. Tie-Breakers (resolve conflicts first)1516| Tension | Default winner | Why |17|---------|----------------|-----|18| KISS vs DRY | **KISS** until 3 occurrences | Two occurrences ≠ a pattern. Premature DRY couples unrelated things. |19| YAGNI vs OCP | **YAGNI** | Don't open for extensions you can't name. Add seams when the second need arrives. |20| Composition vs inheritance | **Composition** | Inherit only when LSP genuinely holds and you need subtype polymorphism. |21| Explicit vs clever | **Explicit** | Read 10×, write 1×. Clever loses. |22| Abstraction vs duplication | Abstract only if it **removes more complexity than it adds** | A wrong abstraction is costlier than duplication. |23| Decomposition vs readability | **Readability** | Split for responsibilities, never to hit a line count. Five fragments you must chase across a file read worse than one cohesive method that flows top-to-bottom. |24| Consistency vs correctness | **Correctness** | Don't propagate a bad pattern for symmetry. Fix the pattern. |25| Correctness vs performance | **Correctness** | Measure before optimizing. A fast wrong answer is still wrong. |2627---2829## 2. SOLID — as questions, per component3031Apply to every non-trivial component in the design.3233- **SRP** — *What is the one reason this changes?* If you list two, split. "Changes for reason A **and** reason B" = two components.34- **OCP** — *What variation is actually coming?* If you can't name a concrete next requirement, **no abstraction yet**. Add the seam with the second use case, not the first.35- **LSP** — *Does every subtype honor the base contract in behavior, not just signature?* Throwing `UnsupportedOperation`, tightening preconditions, or weakening postconditions = violation.36- **ISP** — *Would any client use <50% of this interface's methods?* Split. Fat interfaces force fake implementations.37- **DIP** — *Does this module depend on something more volatile than itself?* Invert. Policy should not depend on mechanism; domain should not depend on I/O.3839---4041## 3. Clean Code (Uncle Bob) — concrete checks4243**Size & complexity — reason from principles, not line counts**4445Length is a symptom. Cohesion, single responsibility, and one-level-of-abstraction are the diseases. Ask which principle a too-big unit is breaking; don't gate on LOC.4647- **Methods / functions** — Uncle Bob: "small, then smaller." A method does **one thing at one level of abstraction**. Diagnostic: can you extract a chunk and give it a meaningful name *that pulls its weight*? Then it was doing more than one thing. Fowler's working heuristic: if it doesn't fit on screen without scrolling, your working memory is already fragmented reading it. Line counts are orientation, not a gate — a long method that does one thing and reads cleanly top-to-bottom is fine; a short one interleaving three concerns is not. The opposite mistake is just as real: shredding a cohesive sequence into a scatter of single-use helpers you must chase across the file hurts readability more than length ever did. Extract for a distinct responsibility, never to hit a number.4849- **Classes** — length is the wrong question. The right one: describe the class's responsibility in one sentence **without "and" or "or."** Can't? Split. Cohesion test: do most methods use most fields, or do you see method clusters using disjoint field clusters? Disjoint = two classes pretending to be one (LCOM smell). Reason-to-change test: list every reason this class would change; more than one = SRP violation. A class hitting several hundred LOC almost always means SRP is broken — but treat the SRP failure as the bug, not the line count.5051- **Files** — follow class size. Default: one public class per file. Acceptable exceptions: tightly coupled types — sealed hierarchies, value object families, internal helpers used only by the file's main class. Multiple unrelated types in one file → split.5253- **Nesting depth** — each level is an obscured decision the reader must hold in working memory. Past ~2 levels the mental stack overflows. Cure is structural: **Guard clauses** for input validation, **Extract Method** to give a chunk a name and a single return value, **Replace Conditional with Polymorphism** when the nesting follows a runtime type discriminator.5455- **Function parameters** — each parameter is a coupling point. Uncle Bob: "ideal is zero, three is a lot." Two specific smells: **Data Clumps** (parameters that always travel together → Parameter Object) and **flag arguments** (hidden if-statements → split the function).5657- **Cyclomatic complexity** — each branch multiplies the test surface and the cognitive load. High CC is the math behind why **Long Method** is a smell. Same refactorings: Extract Method, Replace Conditional with Polymorphism (only when the type varies at runtime — otherwise you're trading branches for class explosion).5859The diagnostic question for all of these is the same: **what principle is the size violating?** Fix the cause; length normalizes downstream.6061**Functions**62- Do one thing at one level of abstraction63- **No flag arguments** (`foo(x, true)`). Split into `fooA(x)` and `fooB(x)`64- **Command-Query Separation:** a function either does something *or* returns something, not both65- Extract until you cannot extract any more *meaningful* name66- Use guard clauses and early returns to flatten nesting67- **Inline logic inside `if` conditions** — e.g. `if (user.age > 18 && user.country === "US" && !user.banned && user.subscription.status === "active")` — extract to a named predicate (`isEligibleForDiscount(user)`). The condition is asking to become a name.68- **Nested function declarations inside methods** — a declared `function foo()` or `const foo = () =>` used only once inside its parent that carries real logic → Extract Method to the enclosing class/module scope. A method must not hide other methods in its body. Trivial inline callbacks passed to higher-order functions are fine.6970**Names**71- Reveal intent; if a comment explains the name, rename instead72- Pronounceable and searchable (`elapsedTimeMs`, not `etm`)73- No type/scope encoding (no Hungarian, no `m_`, no `I` prefix on interfaces)74- Classes = nouns, methods = verbs75- Same concept → same word everywhere in the codebase76- Replace magic numbers and magic strings with named constants7778**Comments**79- Default: **don't write one.** A good name makes it redundant.80- Legitimate comments: non-obvious *why*, legal/licensing, public API contracts, warnings about subtle invariants81- Illegitimate: restating the code, commented-out code, changelog-in-file, "added by X for ticket Y"82- Never leave commented-out code. Delete it — version control remembers.8384**Error handling**85- Prefer exceptions / Result types over error codes (in languages that support them)86- Never return `null` for collections — return empty87- Don't let `null` cross module boundaries; validate at the edge88- Never swallow exceptions — handle, wrap with context, or rethrow89- Exception messages state what was attempted, with what inputs, why it failed90- Define exception types around the **caller's needs**, not the thrower's internals91- Never use exceptions for control flow92- Fail fast. Make illegal states unrepresentable in the type system where possible.9394**Formatting**95- Related concepts vertically close; unrelated separated by blank lines96- Declare variables near first use97- Caller above callee — top-down reading order9899**Boy Scout Rule:** leave the campsite cleaner. Small cleanups inside the scope you're already touching. Not a license for drive-by refactors. **Never mix refactoring with behavior change in the same commit.**100101---102103## 4. Fowler's Code Smells → Refactoring104105Scan design and code for these. Each smell has a canonical move.106107| Smell | Fix |108|-------|-----|109| **Long Method** | Extract Method with intention-revealing name |110| **Large Class** | Extract Class / Extract Subclass — usually hides SRP violation |111| **Long Parameter List** | Introduce Parameter Object / Preserve Whole Object |112| **Divergent Change** (one class, many reasons to change) | Extract Class — split by reason-to-change |113| **Shotgun Surgery** (one change touches many classes) | Move Method/Field — find the missing abstraction |114| **Feature Envy** (method uses another class's data more than its own) | Move Method |115| **Data Clumps** (same fields travel together) | Extract Class / Introduce Parameter Object |116| **Primitive Obsession** (strings/ints carrying domain meaning) | Replace Primitive with Value Object (`UserId`, `Money`, `EmailAddress`) |117| **Switch Statements** (branching on type, repeated) | Replace Conditional with Polymorphism — **only if type varies at runtime** |118| **Speculative Generality** (unused hooks, "just in case" abstractions) | Collapse Hierarchy / Inline Class |119| **Temporary Field** (field set only sometimes) | Extract Class for the when-set state |120| **Message Chains** (`a.b().c().d()`) | Hide Delegate (Law of Demeter) |121| **Middle Man** (class just delegates) | Remove Middle Man |122| **Data Class** (fields + getters/setters, no behavior) | Move behavior to where data lives (anemic domain model fix) |123| **Refused Bequest** (subclass ignores parent's API) | Replace Inheritance with Delegation |124| **Comments** (explaining *what*) | Extract Method / Rename — the comment is asking to become a name |125| **Dead Code** | Delete it. Unused = noise. |126| **Mysterious Name** | Rename. If you can't name it, you don't understand it yet. |127128---129130## 5. Architectural principles131132- **Dependency direction:** inward. Stable abstractions at the core; volatile details (DB, HTTP, time, filesystem) at the edges. Domain never imports infrastructure.133- **Separation of concerns:** UI ≠ domain ≠ persistence. Put them in separate modules and keep them there.134- **Isolate volatility:** wrap I/O, time, randomness, and third-party SDKs behind a thin seam so tests don't need them.135- **Pure core, imperative shell:** business rules as pure functions where possible; push mutation, I/O, and time outward. Makes the core trivially testable.136- **Tell, don't ask:** send commands to objects; don't pull their state out to decide for them.137- **Law of Demeter:** talk to friends, not strangers. One dot per expression as a soft target.138- **Composition root:** wire dependencies in exactly one place (main / factory / DI container). Business code never news up its collaborators.139140---141142## 6. Anti-patterns to actively reject143144- **God objects / utility dumps** (`Helpers`, `Utils`, `Manager`) — naming is the tell; the class has no cohesion145- **Anemic domain model** — behavior orbiting data classes instead of living with them146- **Leaky abstractions** — repository returns SQL rows; HTTP client exposes response objects147- **Circular dependencies** between modules — always a design error, never "fine for now"148- **Temporal coupling** — `init()` then `start()` then `use()`; ordering not enforced by types. Make illegal states unrepresentable.149- **Premature generalization** — generics, plugins, config knobs, strategy patterns with one strategy150- **Over-mocking** — if tests mock everything, the design leaks. Test with real collaborators where cheap; mock only process/network boundaries.151- **Stringly-typed APIs** — `doThing("create", "user", "v2")` instead of types/enums152- **Global mutable state** — singletons holding data, module-level mutables. Pass dependencies explicitly.153154---155156## 7. Self-critique — answer before finalizing157158A Medium/Complex design is not ready until these have concrete answers:1591601. **Reason to change per component** — list the one reason for each. Any with two? Split.1612. **Change-impact radius** — pick the two most likely future requirements. How many components change? >2 signals wrong boundaries.1623. **Dependency direction** — does any arrow point from stable → volatile? Invert.1634. **Abstraction justification** — for each interface/base class: name the *concrete* second implementation. If none exists or is planned, delete the abstraction.1645. **Deletability test** — which components could be deleted with minimal loss? Delete them.1656. **Name smell** — any `Manager`, `Helper`, `Utils`, `Processor`, `Handler`, `Data`, `Info`? Rename to the actual responsibility, or admit it's a god class and split.1667. **Testability** — for each piece of logic, how is it tested without booting the whole system?1678. **What would a reviewer flag?** Write 3 concerns a strict reviewer would raise. Address or accept them consciously.168169---170171## 8. When to escalate the design172173Stop and push back on the requirement itself when:174- The feature requires breaking a core boundary (domain imports DB, etc.)175- Implementing it would require weakening LSP on a widely-used hierarchy176- The "simplest" implementation introduces a circular dependency177- No amount of refactoring yields a design where SRP holds178179The right fix may be to renegotiate scope, not to engineer around a broken requirement.180181---182183## 9. Tests184185Tests are production code — same cleanliness bar.186187- **F.I.R.S.T.** — Fast, Independent, Repeatable, Self-validating, Timely188- **AAA** — Arrange, Act, Assert, visually separated189- One assertion concept per test. Names describe behavior (`shouldRejectNegativeAmount`, not `test1`)190- No `sleep`. No order dependencies. No shared mutable state between tests.191- Test behavior, not implementation. Don't assert on private internals — you lock in the shape, not the contract.192- Mock only at process / network / time boundaries. Use real collaborators elsewhere (see §6: over-mocking).193- If a unit is hard to test, the design is wrong. Fix the design, not the test.194195---196197## 10. Agent behavior when applying this rubric198199- Make the **minimum diff** that satisfies the request. No reformatting or restructuring of unrelated code in the same change.200- Boy Scout applies **only to files you are already editing**. Flag problems elsewhere; do not silently expand scope.201- Never mix refactoring with behavior change in a single commit. Separate them.202- When this rubric conflicts with existing project style, match project style and note the deviation — do not propagate it further without explicit agreement.203- When uncertain whether a rule applies, stop and ask. Do not guess.204- Never introduce a new rubric violation to ship faster. If a shortcut is the only viable path, name it explicitly in the PR/commit message so it is visible and tracked.205- State trade-offs out loud when two rules genuinely pull opposite ways — don't silently pick one.