CI Hex.pm Docs

Architecture rules as tests. Enforced from bytecode.

AssertionWhat it checks
should_not_depend_onNo direct dependency on a module set
should_only_depend_onAll dependencies must be in an allowlist
should_not_be_called_byRestrict who may call a module set
should_only_be_called_byOnly these callers are allowed
should_not_transitively_depend_onNo transitive path to a module set
should_be_free_of_cyclesNo circular dependencies
should_not_existNo modules matching a pattern should exist
should_reside_underModules must live under a namespace
should_have_name_matchingModule names must match a glob
should_have_module_countEnforce min/max module counts
define_layers + enforce_directionClassic layered architecture
define_onion + enforce_onion_rulesOnion / hexagonal architecture
define_slices + enforce_isolationModulith bounded-context isolation
ArchTest.ConventionsBan IO.puts, dbg, bare raise, and more
ArchTest.MetricsCoupling, instability, distance from main sequence
ArchTest.FreezeBaseline violations for gradual adoption

The missing piece

Elixir has excellent tools for code quality. But there's a gap:

ToolWhat it enforces
CredoStyle, readability, code smells within a file
BoundaryCross-context calls at compile time (compiler warnings)
DialyzerType correctness
ArchTestStructural rules across your whole codebase — in tests

Credo tells you a function is too long. It doesn't tell you that your domain layer is calling your web layer.

Boundary gives you compile-time warnings when a module crosses a declared boundary. It's powerful, but it requires annotating every module with use Boundary, it runs at compile time (so violations block your build), and it's scoped to the boundaries you explicitly declare. You can't easily ask "do any Services depend on Repos?" or "does anything in Domain transitively reach Phoenix?" without writing boundary declarations for all of it.

ArchTest is a test library. Rules live in ExUnit tests. You write them in plain Elixir, run them with mix test, and get structured failure output listing every violation. You can express rules Boundary can't — transitive dependencies, glob-based module selection, coupling metrics, naming conventions, cycle detection across arbitrary module sets — without touching production code at all.

For most teams, ArchTest alone is enough. You get bounded-context isolation, dependency direction, naming policies, convention checks, and metrics — all in ExUnit, with no changes to production code. If you later want compile-time enforcement on top of test-time enforcement, the two compose naturally: Boundary for hard build-time API guards, ArchTest for everything else.


Installation

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

Igniter tasks

If you use Igniter, ArchTest provides generators for common setup patterns:

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 to use these:

{:igniter, "~> 0.7", only: [:dev, :test], runtime: false}

Quick start

defmodule MyApp.ArchTest 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 "repo is only called by the domain layer" do
    modules_matching("MyApp.Repo")
    |> should_only_be_called_by(modules_matching("MyApp.Domain.**"))
  end

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

Violations produce clear, actionable output:

  1) test services don't call repos directly (MyApp.ArchTest)
     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

Module selection

modules_matching("MyApp.Orders.*")       # direct children only
modules_matching("MyApp.Orders.**")      # all descendants
modules_matching("**.*Service")          # last segment ends with "Service"
modules_matching("**.*Service*")         # last segment contains "Service"

modules_in("MyApp.Orders")              # shorthand for "MyApp.Orders.*"
all_modules()                           # everything in the app

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

# Composition
modules_matching("MyApp.**") |> excluding("MyApp.Web.*")
modules_matching("**.*Service") |> union(modules_matching("**.*View"))
modules_matching("MyApp.**") |> intersection(modules_matching("**.*Schema"))

Dependency assertions

# 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.**"))

# Caller restriction
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.**"))

# Cycles
modules_matching("MyApp.**") |> should_be_free_of_cycles()

Layered architecture

# Classic layers (top to bottom — each layer may only depend on layers below)
define_layers(
  web:     "MyApp.Web.**",
  context: "MyApp.**",
  repo:    "MyApp.Repo.**"
)
|> enforce_direction()

# Onion / hexagonal (innermost first — dependencies point inward only)
define_onion(
  domain:      "MyApp.Domain.**",
  application: "MyApp.Application.**",
  adapters:    "MyApp.Adapters.**",
  web:         "MyApp.Web.**"
)
|> enforce_onion_rules()

Modulith / bounded-context isolation

define_slices(
  orders:    "MyApp.Orders",
  inventory: "MyApp.Inventory",
  accounts:  "MyApp.Accounts"
)
|> allow_dependency(:orders, :accounts)
|> enforce_isolation()

# Strict: zero cross-context dependencies
define_slices(
  core:    "MyApp.Core",
  plugins: "MyApp.Plugins"
)
|> should_not_depend_on_each_other()

Naming conventions

modules_matching("MyApp.**.*Manager") |> should_not_exist()

modules_satisfying(fn m -> function_exported?(m, :__schema__, 1) end)
|> should_reside_under("MyApp.**.Schemas")

modules_matching("MyApp.Web.**")
|> should_have_name_matching("**.*Controller")

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

Code conventions

defmodule MyApp.ConventionsTest do
  use ExUnit.Case
  use ArchTest
  use ArchTest.Conventions

  test "no IO.puts in production code" do
    no_io_puts_in(modules_matching("MyApp.**"))
  end

  test "no dbg calls left in" do
    no_dbg_in(modules_matching("MyApp.**"))
  end

  test "no Application.get_env in the domain" do
    no_application_get_env_in(modules_matching("MyApp.Domain.**"))
  end

  test "no bare raise strings" do
    no_raise_string_in(modules_matching("MyApp.**"))
  end

  test "domain doesn't import the web framework" do
    no_plug_in(modules_matching("MyApp.Domain.**"))
  end

  test "all public functions are documented" do
    all_public_functions_documented(modules_matching("MyApp.**"))
  end
end

Coupling metrics

alias ArchTest.Metrics

test "Orders context is reasonably stable" do
  assert Metrics.instability("MyApp.Orders") < 0.5
end

test "domain is close to the main sequence" do
  metrics = Metrics.martin("MyApp.Domain.**")
  # %{MyApp.Domain.Order => %{instability: 0.2, abstractness: 0.5, distance: 0.3}, ...}

  Enum.each(metrics, fn {mod, m} ->
    assert m.distance < 0.5, "#{mod} is too far from the main sequence (D=#{m.distance})"
  end)
end

Violation freeze (gradual adoption)

When introducing ArchTest to an existing codebase, freeze current violations and only fail on new ones:

test "legacy dependencies being cleaned up" do
  ArchTest.Freeze.freeze("legacy_deps", fn ->
    modules_matching("MyApp.**")
    |> should_not_depend_on(modules_matching("MyApp.Legacy.**"))
  end)
end
# Establish the baseline
ARCH_TEST_UPDATE_FREEZE=true mix test

Baselines are stored in test/arch_test_violations/. Commit them to version control. Re-run with the flag after fixing violations to shrink the baseline. Delete the file when the rule is clean.


Umbrella projects

use ArchTest, app: :my_app

Pattern reference

PatternMatches
"MyApp.Orders"Exact match only
"MyApp.Orders.*"Direct children (MyApp.Orders.Order)
"MyApp.Orders.**"All descendants at any depth
"**.*Service"Any module whose last segment ends with Service
"**.*Service*"Any module whose last segment contains Service
"MyApp.**.*Repo"Under MyApp, last segment ends with Repo
"**"All modules

License

MIT — see LICENSE.