INW020 adapter-conformance¶
What it does¶
Opt-in, and an error when on. Checks that every public top-level class of an adapter module declares the port it implements with a conformance line: an annotated assignment in a module-level if TYPE_CHECKING: (or if typing.TYPE_CHECKING:) block whose value builds the class or is the class.
if TYPE_CHECKING:
_: OrderRepository = SqlOrderRepository(...) # or
_: type[OrderRepository] = SqlOrderRepository
The target name is free; _ is the convention. Any arguments go, ... included, and SqlOrderRepository[int](...) counts too. Three things are reported:
- No conformance line, on the class's name. The example line in the message names the port when the module imports exactly one class from a port module, and says
<Port>otherwise. - A line that runs, on the line: the same annotated assignment at module level outside
if TYPE_CHECKING:, theelsebranch included. It builds the adapter, or needs the port imported, every time the module is imported. - An annotation that names no port, on the annotation: with
portsset, a class that isn't from one of those modules; with or without it,Anyorobject, which accept any value.
A class needs no line when its name starts with _, when it is a port itself (a Protocol or an ABC), or when it is a record: an exception, an enum, a Pydantic model, a named tuple, a typed dict or a dataclass. See Configuration.
Without modules, the rule checks every module with an adapters segment in its name: shop.adapters, shop.adapters.outbound.orders. Set modules to check other modules instead.
Why is this bad¶
A port here is a typing.Protocol, and an adapter implements it by having its members; subclassing the protocol is "not necessary for the sake of type-checking" (typing spec, Protocols). That leaves nothing in the adapter's module that says which port it implements, so nothing checks it until the composition root passes the adapter where the port is expected, if it does. An agent that renames a port method, or adds one, finds out at run time.
The conformance line closes that gap. TYPE_CHECKING is False at runtime and assumed True by static type checkers (typing.TYPE_CHECKING), so the line builds nothing and is still checked: assignability to a protocol is structural, and ty reports a value that doesn't fit the declared type as invalid-assignment, as mypy and Pyright do. A variable annotated type[Port] accepts a concrete class whose instances implement the protocol (type[] and class objects vs protocols). INW020 checks that the line is there; the type checker checks what it says (ADR-046).
Example¶
[tool.inwards]
layers = [
{ name = "domain", modules = ["shop.domain"] },
{ name = "application", modules = ["shop.application"] },
{ name = "adapters", modules = ["shop.adapters"] },
]
[tool.inwards.rules]
extend-select = ["INW020"]
[tool.inwards.rules.adapter-conformance]
ports = ["shop.application.ports"]
from typing import Protocol
class OrderRepository(Protocol):
def get(self, order_id: int) -> dict: ...
from typing import TYPE_CHECKING
from shop.application.ports.orders import OrderRepository
from shop.domain.orders import Order
class StorageError(Exception):
pass
class SqlOrderRepository:
def get(self, order_id: int) -> dict:
return {"id": order_id}
class CachedOrderRepository:
def get(self, order_id: int) -> dict:
return {"id": order_id}
_: OrderRepository = SqlOrderRepository()
if TYPE_CHECKING:
__: Order = CachedOrderRepository()
shop/adapters/orders.py:21:1: INW020 `_: OrderRepository = SqlOrderRepository()` in adapter module `shop.adapters.orders` is outside `if TYPE_CHECKING:`, so it runs when the module is imported and builds a `SqlOrderRepository`. A conformance line is only for the type checker.
fix: Move the line into an `if TYPE_CHECKING:` block at the end of the module.
1. Move the line into an `if TYPE_CHECKING:` block at module level (`from typing import TYPE_CHECKING`), at the end of the module.
2. Move the port's import into the same block if nothing else in the module uses it at runtime.
docs: https://sircypkowskyy.github.io/inwards/rules/INW020/
shop/adapters/orders.py:24:9: INW020 The conformance line for `CachedOrderRepository` in adapter module `shop.adapters.orders` is annotated `shop.domain.orders.Order`, which isn't a class from a port module (`ports`), so the type checker doesn't check the class against a port.
fix: Annotate the line with the port `CachedOrderRepository` implements.
1. Replace `Order` with the port `CachedOrderRepository` implements, imported from its port module under `if TYPE_CHECKING:`.
2. If that port lives outside the modules `ports` lists in [tool.inwards.rules.adapter-conformance], ask the user to add its module there. Don't edit [tool.inwards] yourself.
docs: https://sircypkowskyy.github.io/inwards/rules/INW020/
StorageError passes because it is an exception. Fixed: both lines move under if TYPE_CHECKING:, annotated with the port.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from shop.application.ports.orders import OrderRepository
class StorageError(Exception):
pass
class SqlOrderRepository:
def get(self, order_id: int) -> dict:
return {"id": order_id}
class CachedOrderRepository:
def get(self, order_id: int) -> dict:
return {"id": order_id}
if TYPE_CHECKING:
_: OrderRepository = SqlOrderRepository()
__: type[OrderRepository] = CachedOrderRepository
How it works¶
A file is parsed only when INW020 is on for its module, the module is an adapter module, and its text has the word class. One parse per such file; no other module is read, so the per-edit hook, the editor and MCP report it on the edit that breaks it.
- Classes. The top-level classes whose name doesn't start with
_. A class is exempt when an allowed decorator or base is among its own, when a base is a same-file class that is exempt that way, or when it listsProtocol,abc.ABCormetaclass=abc.ABCMeta. - Names are qualified through the file's imports, as for INW014:
import typingwithtyping.TYPE_CHECKING, aliased imports andtyping_extensionsall count. A name the file doesn't import is a builtin, spelled bare (Exception,object). - Lines. An annotated assignment directly at module level, or directly in a branch of a module-level
if. Its value isName(...)orName[...](...)with any annotation, or a bareNameundertype[...](typing.Type[...]too), whereNameis a class the rule checks. A line in the body ofif TYPE_CHECKING:orelif TYPE_CHECKING:is for the type checker; any other one runs. - Ports. With
portsset, the annotation's head (RepoofRepo[int]) must resolve to a CapWords name defined in one of those modules. Without it, the port in a message is the one CapWords name the module imports from a module with aportssegment. - One finding per problem. A class with a line that runs, or with a line whose annotation names no port, isn't reported as having no line as well.
How to fix¶
- Import the port under
if TYPE_CHECKING:. - Add
_: Port = Adapter(...), or_: type[Port] = Adapter, in anif TYPE_CHECKING:block at the end of the module; one line per port for a class that implements several. - If the class is a helper, not an adapter, rename it
_Nameor move it out of the adapter module. If it is a record on a base of the project's own, ask whoever owns the config to add that base toextend-allow-bases.
Fix safety¶
No automatic fix (autofix: false). Which port a class implements isn't in the code; that is what the line says.
Configuration¶
Severity error. Opt-in: turn it on with extend-select. The clean and hexagonal presets of inwards init turn INW020 on as a warning, with modules set to the driven adapter layer and ports to application.ports. The options live in [tool.inwards.rules.adapter-conformance] (options tables):
[tool.inwards.rules]
extend-select = ["INW020"]
[tool.inwards.rules.adapter-conformance]
modules = ["shop.infrastructure"] # optional: the adapter modules
ports = ["shop.application.ports"] # optional: annotations must name a class from these
extend-allow-bases = ["shop.kit.Schema"]
modules: module prefixes or selectors, as in a layer'smodules. Set, INW020 checks the modules they match instead of the ones with anadapterssegment.ports: module prefixes or selectors of the port modules. Set, a line's annotation must name a class from one of them, and the message names the one the module imports from them.allow-bases,extend-allow-basesandallow-decorators: as for INW014, with the same defaults (exceptions,pydantic.BaseModel,NamedTuple,TypedDict,enum.*; dataclass, pydantic and attrs decorators).check-tests:truemakes INW020 report in test modules too, which it skips by default (options tables).
To accept one finding, suppress it on the line it points at: # inwards: ignore[INW020] reason="..." on the class line, the conformance line or the annotation's line.
A template can turn INW020 on for one of its roles: adapters = { adapter-conformance = true } sets modules to the role's modules.
Known limitations¶
- An adapter or a helper. The rule can't tell them apart: every public class in an adapter module needs a line. A helper needs an underscore, an allowed base, or a narrower
modules. - Project bases. A Pydantic model on a project's own base (
class Cursor(Schema)withSchemafrompolar.kit.schemas) is reported until the base is inextend-allow-bases: the base is read by name, not followed. - What the line says. Whether the class matches the port is the type checker's job, and a project that doesn't run one gets a line nobody checks.
- Lines read. A line nested deeper than a branch of a module-level
if(inside atry, or anifinsideif TYPE_CHECKING:) isn't read. A string annotation (_: "Port" = Adapter(...)) counts as a line but its port isn't checked. - CapWords ports. The port in a message and the
portscheck take a CapWords name for a class and a lower-case one for a module, since no other file is read. - Measured on the corpus (2026-10-11, the five repos of
bench/corpus.json, the manifest layering plusselect = ["INW020"]): 8 findings in fastapi-clean-example, its 8 adapters, which subclass their ports instead; 2 in Polar, itsStripeAdapterandStripeExtractionCursor, a Pydantic model on the project's ownSchemabase; none in the other three, which have noadaptersmodules. No project in the corpus follows the convention yet, which is why the rule is opt-in.
References¶
- #433: the rule; ADR-046 and #407: the decision and the spike.
- Typing spec: Protocols: structural assignability, explicit subclassing, and
type[Proto]. typing.TYPE_CHECKING:Falseat runtime,Truefor static type checkers.- ty:
invalid-assignment: the check that reads the line.