This guide covers the APIs that make ArchTest practical in larger codebases: app scoping, freeze baselines, import filters, function-level call metadata, reusable rules, captured slices, PlantUML conformance, and extra metrics.

App Scoping

Prefer scoping architecture tests to the OTP application under test:

defmodule MyApp.ArchTest do
  use ExUnit.Case
  use ArchTest, app: :my_app

  test "domain does not call web" do
    modules_matching("MyApp.Domain.**")
    |> should_not_depend_on(modules_matching("MyAppWeb.**"))
  end
end

The app: option is automatically forwarded to assertion, layer, onion, and modulith checks. You can override it per rule:

modules_matching("OtherApp.**")
|> should_be_free_of_cycles(app: :other_app)

Empty Matches

Rules fail when the subject matches zero modules. This catches typoed patterns. Allow an empty set only when it is intentional:

modules_matching("MyApp.Legacy.**")
|> should_not_depend_on(modules_matching("MyApp.NewCore.**"), allow_empty: true)

Auto Freeze

Use freeze: true to baseline every assertion in a test module:

defmodule MyApp.LegacyArchTest do
  use ExUnit.Case
  use ArchTest, app: :my_app, freeze: true

  test "legacy code must not get worse" do
    modules_matching("MyApp.**")
    |> should_not_depend_on(modules_matching("MyApp.Legacy.**"),
      rule_id: "legacy_dependency_rule")
  end
end

Run once with:

ARCH_TEST_UPDATE_FREEZE=true mix test

Freeze baselines use structured violation keys from %ArchTest.Violation{}. This is more stable than parsing rendered ExUnit output. Empty-pattern control failures are not freezable; fix the pattern or pass allow_empty: true when an empty set is intentional.

When freeze: true is enabled, ArchTest generates a rule id from the test module, assertion name, assertion arguments, and scope options such as :app, :apps, :paths, :include, and :exclude. Pass rule_id: for rules whose baseline filename should stay stable while the implementation changes.

Import Filters

Limit imported BEAM modules with :include, :exclude, :apps, or :paths:

graph =
  ArchTest.Collector.build_graph(:my_app,
    include: ["MyApp.**"],
    exclude: ["MyApp.TestSupport.**"],
    force: true)

Multi-app and path-based imports are supported:

ArchTest.Collector.build_graph(:all, apps: [:my_app, :my_app_web])
ArchTest.Collector.build_graph_from_paths(["_build/test/lib/my_app/ebin"])

Filters are applied to graph keys and dependency edges, so excluded modules do not leak back through dependencies_of/2 or transitive dependency traversal. Assertions, metrics, reusable rules, layer checks, onion checks, modulith checks, convention helpers, and PlantUML enforcement all accept :graph for tests and the collector scope options when they need to build a graph.

Call Metadata

For rules that need exact call sites, use function-level metadata:

calls = ArchTest.Collector.calls(:my_app, include: ["MyApp.**"])

Enum.each(calls, fn call ->
  IO.inspect({call.caller_module, call.caller_function, call.callee_module, call.line})
end)

%ArchTest.Call{} includes caller module/function, callee module/function, source file, and line when debug info is available. Include/exclude filters are applied to callers and callees, and compiler-generated metadata calls are omitted. Remote function captures, such as &MyApp.Accounts.create_user/1, and static apply(MyApp.Accounts, :create_user, args) calls are reported when the compiled debug information exposes them.

Reusable Rules

Use ArchTest.Rule when a rule needs to be shared, ignored, or frozen:

rule =
  ArchTest.Rule.new("web must not call repos", fn graph ->
    for {caller, deps} <- graph,
        caller |> Atom.to_string() |> String.starts_with?("Elixir.MyAppWeb."),
        dep <- deps,
        dep |> Atom.to_string() |> String.ends_with?("Repo") do
      ArchTest.Violation.forbidden_dep(caller, dep, "web layer must call contexts")
    end
  end)
  |> ArchTest.Rule.ignore(callee: "MyApp.LegacyRepo")
  |> ArchTest.Rule.freeze("web_repo_rule")

ArchTest.Rule.assert(rule, app: :my_app)

Captured Slices

Instead of listing slices manually, capture one namespace segment:

define_slices_by("MyApp.(*)", app: :my_app,
  except: ["MyApp.Application", "MyApp.Repo"])
|> allow_dependency(:orders, :accounts)
|> enforce_isolation()

"MyApp.(*)" turns modules such as MyApp.Orders, MyApp.Orders.Checkout, and MyApp.Inventory.Repo into orders: "MyApp.Orders" and inventory: "MyApp.Inventory".

Wildcards may appear before the capture. For example, "MyApp.*.(*)" can derive slices from modules like MyApp.Bounded.Orders.Checkout; the captured segment still becomes the slice name and root.

PlantUML Conformance

Keep diagrams honest by checking actual slice dependencies against PlantUML:

@startuml
[Orders] --> [Accounts]
[Orders] --> [Inventory]
@enduml
ArchTest.PlantUML.enforce("docs/components.puml",
  app: :my_app,
  slices: [
    orders: "MyApp.Orders",
    accounts: "MyApp.Accounts",
    inventory: "MyApp.Inventory"
  ])

Any actual slice dependency missing from the diagram fails the test. Line comments (' and //) and PlantUML block comments (/' ... '/) are ignored, so commented-out edges do not accidentally permit dependencies.

Extra Metrics

In addition to Martin metrics, ArchTest exposes simple graph metrics:

assert ArchTest.Metrics.afferent("MyApp.Orders", app: :my_app) >= 1
assert ArchTest.Metrics.efferent("MyApp.Orders", app: :my_app) < 10
assert ArchTest.Metrics.fan_out(MyApp.Orders, app: :my_app) < 5
assert ArchTest.Metrics.fan_in(MyApp.Accounts, app: :my_app) >= 1
assert ArchTest.Metrics.dependency_depth(MyApp.Orders.Checkout, app: :my_app) <= 3

coupling/2, instability/2, abstractness/2, afferent/2, and efferent/2 treat a binary namespace such as "MyApp.Orders" as one package: the root module plus descendants. Pass a module atom, such as MyApp.Orders, to measure only that exact module.