Direct answer

Make the repository’s architecture a testable contract, then run that contract on every coding-agent patch before a pull request can merge. A useful boundary test answers one narrow question: may code in module A depend on module B, directly or through a chain? The test should inspect the dependency graph or imported bytecode, report the exact edge that breaks the rule, and return a failing status when the edge is not allowed. The contract file and its fixtures belong in version control beside the code, so changing the boundary is an intentional design change rather than an accidental side effect of an agent run.

The implementation can be language-specific while the operating model stays the same. ArchUnit’s User Guide describes importing Java bytecode and checking package and class dependencies, layers, slices, and cycles through ordinary unit-test frameworks. For Python, Import Linter’s forbidden contracts express which source modules may not import a configured set of modules; descendants and indirect imports are checked by default. Its layers contracts encode an ordered high-to-low direction and reject dependencies that travel upward. For JavaScript and TypeScript, the dependency-cruiser rules tutorial shows JSON rules with from and to path conditions, names, and severities.

This is more than a style lint. A patch can pass unit tests and still make a controller import a database adapter, make one feature reach into another feature’s private implementation, or create a cycle that will be expensive to remove later. A boundary test turns those structural risks into a deterministic admission check. Keep the rule narrow, make violations actionable, and require the check in the protected branch policy.

Last reviewed: 2026-08-31

Who this is for

This guide is for teams that let a coding agent edit a modular repository and want a reviewer to see proof that the patch stayed inside the intended architecture. It fits layered services, hexagonal or ports-and-adapters code, vertical-slice applications, and monorepos with peer packages. It is especially useful when several agents work in parallel or when a patch is generated faster than a human can reconstruct every import path.

It is not a replacement for functional tests, API compatibility checks, security scanning, or a design review. Boundary tests answer a narrower question: did the change introduce a dependency that the repository has declared illegal? Keeping that question separate makes a failure easier for both a human and an agent to diagnose.

Key takeaways

  • Name the layers or modules and write the allowed direction in plain language before choosing a tool. “Web may call service; service may call domain; domain may not call web” is more useful than a vague “clean architecture” label.
  • Keep one authoritative contract per boundary. If a rule is copied into a prompt, a linter configuration, and a wiki page, those copies will drift. The checked-in contract is the source of truth; agent instructions should point to it.
  • Include transitive edges. Import Linter documents that indirect imports count for forbidden and layers contracts, and ArchUnit offers dependency and cycle checks. A direct-edge-only scan can miss the path that actually makes a lower layer depend on a higher one.
  • Make the checker fail loudly for new violations. dependency-cruiser’s tutorial distinguishes warn from error; use a warning for a measured migration or an external dependency you cannot fix yet, and use an error for a new repository-owned edge.
  • Test the test. Keep one allowed fixture and one deliberately forbidden fixture. A green check with no imported classes, no resolved files, or an accidentally excluded source set is not evidence that the boundary is enforced.
  • Separate policy changes from implementation changes. An exception should be a small, named, reviewable diff with an owner and a removal condition, not a broad ignore pattern added to make an agent run pass.
  • Publish a small, sanitized report. Reviewers need the rule count, violation count, scan scope, tool version, result, and commit—not prompts, environment values, or file contents.
  • Wire the result into branch protection. GitHub’s protected-branch documentation says required status checks must be successful, skipped, or neutral before a protected branch can receive changes. Give the check a stable, unique name and verify that the workflow actually runs for agent pull requests.

Sources checked

The following public references were refetched for this article and support the tool behavior described here:

  • ArchUnit User Guide — explains bytecode import, package/class, layer, slice, and cycle checks, plus integration with ordinary Java test frameworks.
  • Import Linter forbidden contracts — documents source and forbidden module sets, descendant and indirect-import behavior, external-package options, and narrowly scoped ignores.
  • Import Linter layers contracts — documents ordered layers, upward-dependency failures, optional layers, containers, exhaustive checks, and sibling-module syntax.
  • dependency-cruiser rules tutorial — demonstrates forbidden rules, path conditions, group matching, and the difference between warning and error severities.
  • GitHub protected branches — describes required checks and warns that duplicate job names can make check results ambiguous.
  • ArchUnit 1.5.0 release notes — records Java 27 and JUnit 6 support in that release, so teams should verify their runtime and test integration rather than assume compatibility.
  • dependency-cruiser v18.2.0 release notes — records TypeScript configuration support in that release, another reason to pin and verify the version used by CI.

