How ArchUnit works, from first principles

Source code becomes a graph. Rules become questions.

ArchUnit does not run your application and watch requests move through it. It inspects source structure, resolves dependency evidence, builds a directed graph, and evaluates a precise architectural sentence against that graph.

The language adapter changes. Python reads Python syntax; TypeScript asks the TypeScript compiler. The reasoning after extraction stays recognizable.

source text→syntax→resolved imports→directed graph→rule projection→violations

The smallest useful model

Four terms are enough to follow the walkthrough.

These terms give us a shared vocabulary. The later chapters show the implementation details, language adaptations, projections, algorithms, and test integration behind them.

01

Node

A stable identity for something architecture rules can talk about. File rules primarily use source files such as src/ui/ui.ts.

02

Directed edge

An arrow from the depender to the dependency. If A imports B, the graph recordsA → B. Direction is architectural evidence.

03

Projection

A focused view of the raw graph. The same edges can be viewed as files, folders, layers, slices, internal dependencies, external modules, or cycle paths.

04

Violation

Structured evidence that the projected graph disagrees with a rule. It remains data until a test adapter decides to raise, fail, print, or render it.

Chapter 02 / Discover and parse

Choose an adapter, then follow source code into syntax evidence.

Static analysis means inspecting program text and its structure without executing the application. The shared goal is the same in every language, but the project boundary, parser, and supported syntax are language-specific.

Python adapter

A directory root becomes decoded source text, then an Abstract Syntax Tree.

Nothing is imported or executed. ArchUnitPython chooses a root, discovers supported files below it, reads each file as text, and asks Python's standard parser for a syntax tree.

  1. 01

    Establish the project root.

    project_files("src/") stores the supplied path until check() runs. Extraction turns it into an absolute path. If no path is supplied,os.getcwd() uses the test process's current working directory. This one root anchors discovery,.archignore, caching, and the decision that a resolved target belongs to this project.

  2. 02

    Walk the directory tree without entering excluded folders.

    os is Python's built-in operating-system module. os.walk(root) works top down and yields three values for each folder: its path, a mutable list of immediate child folder names, and a list of filenames.

    Before filtering ["src", ".venv", "build"]

    After dirnames[:] ["src"]

    dirnames[:] = kept_names replaces the contents of that same list object. os.walk()consults the list before descending, so it enters onlysrc. This does not rename or delete folders. Assigning a new local list with dirnames = kept_nameswould not change the list held by os.walk().

  3. 03

    Read one file into the form this parser uses.

    A source file begins as bytes on disk. Opening it in text mode with UTF-8 decoding and calling file.read() consumes the complete file to its end and returns one Python str.errors="replace" substitutes malformed byte sequences rather than aborting the whole scan. This implementation passes that decoded source string to ast.parse().

  4. 04

    Parse syntax, then visit its descendants.

    AST means Abstract Syntax Tree. ast.parse(source)returns an ast.Module whose child nodes represent statements and expressions. Thefilename=file_path argument labels parser diagnostics; it does not read the file. ast.walk(tree)recursively yields every descendant, so the outer loop can see imports nested in functions, conditionals, and try blocks. An inner loop is needed because one statement such asimport a, b contains two aliases and must produce two import records.

Python Source-faithful file-discovery path with teaching comments

extract_graph.py
# The fluent builder stores project_path. Extraction resolves it here.
root = os.path.abspath(project_path or os.getcwd())

# os.walk yields one directory at a time: its path,
# mutable child-directory names, and the file names inside it.
for dirpath, dirnames, filenames in os.walk(root):
    # Example before filtering: ["src", ".venv", "build"]
    # Replace this same list's contents, so os.walk sees ["src"]
    # and never descends into .venv or build.
    dirnames[:] = [
        name for name in dirnames
        if not _should_exclude_path(
            os.path.join(dirpath, name), root, exclude, is_dir=True
        )
    ]
    for filename in filenames:
        full_path = os.path.join(dirpath, filename)
        if filename.endswith(".py") and not _should_exclude_path(
            full_path, root, exclude, is_dir=False
        ):
            # Keep one stable absolute identity per Python source file.
            py_files.append(os.path.abspath(full_path))

