State and effects¶
Code becomes hard to change when you cannot answer two questions by reading it: what can write to this value, and what does calling this touch. Both questions have answers that live outside the function you are looking at. A value bound at module scope can be rewritten by any code that imported the module, so its contents at the moment your function runs depend on the order in which unrelated code happened to execute. A function that reaches the filesystem, a socket and a subprocess has three failure modes and three setup costs that its signature does not mention, so a caller cannot tell what a call commits them to and a test cannot exercise one part without providing all three. Neither problem shows up as a wrong answer. It shows up later, as a change that was supposed to be local and was not, because ownership of the state and the reach of the effects were never written down anywhere a reader could find them.
4. Trace State Ownership¶
For every mutable value, determine:
- Who owns it?
- How long should it live?
- Who may mutate it?
- How can other code observe it?
Placement criteria¶
| State lifetime | Recommended location |
|---|---|
| One calculation | Local variable |
| One request or job | Explicit request/context object |
| One value | Immutable value object |
| One entity | Entity instance |
| One stateful component | Component instance |
| Entire application | Explicitly constructed application dependency |
| Beyond the process | Repository or persistent storage |
Good criteria¶
- Mutable state has one clear owner.
- Mutation occurs through meaningful methods on that owner.
- Internal mutable collections are not returned directly.
- An object’s lifetime matches the lifetime required by its state.
ClassVaris not used merely to disguise global mutable state.
class CheckRegistry:
def __init__(self) -> None:
self._check_types: dict[str, type[Check]] = {}
def register(self, check_type: type[Check]) -> None:
...
def resolve(self, check_name: str) -> type[Check]:
...
def names(self) -> tuple[str, ...]:
return tuple(sorted(self._check_types))
Warning signs¶
- A method mutates several passed objects.
- Callers receive and modify an owner’s internal dictionary or list.
- Mutable
ClassVarcollections are shared unintentionally. - State survives longer than the operation that needs it.
- Tests must reset global state between cases.
5. Trace Side Effects¶
Mark every interaction with:
- Database
- Network
- Filesystem
- Subprocess
- Cache
- Message broker
- Clock
- Random source
- Logging or audit sink
Criteria¶
- Side effects are visible through named collaborators or boundary functions.
- Business decisions are separated from technical I/O where practical.
- A workflow makes effect ordering understandable.
- Partial-failure behavior is considered when multiple effects occur.
- Domain objects do not contain vendor-specific networking or persistence logic.
A readable flow normally resembles:
Acquire input → make decisions → create result → persist or publish
6. Protect Invariants¶
Objects that own state should also own the rules governing that state.
Good criteria¶
- Construction produces a valid, usable object.
- Invalid values are rejected during construction.
- Entities expose domain transitions rather than generic setters.
- Callers cannot bypass important transition rules.
- Failed operations preserve valid state.
Prefer:
user.activate()
account.withdraw(amount)
Over:
user.status = UserStatus.ACTIVE
account.balance -= amount
Avoid temporal coupling:
client = ApiClient()
client.configure(settings)
client.authenticate()
client.connect()
Prefer construction that establishes validity:
client = ApiClient(
settings=settings,
credentials=credentials,
)
Further reading¶
The ownership questions above are the ones Martin Fowler's
Encapsulate Variable
refactoring exists to answer: the refactoring moves a widely reachable piece of
data behind functions precisely so that "who may mutate it" has a place to be
written down. The separation in section 5 between deciding something and doing
something is Bertrand Meyer's command-query separation, summarised with its
caveats in Fowler's
CommandQuerySeparation.
For the ClassVar criterion specifically,
PEP 526 defines what the annotation does and
what it does not do, and
typing.ClassVar
is the reference entry: it marks a name as class scoped for a type checker and
has no effect on whether the object it names is shared or mutable at runtime.
What enforces this¶
Four shipped rules detect shapes described on this page. Each is a review lead read off the syntax tree, not a verdict, and none of them executes the code being scanned.
Section 4, state ownership:
HS004 shared-mutable-statefires on a mutable value assigned in a module body or a class body. This is the "state survives longer than the operation that needs it" warning sign, and it is the only rule that reaches theClassVarcriterion, since a mutable collection assigned in a class body is flagged whether or not it carries aClassVarannotation.HS006 multiple-mutation-ownersfires when one function body writes to three or more distinct owners, where an owner isself, a parameter, or a module-level name. This is the "a method mutates several passed objects" warning sign, and it is also where the partial-failure criterion in section 5 becomes visible.
Section 5, side effects:
HS007 mixed-boundariesfires when one function body names three or more of four categories of standard-library boundary module:database,filesystem,network,process. The match is on resolved import aliases, so it reports that three kinds of outside world are named in one body. A resolved reference tosubprocess.runis evidence that the function may start a process; the scanner never runs the code, so it cannot say whether the call is taken, guarded, or unreachable. Only standard-library modules are listed, so a database reached through a third-party driver contributes nothing.HS021 lazy-importfires on any import statement inside a function body. A deferred import moves the cost and the failure mode of loading a module from load time to call time, which makes it an effect of calling the function rather than of importing the module. The rule cannot tell a deliberate cycle break or optional dependency from an import added in place, which is why it is an advisory.
Section 6, invariants, has no rule of its own. Nothing in the shipped set detects
temporal coupling, generic setters standing in for domain transitions, or state
left invalid after a failed operation. The closest signal is
HS014 validated-argument-bundle,
which fires when a function already over the argument threshold validates one of
those arguments in its body. That points at a bundle of values whose invariants
have no owning type yet, which is the "invalid values are rejected during
construction" criterion approached from the calling side rather than from the
object.