INW015 construct-only-in¶
What it does¶
Opt-in, and an error when on. You name a role, the modules only the composition root may build, such as the outbound adapters, and the modules allowed to build them. INW015 then reports two things in every other module a layer owns:
- An import of a role module that runs. A plain import, a
fromimport, a relative or star import, and an import inside a function all count; the finding sits on the imported name. An import inside anif TYPE_CHECKING:(orif typing.TYPE_CHECKING:) block doesn't count: it never runs, so it builds nothing. - A call that builds a class of the role, reached another way.
SqlOrderRepo()afterfrom store.adapters import SqlOrderRepo, wherestore/adapters/__init__.pyre-exports it, orstore.adapters.outbound.sql.SqlOrderRepo()afterimport store.adapters. The finding sits on the call. A call whose name comes from an import INW015 already reports isn't reported again.
The role's own modules may import each other, and so may the modules allowed-in lists. A module no layer owns, such as a test or a script, isn't checked: tests build adapters on purpose.
Why is this bad¶
In ports and adapters, the inner layers own a port (an abstract class or a Protocol), an outbound adapter implements it, and the composition root (main.py, di.py) builds the adapter and hands it to whoever needs the port. When an inbound adapter, such as a REST controller, builds SqlOrderRepo() itself, that wiring is gone: the controller is tied to SQL, a test can't swap in a fake, and the next agent copies the pattern into every route.
INW001 can't see it. Inbound and outbound adapters usually sit in the same layer, so the import doesn't point outward. INW003's public modules are about bounded contexts, not about who may build what.
Example¶
[tool.inwards]
layers = [
{ name = "application", modules = ["store.application"] },
{ name = "adapters", modules = ["store.adapters"] },
{ name = "bootstrap", modules = ["store.main"] },
]
[tool.inwards.rules]
extend-select = ["INW015"]
[tool.inwards.rules.construct-only-in]
role = ["store.adapters.outbound"]
allowed-in = ["store.main"]
from typing import Protocol
class OrderRepo(Protocol):
def save(self, order_id: int) -> None: ...
class SqlOrderRepo:
def save(self, order_id: int) -> None:
print("insert", order_id)
from store.adapters.outbound.sql import SqlOrderRepo
from store.application.ports import OrderRepo
class OrderController:
def __init__(self) -> None:
self.repo: OrderRepo = SqlOrderRepo()
def place(self, order_id: int) -> None:
self.repo.save(order_id)
store/adapters/inbound/rest.py:1:41: INW015 `store.adapters.inbound.rest` imports `SqlOrderRepo` from `store.adapters.outbound.sql`, a module of the role `store.adapters.outbound`. A module that uses the adapter directly is tied to it and bypasses the port it implements.
fix: Take the port as a parameter instead of importing `store.adapters.outbound.sql`, and let `store.main` build the adapter.
1. Delete `from store.adapters.outbound.sql import SqlOrderRepo`.
2. Type this module against the port the adapter implements (the abstract class or Protocol the inner layer owns), not against the adapter.
3. Receive the implementation through a constructor or function parameter; in a FastAPI route or dependency, through `Depends()` on a provider whose return type is the port.
4. Build the adapter in `store.main` and pass it in from there.
Fixed: the controller takes the port through its constructor, and store/main.py builds the adapter.
from store.application.ports import OrderRepo
class OrderController:
def __init__(self, repo: OrderRepo) -> None:
self.repo = repo
def place(self, order_id: int) -> None:
self.repo.save(order_id)
from store.adapters.inbound.rest import OrderController
from store.adapters.outbound.sql import SqlOrderRepo
controller = OrderController(SqlOrderRepo())
A TYPE_CHECKING import passes too, since it never runs. It still ties the module to the adapter's name, so prefer the port in annotations:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from store.adapters.outbound.sql import SqlOrderRepo
How it works¶
INW015 runs on a module when the rule is on for it (modules), a layer owns it, and it is neither in role nor in allowed-in. A file without the word import is never parsed; any other gets one parse of its own.
- Imports. Every import statement, nested ones included, is resolved the way INW001 resolves it, and the project's module index says which first-party module it lands in. An import that lands in a role module and isn't in the body of an
if TYPE_CHECKING:orelif TYPE_CHECKING:branch is reported. Theelsebranch runs, so it counts. - Calls. A call whose callee's last name starts with a capital letter, as Python names classes, is qualified through the file's imports and followed through the module index, across up to four re-exports, to a top-level
class. If that class is defined in a role module, the call is reported. Only modules in the role, inallowed-in, and packages above the role (whose__init__.pymay re-export it) are read, each once per checked file. - INW001 first. An import that points outward to another layer gets INW001 alone, whose fix already says to depend on a port. With INW001 off for the module, INW015 reports it.
How to fix¶
- Depend on the port the adapter implements, not on the adapter.
- Take the implementation as a constructor or function parameter. In a FastAPI route or dependency, use
Depends()on a provider whose return type is the port. - Build the adapter in the composition root and pass it in from there.
Fix safety¶
No automatic fix (autofix: false). Moving construction into the composition root changes constructors and call sites in more than one module.
Configuration¶
Severity error. Opt-in: turn it on with extend-select. The clean and hexagonal presets of inwards init turn INW015 on as a warning, with their driven adapters (app.infrastructure or app.adapters.outbound) as role and app.bootstrap as allowed-in. The options live in [tool.inwards.rules.construct-only-in] (options tables):
[tool.inwards.rules]
extend-select = ["INW015"]
[tool.inwards.rules.construct-only-in]
role = ["app.adapters.outbound", "app.*.gateways"] # required: the guarded modules
allowed-in = ["app.di", "app.main"] # the composition root
modules = ["app.adapters"] # optional: only report here
role(required): module prefixes or selectors, as in a layer'smodules, of the modules only the composition root may import and build. A prefix covers the package and everything below it. A table withoutroleis a config error.allowed-in: module prefixes or selectors of the composition root, which may import the role and build its classes. Unset, only the role's own modules may.modules: as for every rule, the modules INW015 reports in. Unlike in most rules, it doesn't name the role: that isrole.check-tests:truemakes INW015 report in test modules too, which it skips by default (options tables).
A single import can be suppressed with a reason on its line: # inwards: ignore[INW015] reason="...".
A template can turn INW015 on for one of its roles: outbound = { construct-only-in = { allowed-in = ["app.di"] } } puts the role's modules in role, not in modules, so the role is what the rule guards.
Known limitations¶
- Classes only, by name. The call form follows a callee whose name starts with a capital letter, so a factory function (
make_repo()) or a lowercase class reached through a re-export isn't followed. Its import, when it imports a role module directly, is still reported. - What hides a class. A class assigned to another name (
Repo = SqlOrderRepo), passed around as a value, or handed toDepends(SqlOrderRepo)through a re-export isn't followed. A direct import of it is reported all the same. - Re-exports. Up to four re-exports are followed, and only through the role,
allowed-in, and the packages above the role. - Modules no layer owns aren't checked at all, so code outside your layers can build adapters freely. Neither are test modules, unless
check-tests = true. - One finding per import, not per use. Fixing the import fixes every use of it.
- Exceptions of the role aren't reported.
from <role module> import StorageError(a name ending inError,ExceptionorWarning), and a call that builds such a class through a re-export, builds no adapter: code outside the role catches the error or maps it to an HTTP status. A name that only looks like an exception passes too. - A package facade isn't reported for re-exporting. A
from <submodule> import SqlOrderRepoin a package's own__init__.py(store/adapters/__init__.pyimporting fromstore/adapters/outbound/sql.py) builds nothing, and INW003 requires it when the package is the only public entry. A call that builds the class in the facade, and every caller that builds it through the facade, is still reported. - Measured on the corpus (#182, chapter 6) with
roleset to fastapi-clean-example'sapp.outboundand python-ddd'sinfrastructurepackages: 48 findings before the exception rule, 17 after, every one looked at. 15 of the 17 are true positives (an application query importing an ORM model from infrastructure, an inbound handler importing an outbound handler class). The other 2: a constant imported from an adapter (STAGED_COOKIE), and a test module a layer owns. Since #362 test modules are skipped, which leaves 16 findings, 15 of them true.
References¶
- #296: the rule; #98: the role-scoped content rules it comes from.
typing.TYPE_CHECKING:Falseat runtime, assumedTrueby type checkers.- FastAPI: Dependencies:
Depends()and provider functions.