Python Abridged source: error handling, parsing, and one import branch

extract_graph.py · abridged source
def _extract_located_imports(file_path):
    # Decode the complete .py file into one Python string.
    try:
        with open(file_path, "r", encoding="utf-8", errors="replace") as file:
            source = file.read()
    except (OSError, IOError):
        return []

    # filename labels parser diagnostics; ast.parse does not read the file.
    try:
        tree = ast.parse(source, filename=file_path)
    except SyntaxError:
        return []

    imports = []
    ignore_directives = _find_ignore_directives(source)
    type_checking_ranges = _find_type_checking_ranges(tree)
    conditional_import_ranges = _find_conditional_import_ranges(tree)
    # Walk descendants because imports may be nested in functions or try blocks.
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            syntax_kind = ImportKind.IMPORT
            kind = _classify_import(
                node, syntax_kind, type_checking_ranges, conditional_import_ranges
            )
            for alias in node.names:
                imports.append(_LocatedImport(
                    alias.name, kind, node.lineno, syntax_kind
                ))
        elif isinstance(node, ast.ImportFrom):
            # Relative and from-import nodes follow the same record pipeline.
            ...
        elif isinstance(node, ast.Call):
            # Only literal importlib/__import__ module names can be recorded.
            ...
    # Apply inline and next-line # archunit: ignore directives.
    return [
        item for item in imports
        if not _is_ignored_import(item, ignore_directives)
    ]
After readingsource: str

The decoded characters from one complete .py file.

After parsingtree: ast.Module

A root node whose descendants mirror the file's syntax and source locations.

After walkinglocated import evidence

_LocatedImport stores module text, syntax and resolution kinds, line number, fallbacks, and aliases. The outer file loop separately supplies file_path as the source identity.

Source statementfrom ..db import session
Python AST nodeImportFrom(module="db", level=2)
ArchUnitPython record_LocatedImport(module_name="..db", import_kind=RELATIVE_IMPORT, line_number=12, resolution_kind=RELATIVE_IMPORT, aliases=("session",))

Chapter 03 / Resolve and build the graph

Syntax proves that an import exists. Resolution proves what it points to.

A parser returns the written module text and its syntax context. The same text can identify different files depending on the importing file, project root, package layout, aliases, and language configuration. A graph needs stable file identities, so the adapter must resolve that text before recording an edge.

Python adapter

The AST describes ..db; the resolver finds the concrete file.

_LocatedImport is deliberately intermediate evidence. It knows what was written, where it appeared, and whether the syntax was relative, conditional, type-only, or dynamic. It cannot know the final target from syntax alone.

1 / Locatesrc/ui/view.py

The outer file loop supplies the source identity. Inside that file, the parser findsfrom ..db import database on line 12.

2 / Classify_LocatedImport

The record keeps module_name="..db", line 12, alias database, and two kinds. Theimport_kind describes context, whileresolution_kind preserves relative-path semantics even if the import is also conditional or type-only.

3 / Resolvesrc/db.py

Leading dots move from the importing package. The resolver tests a module file and then a package __init__.py. A path inside the discovered project becomes an internal target.

4 / RecordEdge(view.py → db.py)

Only after resolution can ArchUnitPython store the importing path, resolved target,external=False, and the import kinds as one directed evidence record.

Python resolution in source

Resolve against files that actually exist.

Relative imports move from the source directory. Absolute imports are tested from the project root and its parent. One from-import can yield multiple namespace-package alias targets, so _resolve_import_targets() returns a list rather than one answer.

The excerpt is abridged only around its enclosing loops. Every shown branch and record field comes from the current extractor.

