Getting Started with ArchTest

Copy Markdown View Source

ArchTest is an ArchUnit-inspired architecture testing library for Elixir. You write ordinary ExUnit tests that assert structural rules about your codebase — dependency direction, naming conventions, bounded-context isolation, cycle freedom — and get clear, actionable failures when those rules are broken.

Everything works from compiled BEAM bytecode via OTP's :xref. No source parsing, no reflection hacks. If it compiled, ArchTest can analyse it.


1. Add the dependency

# mix.exs
defp deps do
  [
    {:arch_test, "~> 0.2", only: :test, runtime: false}
  ]
end
mix deps.get

2. Create your architecture test file

If you use Igniter, you can scaffold a file instantly:

CommandWhat it generates
mix igniter.install arch_testBasic arch test file with a cycle check
mix arch_test.gen.phoenixOpinionated Phoenix setup — layers + naming + conventions (Phoenix directory structure · N-tier architecture)
mix arch_test.gen.layersClassic web → context → repo layers (N-tier architecture)
mix arch_test.gen.onionOnion / hexagonal rings (Onion Architecture · Hexagonal / Ports & Adapters)
mix arch_test.gen.modulithBounded-context slice isolation (Modular Monolith Primer)
mix arch_test.gen.namingNaming rules — no Managers, schema namespace placement
mix arch_test.gen.conventionsCode hygiene — no IO.puts, dbg, bare raise
mix arch_test.gen.freezeFreeze baseline for gradual adoption

Add Igniter as a dev dependency first: {:igniter, "~> 0.7", only: [:dev, :test], runtime: false}.

Or write the file by hand:

# test/architecture_test.exs
defmodule MyApp.ArchitectureTest do
  use ExUnit.Case
  use ArchTest

  test "services don't call repos directly" do
    modules_matching("MyApp.**.*Service")
    |> should_not_depend_on(modules_matching("MyApp.**.*Repo"))
  end

  test "no Manager modules exist" do
    modules_matching("MyApp.**.*Manager") |> should_not_exist()
  end

  test "no circular dependencies" do
    modules_matching("MyApp.**") |> should_be_free_of_cycles()
  end
end

Run with mix test. Each test block is a standalone ExUnit test — you get normal pass/fail output and clear violation messages on failure.


3. How it works

On the first architecture test in a suite, ArchTest builds a dependency graph using OTP's :xref by scanning all loaded BEAM files. The graph is cached in :persistent_term for the rest of the test run, so subsequent tests add no overhead.

Rules are evaluated against the graph and any violations surface as assertion failures with a full list of offending dependencies:

  1) test services don't call repos directly (MyApp.ArchitectureTest)
     Architecture rule violated (should_not_depend_on)  2 violation(s):

       MyApp.Accounts.RegistrationService  MyApp.Accounts.UserRepo
         MyApp.**.*Service must not depend on MyApp.**.*Repo

       MyApp.Orders.CheckoutService  MyApp.Orders.OrderRepo
         MyApp.**.*Service must not depend on MyApp.**.*Repo

4. Select modules

The DSL starts with a module set — a selection of modules from your app.

# All descendants of a namespace
modules_matching("MyApp.Orders.**")

# Only direct children
modules_matching("MyApp.Orders.*")

# Last segment matches a glob
modules_matching("**.*Service")        # ends with Service
modules_matching("**.*Service*")       # contains Service anywhere

# Shorthand for "MyApp.Orders.*"
modules_in("MyApp.Orders")

# Everything
all_modules()

# Custom predicate
modules_satisfying(fn mod ->
  function_exported?(mod, :__schema__, 1)
end)

Composing sets

# Exclude
modules_matching("MyApp.**")
|> excluding("MyApp.Web.*")

# Union
modules_matching("**.*Service")
|> union(modules_matching("**.*View"))

# Intersection
modules_matching("MyApp.**")
|> intersection(modules_matching("**.*Schema"))

5. Assert dependency rules

Pipe a module set into an assertion:

# Forbid a dependency
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"))

# Allowlist (anything outside the set is forbidden)
modules_matching("MyApp.Web.**")
|> should_only_depend_on(modules_matching("MyApp.Domain.**"))

# Reverse direction — restrict who may call something
modules_matching("MyApp.Repo")
|> should_only_be_called_by(modules_matching("MyApp.Domain.**"))

# Transitive closure
modules_matching("MyApp.Domain.**")
|> should_not_transitively_depend_on(modules_matching("Ecto.**"))

# No cycles
modules_matching("MyApp.**") |> should_be_free_of_cycles()

6. Assert naming conventions

# No modules with this name pattern should exist
modules_matching("MyApp.**.*Manager") |> should_not_exist()

# All modules must live under a namespace
modules_satisfying(fn m -> function_exported?(m, :__schema__, 1) end)
|> should_reside_under("MyApp.**.Schemas")

# All module names must match a glob
modules_matching("MyApp.Web.**")
|> should_have_name_matching("**.*Controller")

# Count constraint
modules_matching("MyApp.**.*God")
|> should_have_module_count(max: 0)

7. Scope to one app (umbrella projects)

use ArchTest, app: :my_app

This filters all module sets to only include modules belonging to :my_app.


Next steps

  • Layered Architecture — enforce layer direction or onion rules with define_layers/1 and define_onion/1
  • Modulith Rules — bounded-context isolation with define_slices/1
  • Freezing — adopt gradually by baselining existing violations
  • ArchTest.Conventions — check for IO.puts, dbg, bare raise, and missing docs
  • ArchTest.Metrics — measure coupling, instability, and distance from the main sequence