Design rules for Python code, for people and for coding agents. The same rules
drive the python-linter-driven-development plugin; this file is the version you can read in ten minutes
and keep open while you work, with or without the plugin. Import it from your
CLAUDE.md or AGENTS.md, or read it once before your first PR.
Before a rule fires, ask the question behind it:
- Tell, don't ask. What will the caller do with the value it is requesting — and does that decision belong on the type that owns the value?
- Make illegal states unrepresentable. Can this type hold a value its methods would have to defend against? Delete the possibility, not the symptom.
- Parse, don't validate. Does this check produce a more-typed value (a parse function that returns the value or fails), or just a boolean the next caller must remember? Validation that returns proof is parsing; validation that returns advice is a latent re-check.
- Every indirection must earn its keep. What does this indirection own — a validation, a decision, a second production implementation, a deleted duplication, a real race, a real escaping alias? If the answer is nothing, it is ceremony: delete it.
- Duplication is far cheaper than the wrong abstraction. Is this extraction earning its indirection today, with the callers in hand — or is it a bet on imagined futures?
- Three strikes and you refactor. How many real occurrences exist right now? One is an instance, two is a coincidence, three is a pattern.
- If a test is hard to write, the design is wrong. What is the test's pain telling you? Huge fixtures → the unit is too big; global mutation → a seam is missing; mocks everywhere → the boundaries are wrong. Never silence test pain with test machinery.
- Clear is better than clever. Will a reader get this in 10–15 seconds? Cleverness is a cost paid by every future reader.
- When in Rome, code as the Romans do. Does this diff arrive in the host repo's existing style, or does it import mine? A new test mechanism, dependency, framework, or convention — however good — is an adoption decision that belongs to the repo owner, not a side effect of a feature PR. Strong opinions travel as a discussion or a separate PR, never as a bundled surprise. Reviewers forgive imperfect code in the house style far more readily than perfect code in a foreign one.
Each rule is stated once, with one Python example and the names of the moves that fix it. The moves are shared vocabulary across every language these rules are rendered for: reviewers cite them by name. Where a rule cites R1's scorecard, the short form is: a type earns its keep by owning validation, behavior or an invariant, and scores zero when its only method unwraps the primitive; the full scorecard lives in the plugin's R1 rule.
Domain concepts must not travel as raw strings, numbers, booleans or lists. When a primitive carries validation rules, behavior, or a domain name, it becomes a type with a validating constructor and named methods. The inverse binds equally: a wrapper that adds no validation, no logic, and no invariant is over-abstraction — score before you wrap.
# ❌ the rule lives at the call site, twice, and 0 means "none"
def management_port(self) -> int:
for p in self.spec.ports:
if p.name == "weka-api" and 0 < p.port <= 65535:
return p.port
for p in self.spec.ports:
if 0 < p.port <= 65535:
return p.port
return 0
# ✅ a Port cannot exist out of range; "first valid" collapses to "first"
@dataclass(frozen=True, slots=True)
class Port:
name: str
number: int
def __post_init__(self) -> None:
if not 0 < self.number <= 65535:
raise ValueError(f"port {self.name!r}: {self.number} out of range 1-65535")
class Ports:
def first_named(self, name: str) -> Port | None: ...
def first(self) -> Port | None: ...In Python: absence is a declared
-> Port | Nonethat every caller narrows, checked by mypy;dict.getbesidedict[k]is the model. A0,""orNonereturned from a signature that promisesPortis a sentinel and a finding, and# type: ignore[return-value]is its silenced form. Never atuple[Port, bool].typing.NewType("PortNumber", int)scores zero on the scorecard: it admits every literal.
Moves: Replace Primitive with Domain Type · Extract Collection Type · Replace Sentinel with Declared Absence · Name enum strings · Introduce Parameter Object
A type validates its own invariants in its constructor — the only way to obtain a value — and every method thereafter trusts the receiver. Validation ownership never sits upstream: a type that relies on callers to have validated for it is not self-validating, whatever its fields look like.
# ❌ optional collaborator kept None-able; every method re-asks the question
class Reporter:
def __init__(self, sink: Sink | None = None) -> None:
self.sink = sink
def record(self, ev: Event) -> None:
if self.sink is not None:
self.sink.write(ev)
# ✅ absence is a named value bound once; no argument is ever None
NULL_SINK = NullSink() # a real Sink whose write() discards
class Reporter:
def __init__(self, *, sink: Sink = NULL_SINK) -> None:
self._sink = sink
def record(self, ev: Event) -> None:
self._sink.write(ev) # no guard anywhereIn Python: the self-validating type is the frozen dataclass with
__post_init__shown under R1, or aparseclassmethod that normalises then constructs. Public read-only fields are fine: a literal is not a hole because__post_init__runs on every construction. The finding is a mutable dataclass carrying invariants with no__post_init__. Where the repository already uses pydantic it is the boundary form, andmodel_constructandmodel_copy(update=)are its bypasses; a refactor never introduces pydantic. The fence above is the optional-collaborator case: a Null Object bound once as a module constant, never aNonethe methods guard.
Moves: Add validating constructor · Hoist method checks into the constructor · Introduce Null Object · Delete re-validation of composed types · Separate Failure from Absence
A top-level function reads like a story: every step is a named call at the same conceptual level, and the whole flow is graspable at a glance. Method calls never mix with string/index manipulation in the same body. A comment that names a block of code is a function name waiting to be extracted.
# ❌ flags track the loop, comments name the blocks, the policy is nowhere stated
def upsert_iface_addr_host(self, iface: Interface) -> None:
ip4_added = False
ip6_added = False
for a in iface.addrs():
if not isinstance(a, IPv4Interface | IPv6Interface) or not a.ip.is_global:
continue
if a.ip.version == 6: # validate IP6
if ip6_added: # already added. skip
continue
...
...
# ✅ the story in three named steps; the loop moved onto a type that owns it
def upsert_iface_addr_host(self, iface: Interface) -> None:
addrs = GlobalAddresses.from_interface(iface)
self._align_ipv4(addrs.first_v4())
self._align_ipv6(addrs.first_v6())In Python: a name reveals its side effect:
align_/upsert_/set_mutate,parse_/validate_/is_never do. Aparse_ip4that writesself.ip4is a storifying bug even when the flow reads well.
Moves: Extract Function named after the comment · Extract Leaf Type · Replace Nesting with Early Returns · Split Phase · Honest Rename
Every extraction raises a second question: where does the helper live? The answer is decided by two axes — juiciness (the scorecard in R1; cite it, never re-derive it) and scope (feature-specific versus domain-generic). Three rungs: underscore-prefixed in place, feature sub-package, shared domain package. Never test privates, and never export a helper into its parent package just so a test can reach it.
# ❌ imported by the test through its private name, so it stays where it should not
from app.k3s._args import _parse_k3s_argument
# ✅ rung 1: one caller, no vocabulary of its own;
# covered through the public API that calls it
def _parse_k3s_argument(arg: str) -> tuple[str, str] | None: ...
# ✅ rung 3: networking vocabulary with several callers → its own package,
# named for the domain
from app.networking import Port, PortsIn Python: the leading underscore is a convention, not a wall, so a test can import
_parse_row. That it can is not a reason it should: the urge is a placement signal, and the helper wants its own module with a public name. Rung 2 is the feature package, whose__init__.pyre-exports the slice's public names and lists them in__all__; rung 3 a shared package under the source root. There is nointernal/; the underscore and__all__carry visibility.
Moves: Demote (rung 1) · Promote to feature sub-package (rung 2) · Promote to domain package (rung 3) · Split policy from vocabulary during promotion · Move Method to the Envied Type
Group code by feature and role, not by technical layer: bad — domain/rotator,
services/rotator; good — rotator/parser.py, rotator/handler.py. All code for a
feature lives in one package, internally separated by role within it. Package names
are flatcase domain vocabulary — never a layer or role name.
❌ by layer ✅ by feature, roles inside
src/app/models/rotator.py src/app/rotator/__init__.py # the slice's public names, in __all__
src/app/services/rotator_service.py src/app/rotator/rotator.py
src/app/repositories/rotator_repo.py src/app/rotator/parser.py
src/app/handlers/rotator_handler.py src/app/rotator/handler.py
src/app/rotator/repository.py
In Python: the package name is the feature noun,
rotator, neverservicesormodels.utils.py,common.pyandhelpers.pyname no vocabulary at all: the first function that lands there has no owner, and the next lands beside it because the first did. Role names (parser.py,handler.py) live in module names inside the slice. Each type with logic sits in its own module named after the type, and__init__.pyis the slice's front door: it re-exports what other slices may import and nothing else, and it never imports another slice, or the front doors form an import cycle.
Moves: Slice out a feature · Rename layer files by role during the move · Split a generic package by owner
A seam that exists only so a test can substitute a double — an interface with one production implementer, a patched attribute, an injection parameter no production caller varies — is deleted; depend on the concrete collaborator. Don't create interfaces until you need them; a test fake is not a need. An interface is justified only by a real second production implementation or a verified import cycle.
# ❌ one production implementer; the Protocol exists for FakeLeaves in the test
class Leaves(Protocol):
def find_latest(self, job_id: JobId) -> Job: ...
# ❌ the same seam with no declaration to grep for
@patch("app.service.Store")
def test_rerun(store_cls: MagicMock) -> None: ...
# ✅ concrete dependency; the test wires the REAL Store over a temp directory
class Service:
def __init__(self, leaves: worker.Store) -> None:
self._leaves = leaves
def test_rerun(tmp_path: Path) -> None:
svc = Service(worker.Store(tmp_path / "leaves.db"))In Python (opinionated):
mock.patchandmonkeypatchon a collaborator you own are this smell with noProtocolto point at. Patching the true external boundary is fine: the clock, a socket,os.environin an entry-point test. AProtocolis structural like an interface, so the one-implementer test transfers unchanged.
Moves: Delete the Test Seam · Rewrite the test around real collaborators · Delete the double
Every behavior is tested at the lowest rung of the composition ladder that contains it: rung 0 is pure leaf types (unit tests with literal inputs, 100% coverage, public API only, imported as a consumer would); each rung above adds exactly one real production layer; only the true external boundary is ever faked. Orchestrating types get integration-style tests that cover the seams between their real collaborators — some overlap with leaf coverage is fine; leaf behavior tested only from above is not.
# ❌ success and error fused into one table, with a branch inside the case
@pytest.mark.parametrize(("raw", "expect_err"), [("a@b.io", False), ("", True)])
def test_parse_email(raw: str, expect_err: bool) -> None:
if expect_err:
with pytest.raises(ValueError):
Email.parse(raw)
else:
assert Email.parse(raw).domain == "b.io"
# ✅ two functions, one shape each, every row named
@pytest.mark.parametrize(
"raw",
[pytest.param("a@b.io", id="plain"), pytest.param("A@B.IO", id="upper")],
)
def test_parse_email_success(raw: str) -> None:
assert Email.parse(raw).domain == "b.io"
@pytest.mark.parametrize(
"raw",
[pytest.param("", id="empty"), pytest.param("no-at", id="no-at")],
)
def test_parse_email_error(raw: str) -> None:
with pytest.raises(ValueError):
Email.parse(raw)In Python:
pytest.param(..., id=...)on every row so a failure names its case; import as a consumer would,from app import user. Notime.sleep: wait onEvent.wait(timeout),Queue.get(timeout)or an awaited future. Orchestrators are tested by wiring their real collaborators overtmp_path, an in-process fake server or an embedded database.
Moves: Move the behavior down a rung · Split Success and Error Tables · Replace doubles with real collaborators · Replace sleep with synchronization · Delete private-function tests
Dependencies are passed down from the caller, never reached sideways: no package-level mutable state, no import-time initialization writing state, no singletons fetched from inside business logic, no library code that manufactures its own root cancellation — cancellation flows from caller to callee. Globals are acceptable only at the composition root — the program's entry point, handler setup, application wiring — where they are read once and injected downward.
# ❌ a module-level config built at import, reached from a leaf
from app.env import CONFIG
def publish_event(event: Event) -> None:
conn = connect(CONFIG.nats_address)
# ✅ read once in the entry point, pushed down as a value
class NatsClient:
def __init__(self, nats_address: str) -> None:
self._nats_address = nats_address
def main() -> None:
config = Config.from_environ()
OrderHandler(OrderService(config.db_host, NatsClient(config.nats_address))).serve()In Python: silent everywhere:
logger = logging.getLogger(__name__), constants, enums, frozen instances as constants, exception classes. Silent only in the entry point:Config.from_environ(),logging.basicConfig, the frameworkapp, a registry filled by hand,asyncio.run. Reported elsewhere:os.environreads, a module-level container functions write into,asyncio.runorget_event_loopin library code. A test that monkeypatches production configuration is evidence against the production code, not a fix for the test.
Moves: Extract Clean Island · Push the Global Up One Level · Replace Import-Time Initialization with a Constructor · Pass Cancellation Down
Documentation is a network ranked by the documentation ladder: storified code → docstrings → repo docs → the index, each fact placed at the lowest rung that can carry it, higher rungs summarizing and pointing down, never duplicating. Two invariants hold the network together: reachability (every doc is reachable from the root: CLAUDE.md → index.md → doc — no orphans) and bidirectionality (code points up at its feature doc; docs point down at code via greppable symbols; the index points everywhere).
# ❌ public class with no docstring; the comment narrates WHAT the loop does
class Policy:
def do(self, op: Callable[[], None]) -> None:
# loop over attempts and back off between failures
for attempt in range(1, self._max_attempts + 1): ...
# ✅ the summary line states the contract, the body says why,
# the doc points down at code and code points up at the doc
class Policy:
"""Retry an operation with full-jitter backoff.
Full jitter over exponential backoff: it spreads retries after an outage so a
fleet does not thunder back in lockstep. Decision and measurements:
docs/retry-policy.md.
"""In Python: the PEP 257 summary line states the contract and is never a restatement finding; the body earns its lines by saying why, and the
Args,ReturnsandRaisessections are free. Where ruff'sDrules require a docstring, a WHAT-docstring is rewritten, not deleted; a_privatename carries noDobligation, so a WHAT-docstring on one is deleted.
Moves: Push the fact down a rung · Convert WHAT to WHY or delete · Rewire orphan doc · Wire the root · Add missing frontmatter · Update the stale doc with the behavior change
Every concurrent task has an owner and a provable exit path; shared mutable state is owned by one type and guarded where it lives; production code never sleeps to pace or synchronize cancellable work. Concurrency is designed at construction time — who owns the state, who stops the concurrent task — never patched in afterward.
# ❌ no owner, no exit; daemon=True hides the leak by killing it mid-item
threading.Thread(target=run_forever, daemon=True).start()
# ✅ the object that starts the thread stops it and waits for it
class Worker:
def __init__(self, work: queue.Queue[Work]) -> None:
self._work = work
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, name="worker")
self._thread.start()
def _run(self) -> None:
while not self._stop.is_set():
try:
process(self._work.get(timeout=0.5))
except queue.Empty:
continue
def close(self) -> None:
self._stop.set()
self._thread.join()
# ✅ asyncio: structured, both awaited, one failure cancels the other
async with asyncio.TaskGroup() as tg:
tg.create_task(poller.run())
tg.create_task(flusher.run())In Python:
asyncio.sleepis cancellable by construction and fine;time.sleepon a thread with a stop condition is the finding, fixed withEvent.wait(timeout). Insideasync def, a blocking call —time.sleep, a sync HTTP client, file I/O — stalls the whole loop and is the same finding; run it inasyncio.to_thread. A droppedasyncio.create_taskhandle is a leak. A lock lives beside the fields it guards and is taken withwith; on 3.13+Queue.shutdown()is the closed-channel twin.
Moves: Inject the Exit Path · Make Concurrent Work Joinable · Extract Synchronized Owner · Replace Sleep with Cancellable Wait · Delete Unearned Guards
A conditional that asks what a value is — a type switch, or a switch/if-chain on a kind/status/mode discriminator — may exist once. The second copy of that discriminator is a missing polymorphic type: the variants want to be implementations of an interface (or entries in a dispatch map), chosen once at the boundary, so downstream code tells the value what to do instead of asking what it is. One well-placed, exhaustive switch is not a defect; a duplicated one always is.
# ❌ the same discriminator in send.py, validate.py and retry.py
match a.channel:
case "email": ...
case "slack": ...
case _:
raise ValueError(f"unknown channel {a.channel!r}")
# ✅ chosen once at the boundary; everything downstream tells
from typing import Protocol, assert_never
class Channel(Protocol):
def send(self, a: Alert) -> None: ...
def valid_recipient(self, recipient: str) -> bool: ...
def retry_delay(self) -> timedelta: ...
def parse_channel(raw: str) -> Channel:
name = ChannelName(raw) # the raw string becomes an enum here, or raises
match name: # the ONE switch
case ChannelName.EMAIL: return Email()
case ChannelName.SLACK: return Slack()
case ChannelName.PAGERDUTY: return PagerDuty()
case _: assert_never(name) # the completeness proof, not an "unknown" pathIn Python: the kept switch is a
matchover an enum closed bycase _: assert_never(x); acase _:that raises or logs is the finding. Prefer a dict of callables first (CHANNELS[ChannelName(raw)]), aProtocolhierarchy second,functools.singledispatchthird. A boolean parameter that selects a branch is a Split Flag Argument candidate (P1).
Moves: Replace Duplicated Switch with Interface Dispatch · Replace If-Chain with Strategy Map · Introduce Null Object · Split Flag Argument · Keep the Single Exhaustive Switch
A validated value changes state only through methods that own its invariants — never through leaked internals. Constructors copy the collections they are given; queries return copies (or iterators), not the internal reference; a method is a query or a modifier, not both; and a type with a validating constructor exposes no setter that skips the validation. This rule adapts Fowler's Mutable Data smell family (Refactoring, 2nd ed.: Encapsulate Collection, Separate Query from Modifier, Remove Setting Method, Split Variable) to languages where collections are passed by reference into shared backing storage.
# ❌ returns a mutable alias into validated state; a distant caller sorts it in place
class Grants:
def all(self) -> list[Permission]:
return self._perms
# ✅ copy on the way in; hand out a view, not the storage
@dataclass(frozen=True, slots=True)
class Grants:
_perms: tuple[Permission, ...]
@classmethod
def of(cls, raw: Iterable[str]) -> Self:
return cls(tuple(dedupe_and_validate(raw)))
def __iter__(self) -> Iterator[Permission]:
return iter(self._perms)In Python:
frozen=Truefreezes the binding, not the value: a frozen dataclass holding alistis mutable through that list. Store tuples and mapping proxies, or copy on the way out. A mutable default in a signature is this rule's most common form (P2).
Moves: Copy on the Way In · Copy on the Way Out / Encapsulate Collection · Separate Query from Modifier · Remove Setting Method · Split Variable
Rules that are not one of the twelve but hold in every language, then the Python house rules. Same shape as above, with their own numbers so a review can cite them.
A # noqa directive is never added on your own: fix the code, and when the
finding is a true false positive, propose the exclusion in the linter's configuration
and get it reviewed. A new suppression in a diff is itself a finding, and no automated
lint-fix pass adds one or edits the configuration.
In Python:
# noqaand# type: ignoreare the same thing, and each carries its code (# noqa: E501,# type: ignore[return-value]; a bare one is ruffPGH003).[tool.ruff.lint.per-file-ignores]and a[tool.mypy]override are the reviewed place for a true false positive.
Review: Did the diff add a # noqa or # type: ignore, or edit [tool.ruff] or [tool.mypy]?
Handle an error once: wrap it with the context of the boundary it crossed and its cause, or handle it, never both log it and pass it on. Catch narrowly, the failure you can handle, never everything. Failure vocabulary belongs to the package that raises it: one named error per thing that can go wrong, made public only when a caller decides on it.
In Python:
except Exception:swallows the bug with the failure (ruffBLE001); the one place it belongs is the process boundary, a worker loop or request handler that logs withlogger.exceptionand does not re-raise. Inside anexcept, raise withfrom err, orfrom Nonewhen the cause is deliberately hidden;B904wants one or the other. Inspect withexcept SpecificError, neverstr(e). Exception classes are defined once per package, named for what went wrong, and in__all__only when a caller catches them.
Review: Does any except catch Exception, BaseException or nothing at all away from the process boundary, re-raise without from, both log and raise, or inspect a message string?
Rules marked (opinionated) are stances, not Python community norms; the departure is deliberate.
A positional True at a call site says nothing. Every bool parameter sits after
*, so the call reads fetch(url, follow_redirects=True). ruff FBT001 and
FBT003 enforce it where the repository enables them; elsewhere it is a review
finding. When the two branches share little, the flag wants to be two functions
(R11, Split Flag Argument).
Review: Is any bool parameter positional?
Two halves. The community half: a mutable literal or a call in a signature is
evaluated once at definition time (ruff B006, B008; immutable calls such as
tuple() are exempt). The stance: an optional collaborator is a do-nothing object
bound once at module level, def __init__(self, *, sink: Sink = NULL_SINK), never
sink: Sink | None = None substituted inside __init__. That idiom is allowed only
for a default that is genuinely mutable or expensive, and even then the attribute is
typed without None and no method guards it.
Review: Is any default a mutable literal or a call, or a None a method later guards?
from app import user, never from app.user._parse import _parse_row. Python lets
you reach a _private name; the rule is that you do not. The urge is a placement
signal (R4).
Review: Does any test import a _private name?
tmp_path, a fake HTTP server, an embedded database: those earn a fixture, and
conftest.py holds those. A small fixture that builds a literal is fine; a fixture
that returns the literal the test is about hides the one thing a reader needs to
see. Write that literal in the test.
Review: Does any fixture return the literal a test is about?
Every public function is fully annotated, and mypy passes where the repository
configures it. An unexplained Any on a public signature is a suppression spelled
differently: narrow it, or name the Protocol. Annotations are what let X | None
be a declared absence instead of a hope.
Review: Does any public signature carry an unexplained Any or lack an annotation?
Before you ask for review, answer each with a file and line, not a feeling. The plugin's reviewers ask every rule question with a detection command behind it; these are the ones that catch the most. The house-rule questions are review questions only.
- R1 Does the diff validate a primitive inline instead of constructing a type? · Is the same predicate enforced in more than one place? · Does any function return a sentinel to mean "not found / invalid"?
- R2 Can the type exist in an invalid state? · Does anything return or accept
Noneas a value? - R3 Does one body mix abstraction levels? · Do block comments narrate sections inside a function body?
- R4 Are
_privatehelpers tested directly? · Does a new shared package have a role name? - R5 Is any package or module named after a layer or role? · Is one feature's code spread across ≥2 layer directories?
- R6 Is the only other implementer a test double? · Does the diff justify a new interface with "for testing" or "import cycle"?
- R7 Does any test case body contain a conditional? · Does any test reach past the public surface? · Does any test sleep to synchronize?
- R8 Does any module declare mutable state at module level? · Does deep code read a global config?
- R9 Does a docstring on a public symbol state WHAT instead of WHY? · Did behavior change silently under an existing doc?
- R10 Does every thread or task started in the diff have a provable exit path? · Does production code sleep?
- R11 Is the same discriminator inspected in more than one place? · Does a
case _:(or trailingelse) handle "unknown kind" away from the boundary? · Does a boolean parameter select between behaviors? - R12 Does a method return an internal list, dict or set by reference? · Can a validated type be mutated around its constructor?
- House rules Did the diff add a
# noqaor# type: ignore, or edit[tool.ruff]or[tool.mypy]? · Does anyexceptcatchException,BaseExceptionor nothing at all away from the process boundary, re-raise withoutfrom, both log and raise, or inspect a message string? · Is anyboolparameter positional? · Is any default a mutable literal or a call, or aNonea method later guards? · Does any test import a_privatename? · Does any fixture return the literal a test is about? · Does any public signature carry an unexplainedAnyor lack an annotation?
| Test | pytest |
| Lint | ruff check . |
| Lint and fix | ruff check --fix . && ruff format . |
| Suppression | # noqa — never added on your own; a new one in a diff is a review finding |
| Docs | docstring: short, says why, not what; long-form under docs/ |
| Type check | mypy, where pyproject.toml has a [tool.mypy] table; never add a checker the repository does not use |
| Type suppression | # type: ignore — the same rule as # noqa, see H1 |
| Python | the examples assume 3.11+ (match, X | None, asyncio.TaskGroup, typing.assert_never); on 3.10 import assert_never from typing_extensions and keep asyncio tasks under kept handles |
| Tests | pytest collects test_*.py and *_test.py; under tests/ mirroring the package or beside the module, whichever the repository does; pytest.param(id=...) on every row |
| Workflow | RED → GREEN → REFACTOR per behavior; package-scoped lint every cycle; review per finished slice; commit only a green tree |