extract_graph.py · abridged source
# One located import can resolve to multiple namespace-package targets.
for resolved, is_external in _resolve_import_targets(
    located_import, file_path, project_path
):
    # Do not duplicate the self-edge already used to preserve the node.
    if resolved and resolved != _normalize(file_path):
        # An internal target must belong to the discovered Python file set.
        if not is_external and resolved not in normalized_py_file_set:
            continue

        edges.append(Edge(
            source=_normalize(file_path),
            target=resolved,
            external=is_external,
            import_kinds=_edge_import_kinds(located_import),
        ))

# Equal source/target pairs become one edge with combined import kinds.
return _merge_edges(edges)

What Python records after resolution

The raw graph is Graph = list[Edge]. Each immutable edge stores a normalized absolute source path. Internal targets are absolute file paths too. An external target may remain a module identifier such asos, while an unresolved relative import or a module found outside the project root can remain a normalized absolute candidate path. The edge also carries an external flag and import kinds such asrelative,conditional, ortype. There is no separate node table. A self-edge for every discovered file preserves files with no imports; equal source-target pairs are merged and their import kinds are combined.

Concrete trace from ArchUnitTS

A real run makes the source-to-violation pipeline observable.

ArchUnitPython does not currently emit an equivalent edge-by-edge debug trace. The lab therefore uses ArchUnitTS to make discovery, compiler setup, import resolution, projection, and evaluation visible. The trace below was captured from the repository fixture; it is not fabricated output.

real fixture / ArchUnitTS
ui.ts
// This import is a violation!
import { startDb } from '../db/db';

export function openUi() {
  console.log('Opened UI');
  startDb();
}
Captured trace completedirected dependency graph
NODE Asrc/ui/ui.tsimports ../db/dbNODE Bsrc/db/db.tsinternal targetEDGE A → Bexternal: falsekinds: value, named

The arrow points from the file that imports to the file being imported. Reversing it would reverse the architecture rule.

archunit debug outputcaptured 27 Sep 2026
[INFO] Starting architecture rule check: Dependency check: 1 filters[INFO] Using TypeScript config file: <repo>/test/layered-arch/layered-arch-1/tsconfig.json[INFO] TypeScript program created with 199 source files[DEBUG] Project source files: 2[DEBUG] Node modules files: 197[DEBUG] Processing file 1/2: src/db/db.ts[DEBUG] Processing file 2/2: src/ui/ui.ts[DEBUG] Processing import: "../db/db" in src/ui/ui.ts[DEBUG] Resolved "../db/db" to: src/db/db.ts (external: false)[INFO] Import analysis completed:[INFO]   - Total edges found: 132[INFO]   - Files processed: 2[INFO] Graph extraction completed with 132 edges[DEBUG] Analyzing dependencies across 124 edges[INFO] Found edge: From src/ui/ui.ts to src/db/db.ts[WARN] Violation found: Dependency violation: {"dependency":{"sourceLabel":"src/ui/ui.ts","targetLabel":"src/db/db.ts","cumulatedEdges":[{"source":"src/ui/ui.ts","target":"src/db/db.ts","external":false,"importKinds":["value","named"]}]},"isNegated":true}[WARN] Completed architecture rule check: Dependency check: 1 filters (1 violations)

This is a filtered real run of the repository's layered-architecture fixture. Only the machine-specific repository prefix was replaced with <repo>; Windows separators were normalized for display. The messages, counts, ordering, paths, and violation evidence come from the run.

Complete trace shown. Replay it to follow the pipeline one step at a time.

Stage 1 / Program boundary

A TypeScript Program includes project files plus declaration files required by its configured type environment. The trace identifies 2 project files and 197 files classified by the logger as coming fromnode_modules.

Stages 2–4 / Extract and project

Processing import andResolved show syntax becoming a target. The full run recorded 132 raw edges. The file-rule projection grouped duplicate source-target labels into 124 candidates for evaluation.

Stages 5–6 / Select and judge

Found edge logs each projected candidate before the rule's filters and mood are applied. The followingViolation found line is where this candidate is confirmed as forbidden; it keeps the source, target, accumulated raw evidence, and negated mood. The full raw run also contains the self-edges that preserve import-free files, although this excerpt focuses on the forbidden UI-to-database dependency.

