nw-code-design-oo · git:20261102.c67d57b · 2026-11-02 · sha256 7e2c87714573f6fd

nw-code-design-oo git:20261102.c67d57bA

Immutable. This exact content never changes and is served at /api/v1/blob/7e2c87714573f6fd.

---
name: nw-code-design-oo
description: OO code-design SSOT — the WHAT-to-design anti-smell catalog (Object Calisthenics, RPP smell taxonomy, effect isolation) shared by the solution architect (design-time) and the crafter (execution-time).
---

# OO Code Design

Design-time anti-smell catalog for object-oriented work. The architect loads this
to design smell-free domain types and module boundaries BEFORE DISTILL authors
ATs; the crafter loads the same SSOT for the execution mechanics that enforce it.
This file is the WHAT-to-design subset. `nw-tdd-methodology` owns RED→GREEN
verification; `nw-refactor` owns behavior-preserving refactoring. Repository
runner and hooks own build, test, and commit execution.

## Object Calisthenics

Ten rules for the OO core (Agile Technical Practices Distilled, "Object
Calisthenics – 10 Steps to Better Software Design"). Inside the hexagon, domain
objects and application services, they are strict. Breaking one is a conscious
design decision recorded in the diagnostic with the smell it causes, its benefit
and its cost; an unrecorded break is accidental complexity.

| # | Rule | Why | Smell it prevents |
|---|------|-----|-------------------|
| 1 | Only one level of indentation per method | One thing per method; small reusable units | Long Method |
| 2 | Don't use the ELSE keyword | One main lane with guard clauses; polymorphism (State, Strategy) or Null Object for the alternatives | Switch Statements, Long Method |
| 3 | Wrap all primitives and strings | No primitive or string as a method argument (except constructors) or return value; each concept gets a type that attracts its behaviour and makes wrong state unrepresentable | Primitive Obsession, Shotgun Surgery |
| 4 | First-class collections | No collection as an argument (except constructors); a class that holds a collection holds nothing else; filters and joins live there | Primitive Obsession, Feature Envy |
| 5 | One dot per line | Talk only to your immediate collaborators (Law of Demeter): self, owned fields, parameters and objects the method creates. The rule is about navigating a collaborator's internals, not LINQ, streams or fluent builders | Message Chains, Inappropriate Intimacy |
| 6 | Don't abbreviate | Names reveal intention; a name you want to abbreviate signals a unit doing too much | Large Class |
| 7 | Keep all entities small | Each method does one thing and each class holds one responsibility; size follows from cohesion, never from splitting code to meet a line count | Large Class, Long Method |
| 8 | No classes with more than two instance variables | High cohesion; a third field signals a missing collaborator | Large Class, Data Clumps |
| 9 | No getters/setters/properties | Tell, don't ask: the decision lives on the object that owns the data | Data Class, Feature Envy |
| 10 | All classes must have state | No static utility classes or stateless "managers"; behaviour has a home | Feature Envy, Middle Man |

**Scope**: domain entities, value objects and application services. Adapters,
infrastructure, DTOs at port boundaries, CQRS read models, configuration and
framework or ORM mapping are exempt; map them to the domain at the boundary.

**Services**: an application service coordinates ports and domain objects and
owns no domain rule. A decision about one object's data lives on that object; a
policy spanning several aggregates may live in a domain service. An application
service method that reads an object's state, branches on it and writes it back
is a transaction script; move the decision onto the owner.

**Rich domain model**: entities carry identity and the behaviour of their
lifecycle; value objects are immutable, equal by value and refuse an invalid
value at construction; an aggregate root is the only entry point that changes
its aggregate and guards its invariants in one consistency boundary; a
repository loads and stores one aggregate; a domain event names a completed
fact. `nw-ddd-tactical` owns these building blocks and their rules.

## Code Smells Hierarchy

Primitive Obsession, Feature Envy (with Inappropriate Intimacy) and Message
Chains cause most other smells; remove them first.

- **Primitive Obsession** → Duplicated Code, Shotgun Surgery, Divergent Change, Long Parameter List.
- **Feature Envy / Inappropriate Intimacy** → Lazy Class, Data Class, Middle Man, Data Clumps, Long Method, Large Class, Duplicated Code, Shotgun Surgery, Divergent Change, Long Parameter List.
- **Message Chains** → Data Clumps, Duplicated Code.

## Refactoring Priority Premise

Refactor each delta in this order, smallest level first, without changing
behaviour; the refactoring is verified once, together with the delta. A level is
done when none of its smells remains in the code you changed. `nw-refactor` owns
the mechanics.

| Level | Goal | Smells it cleans |
|-------|------|------------------|
| L1 Readability | Tackle clutter, comments, dead code, implicit knowledge, scattering, bad naming | Comments, Dead Code, Magic Strings and Numbers, Scope of variables and methods |
| L2 Complexity | Tackle complexity and duplication | Long Method, Duplicated Code |
| L3 Responsibilities | Tackle misplaced responsibilities | Long Class, Feature Envy, Inappropriate Intimacy, Data Class, Message Chain |
| L4 Abstractions | Tackle missing abstractions | Long Parameter List, Data Clump, Primitive Obsession, Middle Man |
| L5 Design Patterns | Replace conditionals with polymorphism; apply a pattern only as the target of a refactoring the actual recurring problem asks for, shaped to the context and named for the domain behaviour | Switch Statements → Dictionary/Hashmap, Strategy, State, Command |
| L6 SOLID++ | Fix the design principles | Refused Bequest → LSP, ISP; Divergent Change → SRP; Shotgun Surgery → SRP; Speculative Generality → YAGNI; Parallel Inheritance → ISP |

**Liskov substitution**: a subtype or interface implementation keeps every
promise to its clients: no stronger preconditions, no weaker postconditions,
invariants preserved. **Interface segregation**: an interface is one cohesive
role its clients need; no client depends on an operation it does not use.
Inheritance expresses substitutable specialization only; other collaboration
uses composition.

## RPP Smell Taxonomy

Smells an architect detects in a design before code is written, and the design
decision each one asks for. Detection is design-time; the refactoring order above
and `nw-refactor` belong to the crafter.

| Level | Smell | Design-time implication |
|-------|-------|-------------------------|
| L3 | Primitive Obsession | Raw int/str carrying domain meaning → introduce a value object |
| L3 | Magic Numbers | Bare literal with hidden semantics → name it (enum / constant / value object) |
| L3 | Tell-Don't-Ask | Caller queries state then decides → move the decision onto the owning object |
| L3 | Feature Envy | Method reaches into another type's data → relocate behavior to that type |
| L3 | God Object | One type owns too many responsibilities → split along bounded responsibilities |
| L3 | Large Class | Class owns more than one cohesive responsibility → extract collaborators |
| L4 | Shotgun Surgery | One change forces edits across many sites → consolidate the concept into one home |

Design rule: a smell detected at design time does not survive into the design
artifact unless it is a recorded conscious trade-off. Resolve it in the type and
boundary decisions, not at DELIVER.

## Effect Isolation

Design components so the bug class "side-effect-free function silently writes" is
non-representable, not testable-around. Push contract enforcement up the type
hierarchy as far as the language allows. Three levers:

- **Functional Core / Imperative Shell** (Bernhardt) — business logic in pure
  functions; effects only at a thin shell. Approximates IO-monad separation in
  any language.
- **Plan-value pattern** — dry-run / preview / validate functions return a `Plan`
  data value, never silent side effects (`dry_run(cfg) -> Plan` pure; `execute(plan)`
  the only impure call). Makes "preview wrote to disk" structurally impossible.
- **Capability injection** — pass restricted interfaces (`PlanRecorder`,
  `SafeFileSystem(root=tmp)`) at boundaries, never god-objects (`os`,
  `Path.home()`). Approximates capability typing via DI.

Per component, declare a **contract shape**: pure-function (return-only),
bounded-change (declared mutation set), or unbounded-preservation (must return a
Plan, never mutate). A driving port that "only reads" must not expose write
methods — split read/write into separate ports.

### Declared inputs at the boundary

The OO reading of `contract:declared-inputs-not-ambient-reads` (SSOT:
`nw-cross-cutting-invariants` — the gate list and the anchor live there, not
here). It is `Capability injection` above restated as a review question, because
that pattern is easy to agree with and easy not to apply.

> **What does this component read that nobody passed it?** For every gate the
> clause lists, decide at the boundary: a constructor parameter, an injected
> capability, or an explicit override — with the ambient lookup kept as a default
> the caller may state.

Resolve it **where the caller can see it**. A gate resolved lazily, at first use
deep inside a method, is read outside the window in which the caller — or a test
— controls the environment, so even a caller that wanted to declare it cannot.

Test-side mirror: the Algebraic Analysis Before the Scenario mandate
(`nw-test-design-mandates`), its declared-inputs question.

### Enumerate the outcomes before choosing the return type

> **How many outcomes does this operation have — and does its signature carry
> every one of them?** "It returns the value and raises on the bad case" is the
> wrong answer: that is N-1 outcomes in the type and one in the control flow.
> **If any outcome is not in the return type, put it there** — a value the caller
> branches on, with the illegal combinations unconstructible (enforce in the
> constructor: only the outcome that carries a payload may carry one, and it
> always does).

Why this is a design rule, not a style preference: `except` matches on class
**identity**, so a raised outcome couples the caller to the exact module object
the raiser was loaded from. Where the same package is reachable by more than one
path — a source tree plus an installed runtime, a harness that adjusts
`sys.path`, a plugin cache — that identity is not guaranteed. The `except` then
fails to match and a *handled* outcome escapes as a crash, in an environment
nobody was testing.

Keep `raise` for what it is good at: a **programming error** (a broken
precondition, an import-time drift guard) where crashing is the correct outcome
and no `except` is meant to cross a module boundary to catch it.

FP states the canonical form of the same rule — a total function into a sum type
(`nw-code-design-fp` § Railway, "Count the outcomes before you choose the return
type"). This is its OO translation.

Empirical anchor, 2026-08-06: CI reported `UnknownPhaseName`
propagating out of `_resolve` from the line INSIDE its own `try`, with the
matching `except` on the next line. `resolve_phase` documented three outcomes,
returned two, threw the third. Returning all three closed the failure class
*without* establishing why the module loaded twice — a fix that does not depend
on that answer is a design fix rather than a patch.

## Cross-cutting invariants (load them — they are not restated here)

Paradigm- and role-independent rules live in ONE shipped home: `nw-cross-cutting-invariants`.
Load it alongside this skill and honour these clauses by id — they are NOT duplicated here:

- `data:consumer-known-before-produced` — a datum is produced only because a named consumer
  reads it, and you must name the JOIN KEY it will be related on. No reader, or no key → the
  datum is unjustified.
- `gate:self-explaining-what-why-how` — every rejection states WHAT / WHY / HOW.
- `gate:design-principles-gdp-1-9` — the canonical gate-design contract.