Contract details to verify

Start with a boundary inventory. List the packages or directories that are architectural units, the public entry points each unit exposes, and the direction of permitted calls. Decide whether tests, generated files, examples, and third-party code are inside the scan. Write down the answer; an unexplained exclusion is an escape hatch an agent can discover accidentally.

Then choose semantics that match the repository. An ArchUnit rule can import the project’s classes and express a layered architecture in a JUnit-style test. A minimal Java shape looks like this:

@AnalyzeClasses(packages = "com.example")
public class ArchitectureTest {
    @ArchTest
    static final ArchRule layers = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Web").definedBy("..web..")
        .layer("Service").definedBy("..service..")
        .layer("Domain").definedBy("..domain..")
        .whereLayer("Web").mayNotBeAccessedByAnyLayer()
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Web")
        .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service");
}

For Python, make the order explicit in a checked-in contract. The following is a small illustrative TOML shape based on the layers contract model:

[tool.importlinter]
root_package = "example_app"

[[tool.importlinter.contracts]]
name = "Downward dependencies only"
type = "layers"
layers = ["example_app.web", "example_app.service", "example_app.domain"]

For a TypeScript or JavaScript graph, start with a named rule and an error severity so the report tells the agent what to fix:

{
  "forbidden": [
    {
      "name": "web-not-to-storage",
      "comment": "Keep storage behind the service boundary",
      "severity": "error",
      "from": {"path": "^src/web/"},
      "to": {"path": "^src/storage/"}
    }
  ]
}

Verify the edges the tool considers. Import Linter treats modules as packages by default, checks descendants, and can include indirect imports; its as_packages and allow_indirect_imports options change that behavior. Its layers contract fails when a required layer is absent, while parentheses can make a layer optional. ArchUnit’s import options determine which classes enter the graph. dependency-cruiser’s from and to conditions operate on resolved paths, so aliases, generated output, and unresolved imports deserve an explicit test. These details are where a “passing” boundary check most often becomes a scan of the wrong graph.

Operator workflow: happy path

  1. Before the agent starts, record the target branch, contract revision, tool version, and the directories in scope. Put the contract in a protected review path and provide the agent a short instruction: change production code to satisfy the existing rule; propose contract changes separately.
  2. Run the same wrapper locally and in CI, for example ./ci/check-architecture. The wrapper should build or import the graph, execute every boundary rule, and write a machine-readable report. Do not let the agent substitute a different command silently.
  3. On a clean patch, expect exit code 0 and a report with zero violations. Compare the changed-file list with the graph scope so a successful result is meaningful. Attach the report to the pull request or its check summary.
  4. Require the named check on the protected branch. A reviewer then sees both the code diff and the structural result before approving. If the patch changes a boundary contract, route that separate change through the architecture owner.

A sanitized passing event can contain fields such as these:

{
  "run_id": "run-20260831-0142",
  "commit_sha": "abc1234",
  "check_id": "architecture-boundaries",
  "rules_evaluated": 3,
  "modules_scanned": 86,
  "violations": 0,
  "changed_files": 4,
  "tool_version": "pinned",
  "exit_code": 0,
  "duration_ms": 1840,
  "result": "pass",
  "redaction_count": 0
}

Keep paths normalized and omit source snippets, prompts, environment values, request payloads, and any credential material. A report should help a reviewer reproduce the check without becoming a second data-leak surface.