Raw graph versus projected graph

Projection changes the viewing scale for one question. It does not discover new dependencies.

The raw graph is the adapter's file-level evidence. A rule may need to ask about those exact files, or it may need a coarser view such as folders, layers, or business slices. A projection is the small mapping step between those two levels. It can discard edges that are irrelevant to the question, assign labels to each endpoint, and group edges that now have the same label pair.

Raw file factsorders/api.py → payments/client.pyorders/jobs.py → payments/client.py
One slice-level candidateOrders → Payments

cumulated_edges still contains both raw file edges.

In this grouping example, neither contributing import is lost. The projected edge gives the rule one Orders → Payments candidate, while its accumulated evidence still identifies the two exact imports to report when the rule fails. A different projection may deliberately skip edges outside its question. This is why the model needs both a raw graph and a projected graph.

  1. Map or skip. A mapper returns labels for a raw edge or no value when that edge is irrelevant.
  2. Group equal labels. Edges with the same projected source and target become one candidate.
  3. Retain proof. Every contributing raw edge remains attached for diagnostics and reports.

Python file dependency rules:per_internal_edge() removes external and self-edges, keeps the full paths as labels, and groups equal pairs.

TypeScript file dependency rules:perEdge() keeps each raw edge's labels and groups duplicate pairs. It does not automatically remove external or self-edges. The real trace's 132 to 124 change is grouping, not a second scan.

Output of this chapter

A raw edge list containing resolved facts, plus a question-specific list of projected candidates whose accumulated edges preserve the underlying proof.

Chapter 04 / Turn intent into a fluent rule

The fluent API is a sentence-shaped plan, not the graph itself.

Building the chain records a subject, a mood, a predicate, an object, and optionally a rationale. Expensive work begins only at the terminalcheck() or the test-framework assertion.

Chapters 02 and 03 use the language selector because discovery, parsing, and resolution are adapter-specific. From this chapter onward, the machine is shared. Python and TypeScript implementation differences are labeled explicitly; unavailable adapters are not inferred from another language.

entryproject files
→
subjectin UI
→
moodshould not
→
predicatedepend on files
→
objectin DB
→
terminalcheck

Python The sentence and its architectural reason

test_architecture.py
# Build a lazy architecture sentence. No project scan happens yet.
rule = (
    project_files("src/")
    # Subject: only files under a UI folder.
    .in_folder("**/ui/**")
    # Mood and predicate: forbid matching dependency edges.
    .should_not()
    .depend_on_files()
    # Object: dependency targets under a DB folder.
    .in_folder("**/db/**")
    .because("UI code must not own persistence")
)

# The assertion terminal extracts, evaluates, and fails on findings.
assert_passes(rule)

TypeScript The real fixture rule with debug logging

architecture.spec.ts
// Build the same lazy sentence with TypeScript naming.
const rule = projectFiles("tsconfig.json")
  .inFolder('src/ui')
  .shouldNot()
  .dependOnFiles()
  .inFolder('src/db');

// check() triggers extraction and returns structured violations.
const violations = await rule.check({
  clearCache: true,
  logging: { enabled: true, level: 'debug' },
});
Subject filters

Select source labels. Multiple scope filters combine to narrow the population.

Mood

should_not() sets negated semantics. It does not mean that violations pass.

Object filters

Select dependency targets. For this rule, a matching directed edge is forbidden evidence.

Rationale

because() travels to the assertion message so policy and evidence stay together.

Chapter 05 / Evaluate the rule

The evaluator is a small machine over an in-memory graph.

Language-specific parsing is finished. From here the rule terminal projects graph edges, applies selectors, and creates typed violations. The negative dependency example can be described without knowing Python or TypeScript syntax.

1Extract

Obtain the cached or newly built raw graph.

2Project

Create stable source and target labels for this rule. Python file-dependency rules keep internal edges; TypeScript uses perEdge()and retains external and self-edges.

3Select subject

Does the source label match every UI filter?

