From source code to architectural feedback

How ArchUnit sees your system.

Every library adapts to its language, but the underlying journey is recognizable: find source, extract dependency evidence, build a graph, evaluate a sentence, and return proof.

Follow one Python boundary all the way through. TypeScript appears beside it at every language-specific turn, with the other libraries choosing their own native extraction strategy.

Begin the walkthrough ↓

Architecture becomes testable when source files become nodes,dependencies become edges, and a decision becomes a question the graph can answer.

SourceExtractProjectAssertReport
01 / 08Find the project that actually ships.
The ArchUnit analysis pipelineAn eight-stage diagram that changes from source discovery through dependency graph extraction, rule evaluation, cycle detection, and test feedback as the page scrolls.PYTHON PROJECTapi/orders.pyimport servicedomain/order.pypure policyinfra/sql.pyimport driverwalk .py files · apply .archignore · keep source rootsPYTHON ASTfrom app.domain import Orderimport requestsif TYPE_CHECKING:from app.ports import Storeimportlib.import_module("app.audit")from importexternaltype onlydynamicDIRECTED DEPENDENCY GRAPHapiorders.pydomainorder.pyinfrasql.pynormalized paths · typed edges · external targets · cached graphAN ARCHITECTURE SENTENCEproject files in **/api/**should notdepend on files in **/infrastructure/**becausethe API speaks to ports, not adapterssubject + mood + predicate + object + rationalePROJECT, FILTER, EVALUATEapidomaininfra1 architecture violationapi/orders.py → infrastructure/sql.pySEMANTICS THAT RESIST FALSE CONFIDENCENEGATIONforbidden edge existsviolationforbidden edge absentpassEMPTY SELECTION0 files matchedviolation by defaultexplicit opt-outallow emptyCYCLE DETECTIONABCTarjan: find SCC → Johnson: enumerate simple cyclesSTRUCTURED EVIDENCE, HUMAN FEEDBACKarchitecture testRuleAPI must not depend on infrastructureBecausethe API speaks to ports, not adaptersEvidenceapi/orders.py → infrastructure/sql.pyResult1 violation, test failed

Python walks source beneath the selected project root and applies explicit exclusions.

01Source

Find the project that actually ships.

A rule begins with a real project root and its language toolchain. ArchUnit does not start from a diagram or a manually maintained inventory.

Python, concretely

Python walks .py files beneath the selected root. Built-in exclusions skip environments, caches, builds, and generated package metadata; .archignore adds project-specific omissions.

for dirpath, dirnames, filenames in os.walk(project_root):
TypeScript, briefly

TypeScript starts from tsconfig.json, including its include and exclude rules, compiler options, path aliases, and referenced projects.

const config = ts.readConfigFile(configPath, ts.sys.readFile);
02Extract

Read dependency evidence without running the application.

Only source-backed relationships enter the model. The language adapter owns syntax and resolution; downstream architecture logic never needs to understand an import statement.

Python, concretely

Python uses ast.parse(), recognizes import, from import, relative imports, literal dynamic imports, TYPE_CHECKING blocks, and conditional ImportError fallbacks.

tree = ast.parse(source, filename=file_path)
TypeScript, briefly

TypeScript builds a Compiler API Program and resolves each import with the compiler host, using the same module-resolution settings as the project.

ts.forEachChild(sourceFile, node => ts.isImportDeclaration(node) && resolve(node));
03Graph

Turn files into nodes and imports into directed edges.

Paths are normalized, internal and external dependencies stay distinct, duplicate evidence is merged, and isolated source files remain visible through self-edges.

Python, concretely

An import from app.api.orders to app.infrastructure.sql becomes an edge from api/orders.py to infrastructure/sql.py. requests stays an external module edge.

Edge(source=file_path, target=resolved, external=is_external)
TypeScript, briefly

The same model is produced for .ts and .tsx files. Project references and alias resolution can contribute valid edges without depending on reference order.

const edge: Edge = { source, target, external, importKinds };
04Grammar

Build a sentence before doing expensive work.

The fluent API records a subject, a mood, a predicate, an object, and optionally a rationale. Builders describe intent; the terminal check performs extraction and evaluation.

Python, concretely

project_files("src/").in_folder("**/api/**").should_not().depend_on_files().in_folder("**/infrastructure/**")

project_files("src/").in_folder("**/api/**").should_not()
TypeScript, briefly

projectFiles().inFolder("src/api/**").shouldNot().dependOnFiles().inFolder("src/infrastructure/**")

projectFiles().inFolder("src/api/**").shouldNot()
05Project and assert

Ask a focused question of the graph.

A file rule projects edges, a layer rule groups their endpoints by named regions, a slice rule derives component names, and a metric rule projects measurable source facts.

Python, concretely

The dependency evaluator filters source edges by the API selector, tests their targets against the infrastructure selector, and emits one typed violation for every forbidden match.

_resolve_import_targets(import_, file_path, project_path)
TypeScript, briefly

ArchUnitTS follows the same projection and violation boundary asynchronously, which lets test-runner adapters await compiler-backed extraction.

ts.resolveModuleName(moduleName, sourceFile.fileName, options, compilerHost)
06Semantics

Make negation and empty tests explicit.

should_not changes the condition being evaluated; it does not invert whether the test runner passes. Zero selected subjects are a violation by default, because an architecture test that checks nothing is false confidence.

Python, concretely

CheckOptions(allow_empty_tests=True) is the deliberate opt-out. Otherwise a stale **/api/** pattern produces EmptyTestViolation instead of a green build.

CheckOptions(allow_empty_tests=True)
TypeScript, briefly

ArchUnitTS uses allowEmptyTests for the same opt-out and records the unmatched filter in EmptyTestViolation.

const options = { allowEmptyTests: true };
07Algorithms

Use graph algorithms where local matching is not enough.

Dependency direction is an edge question. Cycles are a graph question. Layer policies, slices, coupling, instability, and reachability each use the projection suited to their architectural meaning.

Python, concretely

Cycle detection first uses Tarjan to isolate strongly connected components, then Johnson to enumerate the simple cycles inside each relevant component.

cycles = calculate_cycles(number_edges)
TypeScript, briefly

The TypeScript implementation follows the same two-stage approach over internal projected edges, then converts complete paths into cycle violations.

const cycles = calculateCycles(numberEdges);
08Feedback

Keep findings structured until the delivery boundary.

Rules return violation data. Assertion adapters turn that data into pytest, Jest, Vitest, xUnit, or native test failures; reporters turn it into text, JSON, diagrams, SARIF, or HTML where supported.

Python, concretely

assert_passes() calls check(), formats every violation with concrete evidence, includes the because() rationale, and raises AssertionError only at the final boundary.

assert_passes(rule)
TypeScript, briefly

toPassAsync() integrates with the runner, while check() remains available for custom workflows and report generation.

await expect(rule).toPassAsync();

One decision, two native expressions

The grammar stays familiar. The language stays itself.

Python is the detailed example throughout this guide. TypeScript preserves the same architectural sentence with camelCase methods, asynchronous extraction, and native test matcher integration. Other ports adapt ownership, errors, parsing, and runners to their ecosystems rather than forcing a byte-for-byte translation.

Python

Static AST analysis, synchronous checks, pytest or framework-neutral assertions.

test_architecture.py
from archunitpython import project_files, assert_passes

rule = (
    project_files("src/")
    .in_folder("**/api/**")
    .should_not()
    .depend_on_files()
    .in_folder("**/infrastructure/**")
    .because("the API must depend on ports, not adapters")
)

assert_passes(rule)

TypeScript

Compiler-backed module resolution and an asynchronous matcher for modern test runners.

architecture.test.ts
import { projectFiles } from 'archunit';

const rule = projectFiles()
  .inFolder('src/api/**')
  .shouldNot()
  .dependOnFiles()
  .inFolder('src/infrastructure/**');

await expect(rule).toPassAsync();

Below the fluent API

The technical model, one contract at a time.

The first walkthrough follows the lifecycle of a rule. This second pass opens the engine: which evidence is collected, how names become nodes, how uncertainty is preserved, when work is cached, and exactly what a green result means.

Python remains the concrete implementation. TypeScript appears wherever its compiler model changes the mechanics, while the graph and evaluation contracts stay recognizable across the family.

01 / Extraction

Preserve evidence before deciding what it means.

Extraction is deliberately separate from rule evaluation. The parser records what the source says, including the importing file, the referenced module, relative depth, and relevant context. Only after that evidence exists does a resolver decide whether the target is an internal source node or an external dependency.

Python begins with an absolute project root. os.walk(project_root) recursively discovers *.py files while pruning excluded directories in place. The defaults remove virtual environments, dependency folders, Git metadata, caches, distribution output, build output, and egg metadata. Patterns from .archignore join that list before discovery, so ignored trees are never parsed.

Each Python file is decoded as UTF-8 and passed to ast.parse(source, filename=file_path). The extractor walks the tree with ast.walk(tree), recognizes ast.Import, ast.ImportFrom, and supported literal dynamic-import calls. It also records line numbers and classifies imports inside TYPE_CHECKING or a handled try / except ImportError block. Computed runtime module names are deliberately not guessed.

Relative Python imports are resolved by counting leading dots from the importing file’s directory, then trying both module.py andpackage/__init__.py. Absolute names are tested from the project root and its parent, longest module prefix first. A resolved internal target must also belong to the discovered source set; otherwise the module remains an external dependency instead of becoming an invented project node.

TypeScript lets the compiler define the project. ts.readConfigFile() reads the JSON source and ts.parseJsonConfigFileContent() applies extends, include, exclude, and compiler options. ArchUnitTS recursively visits every projectReferences entry throughts.resolveProjectReferencePath(), guarded by a visited-config set so cycles or repeated references cannot duplicate work.

The collected file names and root compiler options create a ts.Program through ts.createProgram(). ArchUnitTS visits top-level import declarations with ts.forEachChild() and resolves their module specifiers with ts.resolveModuleName(). When a source file belongs to more than one referenced configuration, resolution runs under every applicable option set and deduplicates results by resolved filename and external-library status.

Python implementation APIs, simplified
extract_graph.py
excludes = _resolve_exclude_patterns(project_path, patterns)
py_files = _find_python_files(project_path, excludes)
known_files = {_normalize(path) for path in py_files}

for file_path in py_files:
    edges.append(Edge(file_path, file_path, external=False))
    with open(file_path, "r", encoding="utf-8", errors="replace") as f:
        source = f.read()
    tree = ast.parse(source, filename=file_path)

    for node in ast.walk(tree):
        located = classify_import(node)
        if located is None:
            continue
        for target, external in _resolve_import_targets(
            located, file_path, project_path
        ):
            if external or target in known_files:
                edges.append(Edge(
                    source=_normalize(file_path),
                    target=target,
                    external=external,
                    import_kinds=_edge_import_kinds(located),
                ))

return _merge_edges(edges)
TypeScript Compiler APIs, simplified
extract-graph.ts
const contexts: ts.ParsedCommandLine[] = [];
const visited = new Set<string>();

function visitConfig(configPath: string) {
  const identity = path.resolve(configPath);
  if (visited.has(identity)) return;
  visited.add(identity);

  const source = ts.readConfigFile(identity, ts.sys.readFile);
  const parsed = ts.parseJsonConfigFileContent(
    source.config, ts.sys, path.dirname(identity), {}, identity
  );
  contexts.push(parsed);

  for (const ref of parsed.projectReferences ?? []) {
    visitConfig(ts.resolveProjectReferencePath(ref));
  }
}

visitConfig("tsconfig.json");
const options = contexts[0].options;
const host = ts.createCompilerHost(options);
const rootNames = [...new Set(contexts.flatMap(c => c.fileNames))];
const program = ts.createProgram({ rootNames, options, host });

for (const sourceFile of program.getSourceFiles()) {
  ts.forEachChild(sourceFile, node => {
    if (!ts.isImportDeclaration(node)) return;
    const moduleName = (node.moduleSpecifier as { text?: string }).text;
    if (!moduleName) return;
    const resolved = ts.resolveModuleName(
      moduleName, sourceFile.fileName, options, host
    ).resolvedModule;
    if (!resolved) return;
    imports.push({
      source: normalize(sourceFile.fileName),
      target: normalize(resolved.resolvedFileName),
      external: resolved.isExternalLibraryImport ?? false,
      importKinds: determineImportKinds(node),
    });
  });
}
1Discover

Apply roots, defaults, and explicit exclusions.

2Parse

Read syntax without importing or running the app.

3Collect

Retain module text, context, and source evidence.

4Resolve

Map evidence to internal or external identities.

02 / Resolution

An import string is evidence, not yet an edge.

Resolution gives an import its project identity. The same text can refer to a sibling module, an index file, a package export, a path alias, a referenced project, or an external package. Treating every string as a path would build a graph that looks precise while disagreeing with the language toolchain.

How common source evidence enters the project graph
EvidencePythonTypeScriptGraph outcome
Relative modulefrom ..domain import Orderimport Order from '../domain'Resolve from the importing module or source file.
Project aliasResolve against indexed package roots.Apply tsconfig paths and project references.Internal edge to the canonical project-relative node.
External packageimport requestsimport express from 'express'External identity remains queryable without becoming a source node.
Literal dynamic importimport_module('app.audit')A literal specifier can be inspected by the language adapter.Edge when the target is statically resolvable.
Computed runtime targetimport_module(prefix + name)import(prefix + name)No invented edge. The static boundary remains explicit.
Python

Build an index before resolving.

Discovered files establish the allowed internal targets. Relative imports count dot depth from the source directory and test a module file before a package initializer. Absolute imports search the project root and its parent, shortening the dotted name until a file or package resolves. Namespace-package aliases receive a second pass sofrom package import feature can resolve the feature submodule rather than stopping at the package.

TypeScript

Let compiler configuration define identity.

Each source file is matched back to the parsed configurations that include it. The module specifier is passed to ts.resolveModuleName() with every applicable compiler-option context and the shared compiler host. A map keyed by normalized filename and external-library status removes duplicate resolutions without allowing reference order to hide a valid edge.

sourceapi/orders.pyproject node
dependency edgeline 7 · importconditional: false
targetinfrastructure/sql.pyproject node

03 / Graph model

Canonical identities make every later question cheaper.

Nodes use normalized project-relative identities so selectors, reports, and test output refer to the same file. Duplicate import evidence can be merged into one relationship without losing the source records that explain it. Internal and external targets remain distinct because the questions teams ask about them are different.

Isolated files remain observable. A project model that drops nodes without outgoing edges would make naming, placement, size, and empty-selection checks depend on whether a file imports something. The graph therefore represents the discovered source set, not only the edges that happened to resolve.

Both implementations use an edge list as the graph boundary. Python creates a self-edge for every discovered file before it processes imports, then merges equal source-target pairs while unioning their import kinds. TypeScript likewise adds a self-edge for every project source file. This keeps isolated files selectable without introducing a separate node collection that could drift from the edges.

Python graph records
graph.py
class ImportKind(Enum):
    IMPORT = "import"
    FROM_IMPORT = "from_import"
    RELATIVE_IMPORT = "relative"
    DYNAMIC_IMPORT = "dynamic"
    TYPE_IMPORT = "type"
    CONDITIONAL_IMPORT = "conditional"

@dataclass(frozen=True)
class Edge:
    source: str
    target: str
    external: bool
    import_kinds: tuple[ImportKind, ...] = ()
TypeScript graph records
graph.ts
export type Edge = {
  source: string;
  target: string;
  external: boolean;
  importKinds: ImportKind[];
};

export type Graph = Edge[];

04 / Evaluation

A fluent sentence compiles into a small evaluation plan.

Builders record intent. The terminal check obtains the project graph, selects subjects, projects the relevant relationships or metrics, evaluates the condition, and returns structured findings. The assertion adapter is deliberately last.

forbidden-dependency.pseudo.py
subjects = graph.nodes.filter(subject_selector)

if subjects.is_empty() and not options.allow_empty_tests:
    return [EmptyTestViolation(subject_selector)]

candidate_edges = graph.outgoing_edges(subjects)
violations = [
    edge for edge in candidate_edges
    if target_selector(edge.target)
]

return CheckResult(rule=rule, violations=violations)

For a negative dependency rule

SubjectsForbidden edgesResult
0unknownEmpty-test violation
1 or more0Pass
1 or more1 or moreOne finding per matched edge

should_not() negates the condition. It never means that the test runner should pass when violations exist.

Subject

The files, classes, slices, layers, or metric population being evaluated.

Condition

The relationship or measurement the selected subjects must satisfy.

Evidence

The edge, path, source fact, or measured value that supports the finding.

Adapter

The pytest, Jest, Vitest, xUnit, native, or report boundary that presents it.

05 / Performance and reuse

Extract broadly once. Project narrowly many times.

Parsing is usually the expensive boundary. A suite should not rediscover and reparse the same project independently for every architectural sentence. The project model can be cached for compatible roots and configuration, then reused by file, layer, slice, metric, and report projections.

Scope still matters. Excluding build output and generated trees reduces both noise and work. Focused checks can project a subset of an existing graph, while a configuration or source change invalidates the relevant cached model.

  1. 01
    Discover once

    Normalize source roots and exclusions.

  2. 02
    Parse once

    Collect reusable language evidence.

  3. 03
    Index once

    Resolve canonical internal identities.

  4. 04
    Project per rule

    Filter the graph for the current question.

  5. 05
    Render per consumer

    Reuse findings in tests, CI, and reports.

Why cycle checks reduce the graph first

Tarjan finds strongly connected components in linear time over the projected nodes and edges. Only components capable of containing a cycle continue to Johnson’s enumeration. Enumeration must still spend time proportional to the cycles it returns, which is why scoping and complete path evidence matter on highly connected graphs.

06 / Trust boundary

Static analysis is strongest when it states its limits.

Source-backed

What the graph can prove well

  • Direct internal and external dependencies visible in supported syntax
  • Cycles, layer direction, slice relationships, and reachability over those edges
  • Naming, placement, source counts, and language-specific static metrics
  • Whether a selector observed the source population it claimed to protect
Runtime-dependent

What the graph should not guess

  • Computed import targets whose module name exists only at runtime
  • Dependencies introduced through reflection, monkey patching, or container wiring
  • Business behavior, semantic duplication, or whether a boundary is a good decision
  • Production calls that are not represented by a supported source relationship

Runtime tests, traces, and human review complement the static graph. The useful contract is not that ArchUnit knows everything. It is that every reported relationship has a clear path back to source evidence, and every unsupported category remains visible as a limitation.

The contract behind a green test

Three details carry most of the trust.

01

Selection is observable

Patterns match normalized project-relative identifiers. A rule that selects nothing fails unless the caller opts out, so a directory rename cannot quietly erase coverage.

02

Negation belongs to the condition

“Should not depend” means a matching edge is evidence of a violation. The evaluator still returns a list of findings; pass means that list is empty.

03

Violations stay data

A rule does not print and exit. It returns typed evidence that an assertion adapter, report renderer, CI integration, or custom tool can phrase for its audience.

Inside a cycle check

Reduce first. Enumerate second.

Testing every possible path across a large codebase would repeat useless work. ArchUnit narrows the problem before it names the cycles a developer needs to fix.

  1. 1
    Project internal edges

    Drop external dependencies and apply the rule’s file scope.

  2. 2
    Find strongly connected components

    Tarjan identifies regions in which every node can reach another.

  3. 3
    Enumerate simple cycles

    Johnson walks only relevant components and returns complete, non-repeating paths.

  4. 4
    Attach source evidence

    Projected edges become cycle violations that name the path closing the loop.

One graph, several architectural views

Choose the level that matches the decision.

Files
Concrete source nodes for naming, placement, direct dependencies, and cycles.
Layers
Named regions with an allowlist or blocklist for dependency direction.
Slices
Repeated components derived from paths, then compared with each other or a diagram.
Metrics
Measurements over files, classes, packages, or graph relationships and their thresholds.
Reports
The same graph narrowed, collapsed, traversed, and rendered for review or automation.

The complete model

Language-native extraction.
Shared architectural reasoning.

The parser changes. The project model changes. The runner changes. The central contract does not: derive evidence from source, ask a precise question, and make disagreement useful.