Skip to content

INW001 layer-dependency

What it does

Reports an import, in a file that belongs to a layer, of first-party code that belongs to an outer layer. Layers are listed innermost first in [tool.inwards], and a module may import its own layer and anything listed before it. Sibling layers, a nested array in layers or a | b in a template's roles, may not import each other either; the message then says from sibling layer.

Every import in the file counts: top level, inside a function or class, and behind if TYPE_CHECKING: (ADR-009). Relative imports are resolved against the file's package, and from shop import infrastructure is resolved to shop.infrastructure. Dynamic imports (importlib.import_module("shop.infrastructure.db")) are INW011's job.

Why is this bad

An outward import ties the inner layer to the details it is meant to be independent of: the domain can no longer be tested, read or reused without the database code. Agents make this edit often, because importing the class that already does the job is the shortest path to working code. Review catches it late, if at all. The first thing an agent tries when the import is flagged is to hide it, in a function or behind TYPE_CHECKING, so the rule checks those places too and the fix says so.

Example

The layers of this example, which the other rule pages use too:

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain",         modules = ["shop.domain"] },
  { name = "application",    modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]

The domain reaches into the infrastructure for a class that talks to the database:

shop/domain/order.py
from shop.infrastructure.db import SqlOrders


def total(order_id: int) -> int:
    return SqlOrders().total(order_id)
inwards check
shop/domain/order.py:1:36: INW001 Layer "domain" imports "shop.infrastructure.db.SqlOrders" from outer layer "infrastructure". Allowed direction: domain <- application <- infrastructure.
  fix: Depend on an abstraction owned by "domain" instead of "shop.infrastructure.db.SqlOrders".
    1. Delete `from shop.infrastructure.db import SqlOrders`. Do not move the import into a function or behind TYPE_CHECKING; Inwards checks those too.
    2. Declare a typing.Protocol in `shop.domain` (for example `shop.domain.ports`) that describes only what this module needs from `SqlOrders`.
    3. Type this module against that Protocol and receive the implementation through a constructor or function parameter.
    4. Make the class in "infrastructure" satisfy the Protocol, and wire it in the outermost layer (the composition root).
  docs: https://sircypkowskyy.github.io/inwards/rules/INW001/

Fixed: the domain owns a port, and the infrastructure implements it.

shop/domain/ports.py
from typing import Protocol


class Orders(Protocol):
    def total(self, order_id: int) -> int: ...
shop/domain/order.py
from shop.domain.ports import Orders


def total(orders: Orders, order_id: int) -> int:
    return orders.total(order_id)
shop/infrastructure/db.py
class SqlOrders:
    def total(self, order_id: int) -> int:
        return 0
inwards check

SqlOrders satisfies Orders structurally, so it needs no import of the domain. The composition root, in the outermost layer, passes a SqlOrders() to total.

How to fix

  1. Delete the import. Moving it into a function, behind TYPE_CHECKING or into importlib only moves the finding.
  2. Declare a typing.Protocol in the inner layer that describes only what the module needs.
  3. Type the module against the Protocol and take the implementation as a constructor or function parameter.
  4. Let the outer class satisfy the Protocol, and wire the two together in the outermost layer.

When the config has a layer named ports (a template's ports role, such as slice.ports over shop.*.ports) that sits inside the importing layer, step 2 names that role's module in the importing slice, for example shop.orders.ports. inwards context names the role the same way.

If the module really belongs in the inner layer, move it there instead. If the layers themselves are wrong, that is the user's call: the fix never tells an agent to edit [tool.inwards], and the config guard stops it.

Fix safety

Inwards has no automatic fix for this rule (autofix: false). The steps above change the design, so they need a person or an agent to carry them out and a review of the result.

Configuration

Severity error. [tool.inwards.rules] can turn the rule off (ignore = ["INW001"]) or make it a warning (severity = { INW001 = "warning" }); see Configure rules. To accept one import, suppress it on its line:

from shop.infrastructure.legacy import LegacyClient  # inwards: ignore[INW001] reason="old billing adapter, removed in #210"

For a parenthesised import, the comment goes on the imported name's line. The Claude Code hooks don't honour a suppression the agent added during the session unless agent-suppressions = "allow" (ADR-028).

In the Claude Code hooks, a violation blocks the edit that adds it and the Stop gate. A violation the file already had when the session started goes to the agent as context instead, so legacy code doesn't block an unrelated edit, with two exceptions. A file that was uncommitted, untracked or outside git at session start and too large for the hooks to keep a copy of (over 512 KiB, or past 4 MiB of such files) has no start content Inwards can prove, so every violation in it blocks once the agent edits it. With stop-gate = "project", the Stop gate checks the whole project, and every violation the baseline doesn't accept blocks, old or new. The Stop gate section has the details.

Known limitations

  • Layer membership is by module prefix only. Globs such as shop.*.domain for vertical slices come with the config schema v2 (#51).
  • There is no opt-out for type-only references to an outer layer (ADR-009).
  • Imports inside first-party code that belongs to no layer aren't checked; INW006 reports that code.
  • In an implicit namespace package (no __init__.py), relative imports resolve as if the directory were a regular package.
  • INW001 decides layers by module name, so an import through a symlink inside a layer that points into another layer passes it. INW006 reports such a link, and a link out of the config root, at the link itself (ADR-013).

References