4Select object

Does the target label match every DB filter?

5Judge mood

For should_not(), a double match is a violation.

6Return evidence

Keep the projected edge and its underlying raw imports.

Python Extract, project internal edges, then evaluate

files.py + depend_on_files.py
# DependOnFileCondition.check(): extract, project, then delegate.
graph = extract_graph(self._project_path, options=options)
edges = project_edges(graph, per_internal_edge())
return gather_depend_on_file_violations(
    edges,
    self._subject_filters,
    self._object_filters,
    self._is_negated,
)

# gather_depend_on_file_violations(): inspect each projected edge.
for edge in edges:
    # All subject filters must accept the importing file.
    source_matches = all(
        matches_pattern(edge.source_label, f) for f in subject_filters
    )
    if not source_matches:
        continue

    # All object filters must accept the resolved target file.
    target_matches = all(
        matches_pattern(edge.target_label, f) for f in object_filters
    )
    # For should_not(), a matching source and target is forbidden.
    if is_negated and target_matches:
        violations.append(ViolatingFileDependency(
            dependency=edge, is_negated=True
        ))

TypeScript Extract, project per edge, guard empty selections, then evaluate

files.ts + depend-on-files.ts · abridged
// DependOnFileCondition.check(): extract, project, then delegate.
const graph = await extractGraph(configFileName, options);
// perEdge() keeps external and self-edges; equal label pairs are grouped.
const projectedEdges = projectEdges(graph, perEdge());
// These aliases shorten the current builder fields for this excerpt.
const sourceFilters = this.dependOnFileConditionBuilder
  .matchPatternFileConditionBuilder
  .filesShouldCondition.filters;
const targetFilters = this.dependencyFilters;
const isNegated = this.dependOnFileConditionBuilder
  .matchPatternFileConditionBuilder.isNegated;
const allowEmptyTests = options?.allowEmptyTests || false;
return gatherDependOnFileViolations(
  projectedEdges,
  sourceFilters,
  targetFilters,
  isNegated,
  allowEmptyTests,
);

// gatherDependOnFileViolations(): select both ends of each edge.
const matchingSources = projectedEdges.filter((edge) =>
  matchesAllPatterns(edge.sourceLabel, sourceFilters),
);
const matchingTargets = projectedEdges.filter((edge) =>
  matchesAllPatterns(edge.targetLabel, targetFilters),
);
if (matchingSources.length === 0 && !allowEmptyTests) {
  return [new EmptyTestViolation(sourceFilters)];
}
if (matchingTargets.length === 0 && !allowEmptyTests) {
  return [new EmptyTestViolation(targetFilters)];
}

// For shouldNot(), an edge selected at both ends is forbidden.
return projectedEdges
  .filter((edge) =>
    isNegated
      ? matchingSources.includes(edge) && matchingTargets.includes(edge)
      : !matchingSources.includes(edge) || !matchingTargets.includes(edge),
  )
  .map((edge) => new ViolatingFileDependency(edge, isNegated));
all(...)

This is logical AND across every configured filter. A source or target must satisfy all of them, not merely one.

EmptyTestViolation

TypeScript reports an empty-test violation when no source or target candidate matches, unless allowEmptyTests is enabled. This prevents a misspelled path from passing silently.

continue

A nonmatching source rejects the current edge early and advances the loop to the next candidate.

matches_pattern()

