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}
]
endmix deps.get
2. Create your architecture test file
If you use Igniter, you can scaffold a file instantly:
| Command | What it generates |
|---|---|
mix igniter.install arch_test | Basic arch test file with a cycle check |
mix arch_test.gen.phoenix | Opinionated Phoenix setup — layers + naming + conventions (Phoenix directory structure · N-tier architecture) |
mix arch_test.gen.layers | Classic web → context → repo layers (N-tier architecture) |
mix arch_test.gen.onion | Onion / hexagonal rings (Onion Architecture · Hexagonal / Ports & Adapters) |
mix arch_test.gen.modulith | Bounded-context slice isolation (Modular Monolith Primer) |
mix arch_test.gen.naming | Naming rules — no Managers, schema namespace placement |
mix arch_test.gen.conventions | Code hygiene — no IO.puts, dbg, bare raise |
mix arch_test.gen.freeze | Freeze 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
endRun 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.**.*Repo4. 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_appThis 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/1anddefine_onion/1 - Modulith Rules — bounded-context isolation with
define_slices/1 - Freezing — adopt gradually by baselining existing violations
ArchTest.Conventions— check forIO.puts,dbg, bareraise, and missing docsArchTest.Metrics— measure coupling, instability, and distance from the main sequence