Operator workflow: error path

  1. The wrapper returns a nonzero exit code and records each violation as from_module, to_module, rule_id, and a short reason. First confirm that both endpoints resolved and that the rule loaded; do not assume the agent’s proposed fix is correct.
  2. Classify the edge: direct import, transitive path, cycle, missing layer, unresolved alias, generated file, or an intentional legacy exception. Ask the agent for the smallest production-code change that removes the edge while leaving the contract unchanged.
  3. If the edge is intentional, open a separate policy change with a narrow exception, rationale, owner, and review date. Never turn an error into a repository-wide warning just to unblock one patch.
  4. Re-run the wrapper from a clean checkout, compare the new report with the previous report, and verify that the agent did not delete a rule, change the scan root, or exclude the failing file. Only then should the pull request check be re-evaluated.

Failure modes

The rule scans too little. A changed package may sit outside the configured root, tests may be excluded, or a generated directory may hide an import. Add positive and negative fixtures and print the resolved scope in the check summary. For Java, verify the ArchUnit import options; for Python, verify root packages and external-package settings; for TypeScript, verify path resolution.

Indirect dependencies are mistaken for harmless dependencies. A lower layer may import a helper that imports a higher layer. Import Linter explicitly checks indirect imports by default, and ArchUnit can consider all dependencies. Preserve that behavior unless the architecture owner documents why a contract intentionally permits the chain.

A warning is treated as a pass. dependency-cruiser’s tutorial shows that warnings remain visible while errors stop the build. Use warning severity only for a deliberately managed migration, and make the check summary say whether warnings are merge-blocking in your policy.

The agent edits the contract to make the patch green. This is a governance failure, not a successful repair. Protect the contract path, require a separate review for policy changes, and compare the contract diff with the implementation diff.

A blanket ignore hides real drift. An exception for an entire directory can swallow future violations. Prefer one fully qualified edge or a small named module set, and record why it exists. Revisit it on a schedule.

A required check is ambiguous or never runs. GitHub notes that duplicate job names across workflows can make required results ambiguous. Give the architecture job one stable name, test it on pull-request events, and confirm the branch rule requires that exact check. Also inspect conditional workflow logic: a skipped check may satisfy a policy even when the architecture was not evaluated.

A declared layer disappears. Import Linter documents that a listed layer normally must exist; optional parentheses change that expectation. Treat an unexpected missing layer as a contract error, not as permission to continue.

The tool version changes under the agent. A new resolver or language runtime can alter the graph. Pin the version, record it in the sanitized report, and review upgrades separately. The ArchUnit 1.5.0 and dependency-cruiser v18.2.0 release notes show why compatibility should be verified against the repository’s runtime.

FAQ

Is an architecture boundary test just another unit test? It can run through a unit-test framework—ArchUnit is designed for that—but it asserts relationships among many classes or modules rather than one function’s output. Python and JavaScript tools perform the same contract role through import-graph analysis.

Should the check inspect only files changed by the agent? Use the changed set to make feedback fast, but validate the resulting graph against the full contract before merge. A new edge can travel through an unchanged helper, so a file-only check is not sufficient evidence.

What is the safest way to allow an exception? Make it explicit and narrow: identify the source and target, explain the design reason, assign an owner, and set a review or removal condition. Keep the exception in the contract diff so reviewers can see the policy change.

What does a passing protected-branch check mean? GitHub’s documentation allows a required check to be successful, skipped, or neutral. If your architecture must always be evaluated, configure the workflow so the job is present for every relevant pull request and do not rely on an accidental skip.

Can a coding agent write new boundary rules? It can propose them, but a human who owns the architecture should approve the policy. Ask the agent to add a failing fixture and explain the intended dependency direction; review the rule as a design artifact before enabling it as a required check.

How much should the report log? Enough to reproduce the relationship—commit, rule, normalized source and target modules, dependency kind, severity, tool version, result, and timing. Do not log prompts, file contents, environment values, or credentials.

Reader next step

Choose one boundary that matters today, such as “web may depend on service, but service may not import web.” Add one allowed fixture and one forbidden fixture, place the rule in version control, and expose it through a single CI wrapper. Run that wrapper against a real agent patch and inspect the sanitized report on both the pass and fail paths. Then make the uniquely named check required on the protected branch.

For the surrounding review loop, use the reviewable-diff workflow and the CI failure triage guide . The goal is a small, repeatable proof: the agent can change implementation details, but the repository—not the agent—decides which module relationships are allowed.