The matcher compares the normalized projected path label with a fluent API filter such as **/ui/**.

sourcetargetsubject matchobject matchresult
src/ui/view.pysrc/db.pyyesyesviolation
src/ui/view.pysrc/ui/presenter.pyyesnoignored
src/core/config.pysrc/db.pynoyesignored

Zero matches need an explicit policy

A green test that inspected nothing is dangerous.

Empty-selection handling is terminal-specific. Python's cycle and some pattern terminals call _check_empty_test() and return anEmptyTestViolation unlessallow_empty_tests is enabled. The PythonDependOnFileCondition shown above does not currently add that guard. This is concrete terminal behavior, not a magical property of the graph, and callers should scope dependency rules deliberately. TypeScript's dependency condition checks both the source and target selections and returns anEmptyTestViolation by default when either is empty.

Output of evaluation

Python returns a list[Violation]; TypeScript resolves a Promise<Violation[]>. Each dependency finding keeps the projected edge, its accumulated raw edges, and whether the rule was negated. Nothing has failed the test runner yet.

Chapter 06 / Algorithms

Different architecture questions require different graph operations.

A forbidden direct dependency is local: inspect candidate edges. A cycle is global: find a path that eventually returns to its starting node. ArchUnit chooses the operation that matches the meaning of the rule.

Direct dependency

Filter edges.

Project to the relevant edge type, match source labels, match target labels, and emit one finding for every forbidden relationship.

Work grows with inspected edges.
Cycle detection, phase one

Tarjan finds strongly connected components.

A strongly connected component is a region where every node can reach every other node. Tarjan identifies those regions in linear time over nodes and edges.

Discard regions that cannot contain a cycle.
Cycle detection, phase two

Johnson enumerates simple cycles.

Johnson's algorithm runs inside the relevant components and returns complete cycle paths without repeating vertices inside one path.

Work also depends on how many cycles must be reported.
Acomponent 1
→
Bcomponent 1
→
Ccomponent 1
↩ A
Dacyclic

Tarjan keeps A, B, C together. Johnson reports A → B → C → A. D is eliminated from cycle enumeration.

Chapter 07 / Cross the test boundary last

A rule returns data. An assertion turns that data into a failed test.

Separating evaluation from presentation makes the same findings usable by pytest, Jest, Vitest, CI logs, reports, and custom tools. It also keeps the core algorithms testable without a test runner.

Python Abridged source from the test assertion adapter

testing/assertion.py
def assert_passes(checkable, options=None):
    # Keep evaluation as data until the test boundary.
    violations = checkable.check(options)
    if violations:
        # Carry the architectural reason into the failure message.
        because = getattr(checkable, "because_reason", None)
        raise AssertionError(
            format_violations(violations, because=because)
        )

TypeScript The shared Jest and Vitest matcher boundary

jest-adapter.ts · equivalent path in Vitest
expect.extend({
  async toPassAsync(checkable, options) {
    if (!checkable) {
      return ResultFactory.error(
        'expected something checkable as an argument for expect()',
      );
    }

    // The rule still returns data at this point.
    const violations = await checkable.check(options);
    const testViolations = violations.map((violation) =>
      ViolationFactory.from(violation),
    );

    // Jest or Vitest receives the final pass flag and message function.
    return ResultFactory.result(Boolean(this.isNot), testViolations);
  },
});
01

Python checkable.check(options) and TypeScript checkable.check(options) return typed violation data rather than failing a test themselves.

02

With no findings, Python returns normally. TypeScript's matcher produces a result withpass: true.

03

With findings, Python formats concrete details and the optionalbecause_reason. TypeScript maps each finding through ViolationFactory.from() and keeps every accumulated raw edge for diagnostics.

04

Python raises AssertionError. TypeScript returns{ pass, message }; Jest or Vitest turnspass: false into the failed test.

Output at the test boundary

No findings means a normal return. Findings become one formattedAssertionError with concrete dependency evidence and the optional because_reason. TypeScript keeps the same separation and crosses the runner boundary throughtoPassAsync().

What static analysis can support
  • Supported import syntax and statically resolvable targets
  • Dependency direction, cycles, layers, slices, and reachability over those edges
  • Naming, placement, source counts, and supported static metrics
What it must not invent
  • Computed module names known only after runtime values exist
  • Reflection, monkey patching, or container wiring without source evidence
  • Whether a business boundary is a good architectural decision

The complete machine

Discover. Parse. Resolve. Build. Project. Judge. Explain.

The parser is language-native. The graph is explicit. The rule is readable. The finding keeps its evidence. That is how an architectural intention becomes an ordinary test the team can run on every change.