0016 — Code lives in classes, and every class has an interface beside it¶
Status: accepted · Date: 2026-09-15
Canon: sub-doctrine 9.b — the declared seam, filed under doctrine 9 — The vibe (ADR-0020). It is law from the operator's ratifying merge of the change that carries it.
Context¶
This codebase already has one interface convention and it only covers one seam.
application/interfaces/ declares thirteen Protocol modules — the ports the
composition root wires concrete adapters into — and .importlinter's
interfaces-declare-only contract keeps them from importing the code that
consumes them. That is the boundary between the application layer and the world.
Everywhere else, substitution is ad hoc. Measured on a81c825:
| layer | classes | Protocols | module-level functions |
|---|---|---|---|
domain/ |
129 | 0 | 99 |
application/ |
83 | 40 | 48 |
infrastructure/ |
64 | 2 | 91 |
cli/ |
0 | 0 | 37 |
tui/ |
8 | 0 | 5 |
Two interfaces/ directories existed at that commit: application/interfaces (13
files, twelve of them declaring the 40 Protocols counted above) and
infrastructure/interfaces (1). The "classes" column counts concrete classes, not
Protocols. So roughly three hundred module-level functions and two hundred and
eighty classes had no declared seam at all. A third package, cli/interfaces,
arrived with the SIGTERM latch (44c1c21a) and is the first to follow the mirrored
_interface naming below; the existing application/interfaces modules are grouped
by port family and converge like any other module.
A function with no seam is substituted by patching its import — monkeypatch.setattr
against the module that imported it, not the module that defined it. That works,
and it binds every test to the import graph rather than to the contract. Rename a
module, move a call site, and a test that was asserting behaviour starts asserting
nothing while still passing.
Decision¶
Two rules, and the second is the reason for the first.
1. Code lives in class objects. A module-level function is the method of last
resort, permitted only where a class is genuinely not available — a module's
public __all__ façade, a __main__ entry point, a pure helper that a language
or library contract requires to be a bare function. "It is only a few lines" is
not a reason. Where a bare function is used, the reason is written at the
definition, not left to be inferred.
2. Every class has an interface beside it, in a mirrored interfaces/
directory. For src/<pkg>/services/github_service.py there is
src/<pkg>/services/interfaces/github_service_interface.py. The mapping is
mechanical: the directory gains an interfaces/ child, the module gains an
_interface suffix, and the interface declares the contract the class
implements.
Interfaces declare; they never consume. .importlinter already states that rule
for application.interfaces and it extends unchanged to every new interfaces/
package: an interface module may import the standard library and other
interfaces, and nothing else from its own tree.
Rationale¶
Testability is the whole argument. A class behind an interface is substituted at its seam — the caller takes the interface, the test passes a double, and nothing is patched. The test then depends on the contract, which is the thing that is supposed to be stable, instead of on the import graph, which is not. The 100% branch-coverage floor makes this concrete rather than aspirational: a branch that can only be reached by patching a module attribute is a branch whose test will break for reasons unrelated to its subject.
It makes the seam a reviewable artifact. An interface file is where a contract change becomes visible in a diff. A bare function's contract changes silently, in its signature, among its implementation.
It generalises the pattern that already works here. application/interfaces
plus a composition root is why the application layer can be tested without a
database, and why bootstrap.py is the only module that knows what a
HandoffStore really is (a PostgresHandoffRepository). The decision is to stop treating that as a special
case for one layer.
Consequences¶
This is the rule for new and changed code from this date. Retrofitting the measured 289 module-level functions and 288 unfaced classes is its own backlog, prioritised per module family, and it is explicitly not a precondition for anything else. A sweeping mechanical rewrite of a tree with a 100% branch floor would be a very large diff whose tests all still pass — which is the shape of change that hides a regression rather than catching one.
domain/ is where this rule costs the most, and it does not get an
exemption. The layer is stdlib-only, pure, and full of frozen dataclasses and
free functions; rotation.py::select() and noloss.py::verify() are
deliberately not objects. Those keep their behaviour, and the rule applies to
their form: the function becomes a method on a class that carries no state, and
the interface declares it. Purity is preserved because purity was never about
the absence of a class.
The workspace tenants inherit it as they are touched, not on arrival.
src/vibey_runners/* and src/vibey_tools/* are absorbed subtrees with their
own histories; vibey-gh in particular is 33 modules of mostly stdlib functions —
284 module-level functions beside 59 classes, 54 of them configuration dataclasses. Rewriting them on import would destroy the property the
import exists to create — that each package still builds and passes its own
suite unchanged. They converge module by module.
An interface with one implementation is still correct. The objection that a single-implementation interface is ceremony is answered by the test double: the second implementation is the one the tests use, and it exists from the first day.
New enforcement. .importlinter gains a contract per interfaces/ package
as those packages appear, matching interfaces-declare-only. A structural check
that every class module has a matching interface module belongs with the
agent-surface parity test — a repo-introspecting static test, not a lint plugin.