01

Python moves quickly

Python makes it easy to move from an idea to a working service, pipeline, or model. That is one of its strengths. It also means a notebook can become a package, a script can become an API, and an experiment can become production before its module boundaries have been made explicit.

ArchUnitPython turns imports and source structure into tests. It has no production runtime dependency, works with pytest and unittest, and lets teams start with one boundary instead of adopting a separate architecture platform.

The library analyzes source statically, so the application does not have to be imported or started. That matters for services with expensive initialization, data projects with environment-specific connectors, and AI systems whose runtime dependencies are unavailable in a lightweight CI job.

test_architecture.py
from archunitpython import project_files, assert_passes

def test_domain_does_not_import_routes():
    rule = (
        project_files('src/')
        .in_folder('**/domain/**')
        .should_not()
        .depend_on_files()
        .in_folder('**/routes/**')
    )
    assert_passes(rule)
02

What the analyzer reads

ArchUnitPython walks Python files beneath the selected project root and parses each file with the standard abstract syntax tree. It recognizes ordinary import statements, from imports, relative imports, literal calls to importlib.import_module and __import__, imports guarded by TYPE_CHECKING, and common conditional ImportError fallbacks.

The extractor records source-backed evidence without executing module code. Literal dynamic imports can be represented because their target is visible in the syntax tree. A module name assembled from runtime values cannot be resolved safely, so the analyzer does not invent an edge. This is a deliberate static-analysis boundary rather than an attempt to simulate the interpreter.

Project-level defaults exclude virtual environments, caches, build output, and generated package metadata. A repository can add .archignore entries and scoped exclusions for generated clients, migrations, fixtures, or other files that should not participate in a particular architectural question.

python-extraction.txt
source discovery
    -> ast.parse()
    -> import evidence
    -> internal or external resolution
    -> normalized dependency graph
03

Useful boundaries for Python applications

The first rule should protect the decision that most often erodes. In a FastAPI service, that may mean domain logic cannot import route modules. In a Django project, apps may need explicit dependency direction. In a data platform, orchestration code should not leak into reusable transformations.

A layered rule is useful when responsibilities have a clear direction. A slice rule is better when the system repeats a vertical structure such as customers, orders, and billing. File rules handle precise source relationships, while external dependency restrictions can keep frameworks and vendor SDKs behind designated adapters.

Folder names are not architecture by themselves. They become an architectural model when the selectors have a stable meaning, the allowed relationships are explicit, and the test fails with the source path that crossed the boundary.

  • Keep domain code independent from web frameworks and database adapters.
  • Detect cycles across packages before import order becomes operational behavior.
  • Restrict external dependencies to designated integration modules.
  • Track size, coupling, cohesion, and distance metrics as the project grows.
04

Keep domain code independent from frameworks

Hexagonal and clean architectures depend on an inward direction: domain policy can define ports, while web frameworks, database clients, message brokers, and model providers stay in outer adapters. Python will not enforce that direction. A route can import a SQLAlchemy model or a domain object can instantiate a vendor client, and both may work perfectly in production until a later change makes the coupling expensive.

An external-dependency rule can restrict FastAPI, Django, SQLAlchemy, boto3, or an AI provider SDK to adapter modules. A file-dependency rule can prevent domain packages from importing routes or infrastructure. Together they protect both sides of the boundary: where vendor code may appear and which internal layer may reach it.

This is particularly useful in AI systems. Retrieval, model access, vector storage, and orchestration libraries change quickly. Keeping them behind application ports lets teams replace infrastructure without rewriting the policy and evaluation logic that gives the system its meaning.

test_domain_boundary.py
def test_domain_stays_framework_independent():
    rule = (
        project_files("src/")
        .in_folder("**/domain/**")
        .should_not()
        .depend_on_files()
        .in_folder("**/infrastructure/**")
        .because("domain policy must stay portable")
    )
    assert_passes(rule)
05

Cycles are a graph problem

Python circular imports sometimes fail immediately and sometimes remain latent until import order, type annotations, or initialization behavior changes. Even when the interpreter accepts the cycle, the design cost remains: two modules can no longer be understood, tested, or moved independently.

Cycle detection starts from the internal dependency graph rather than inspecting one file at a time. ArchUnitPython isolates strongly connected components and enumerates the simple cycles within the relevant component, then reports complete dependency paths. That turns a vague circularity warning into a sequence a developer can break deliberately.

Scope matters. A project-wide cycle check can be valuable, but a focused rule over services or domain modules often produces a clearer first adoption step. Once the existing region is clean, the scope can expand without introducing a permanently red suite.

06

Metrics are sensors, not quality scores

Structural metrics add a different kind of evidence. Lines of code and method counts can reveal growing units. Cohesion can identify classes whose methods operate on unrelated fields. Afferent and efferent coupling describe how a component is depended on and what it depends on. Abstractness and instability can be combined as distance from the main sequence.

The threshold is a team decision, not a universal constant. Measure comparable code, inspect the current distribution, and start with a ceiling that prevents regression. A fixed limit copied from another repository can reward superficial splitting or create noise around code that is large for a legitimate reason.

Metrics work best as ratchets. Establish the baseline, prevent new outliers, and lower the threshold as cleanup lands. Keep any exception narrow and explain why the measured component differs from the population around it.

test_architecture_metrics.py
from archunitpython import metrics, assert_passes

def test_source_files_do_not_keep_growing():
    rule = (
        metrics("src/")
        .count()
        .lines_of_code()
        .should_be_below(600)
    )
    assert_passes(rule)
07

Make adoption observable in CI

Architecture tests are ordinary tests, so the first CI integration can be a focused pytest command. Keep the suite near the source, use descriptive test names, and publish logs or reports when they add useful context for review. A failure should identify the rule, rationale, subject, and offending dependency rather than force a reviewer to reconstruct the graph.

Protect the guardrail itself. Changes to architecture tests, thresholds, ignore files, and CI steps deserve the same ownership as the design they encode. In an agent-assisted repository, that can mean CODEOWNERS or an explicit review rule so automation cannot make the pipeline green by weakening the verifier.

For a brownfield codebase, avoid a permanent all-project exception. Start with one clean module or one high-cost boundary, record narrow temporary exclusions, and expand the observed scope as violations are removed.

.github/workflows/architecture.yml
name: Architecture checks
on: [pull_request]
jobs:
  architecture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements-dev.txt
      - run: python -m pytest tests/test_architecture.py -q
08

A guardrail for generated code

AI-assisted development increases the amount of code a team can produce, but a generated implementation does not automatically know the local architecture. A deterministic test closes that gap. The prompt can describe the intended boundary; the architecture test proves whether the resulting files respect it.

Start with one rule, make it pass, and run it in CI. Add the next rule when the system reveals a boundary worth protecting. Architecture testing works best as a focused suite of high-value decisions, not a wall of abstract policy.

The most useful agent loop is explicit: change code, run behavioral tests, run the focused architecture suite, inspect source-backed violations, and correct production code. The agent should not delete the rule, broaden an ignore, or raise a threshold simply to reach green. Humans retain ownership of what the architecture is allowed to become.