Skip to content

Inwards: Architecture rules for Python, fast enough to run after every edit an AI agent makes.

You declare the layers in pyproject.toml. When an import points the wrong way, inwards check names the line and tells the agent how to fix it.

Get started Browse the rules

shop/domain/order.py1 line added
3from __future__ import annotations45from dataclasses import dataclass6from shop.infrastructure.in_memory_orders import InMemoryOrderRepository
$ uv run inwards check shop/domain/order.py
shop/domain/order.py:6:50: INW001 Layer "domain" imports "shop.infrastructure.in_memory_orders.InMemoryOrderRepository" from outer layer "infrastructure". Allowed direction: domain <- application <- infrastructure <- presentation <- bootstrap.
  fix: Depend on an abstraction owned by "domain" instead of "shop.infrastructure.in_memory_orders.InMemoryOrderRepository".
    1. Delete `from shop.infrastructure.in_memory_orders import InMemoryOrderRepository`. 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 `InMemoryOrderRepository`.
    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/

Found 1 violation in 1 file (63.7 ms).
A real run of Inwards 0.4.0 on the scaffold's example app with one bad import added, 2026-09-29. Exit code 1.

Your architecture, as rules

Layers go in pyproject.toml, innermost first. INW001 enforces the direction; every diagnostic carries fix steps.

[tool.inwards]
root = "."
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
  { name = "presentation", modules = ["shop.presentation"] },
  { name = "bootstrap", modules = ["shop.bootstrap"] },
]

Built into the agent loop

A check after each edit, a Stop gate over the whole session (Bash edits too), a config guard on the rules, and escalation to you after escalate-after tries.

violation + fix steps clean Agent edits a file Hook runs inwards check Agent continues

Catches what agents do

Hallucinated modules, imports behind TYPE_CHECKING, dynamic imports. Suppressions need a reason (INW009); loosening the config is refused.

$ uv run inwards check shop/domain/dodge.py --format concise
shop/domain/dodge.py:8:33: INW010 "shop.domain.pricing" is not a module of this project: "shop.domain" has no "pricing". fix: Check the name. The closest modules in "shop.domain": `shop.domain.order`.
shop/domain/dodge.py:11:54: INW001 Layer "domain" imports "shop.infrastructure.in_memory_orders.InMemoryOrderRepository" from outer layer "infrastructure". Allowed direction: domain <- application <- infrastructure <- presentation <- bootstrap. fix: Delete `from shop.infrastructure.in_memory_orders import InMemoryOrderRepository`. Do not move the import into a function or behind TYPE_CHECKING; Inwards checks those too.
shop/domain/dodge.py:15:12: INW011 Layer "domain" imports "shop.infrastructure.in_memory_orders" from outer layer "infrastructure" through a dynamic import (importlib.import_module). Allowed direction: domain <- application <- infrastructure <- presentation <- bootstrap. fix: Delete `importlib.import_module("shop.infrastructure.in_memory_orders")`. A dynamic import is still a dependency: building the module name at runtime or moving it to another loader hides it instead of removing it.
Found 3 violations in 1 file (109.4 ms).

Fast enough for every edit

The one-file check a hook runs stays under its 100 ms p95 budget, even on a 4,482-line file.

File p95, of 100 ms
saleor, one file 30 ms
The scaffold's example app 39.5 ms
polar, a 4,482-line file 90 ms
inwards check <file> with process start, one thread, 2026-09-26.

Cold full check, one core: 496,000 lines in 0.4 s, saleor's 848,000 in 1.8 s. Prescan misses: 0 in 6,543 files.

Quickstart

  1. Install it, or take a binary.

    uv add --dev inwards
    
    pip install inwards
    
  2. Add the Claude Code hooks. New project? Add --style clean --scaffold.

    uv run inwards init --agent claude
    
    inwards init --agent claude
    
  3. Check the project.

    uv run inwards check
    
    inwards check
    

Other agents: OpenCode, Aider, AGENTS.md.

Pre-alpha. Latest release: 0.7.0 on PyPI, 2026-10-11. Rules: INW000 to INW021, FAPI001 to FAPI003 and FAPI005 to FAPI009.