Module Selection
Glob patterns
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_matching("MyApp.**.*Repo") # under MyApp, ends with Repo
modules_in("MyApp.Orders") # shorthand for "MyApp.Orders.*"
all_modules() # every module in scoped appCustom predicate
modules_satisfying(fn mod ->
function_exported?(mod, :__schema__, 1)
end)Composing sets
modules_matching("MyApp.**")
|> excluding("MyApp.Web.*")
modules_matching("**.*Service")
|> union(modules_matching("**.*View"))
modules_matching("MyApp.**")
|> intersection(modules_matching("**.*Schema"))Pattern Reference
| Pattern | Matches |
|---|---|
"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 |
Dependency Rules
Forbid
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"))Allowlist
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.**"))
modules_matching("MyApp.Repo")
|> should_not_be_called_by(modules_matching("MyApp.Web.**"))Transitive
modules_matching("MyApp.Domain.**")
|> should_not_transitively_depend_on(modules_matching("Ecto.**"))Cycles
modules_matching("MyApp.**") |> should_be_free_of_cycles()Naming Rules
# Ban a naming convention
modules_matching("MyApp.**.*Manager") |> should_not_exist()
# Enforce namespace
modules_satisfying(fn m -> function_exported?(m, :__schema__, 1) end)
|> should_reside_under("MyApp.**.Schemas")
# Enforce name pattern
modules_matching("MyApp.Web.**")
|> should_have_name_matching("**.*Controller")
# Count
modules_matching("MyApp.**.*God")
|> should_have_module_count(at_most: 0)Behaviour / Protocol / Attribute Rules
modules_matching("MyApp.Workers.**")
|> should_implement_behaviour(Oban.Worker)
modules_matching("MyApp.Domain.**")
|> should_not_implement_behaviour(Plug)
modules_matching("MyApp.**.*Schema")
|> should_implement_protocol(Jason.Encoder)
modules_matching("MyApp.**")
|> should_have_attribute(:moduledoc)
modules_matching("MyApp.**")
|> should_not_have_attribute_value(:deprecated, true)Function Rules
modules_matching("MyApp.Domain.**")
|> should_export(:call, 2)
modules_matching("MyApp.Web.**")
|> should_not_export(:__impl__, 1)
modules_matching("MyApp.**")
|> should_use(Phoenix.Controller)
modules_matching("MyApp.Domain.**")
|> should_not_use(Plug)Layered Architecture
Classic layers (top → bottom)
define_layers(
web: "MyApp.Web.**",
context: "MyApp.Context.**",
repo: "MyApp.Repo.**"
)
|> enforce_direction()
define_layers(web: "MyApp.Web.**", context: "MyApp.Context.**", repo: "MyApp.Repo.**")
|> allow_layer_dependency(:context, :web)
|> enforce_direction()Onion / hexagonal (innermost first)
define_onion(
domain: "MyApp.Domain.**",
application: "MyApp.Application.**",
adapters: "MyApp.Adapters.**",
web: "MyApp.Web.**"
)
|> enforce_onion_rules()Modulith / Bounded Contexts
Enforce isolation
define_slices(
orders: "MyApp.Orders",
inventory: "MyApp.Inventory",
accounts: "MyApp.Accounts"
)
|> allow_dependency(:orders, :accounts)
|> allow_dependency(:orders, :inventory)
|> enforce_isolation()Strict (zero cross-context deps)
define_slices(
core: "MyApp.Core",
plugins: "MyApp.Plugins"
)
|> should_not_depend_on_each_other()Cycle detection
define_slices(orders: "MyApp.Orders", accounts: "MyApp.Accounts")
|> should_be_free_of_cycles()Discover slices
define_slices_by("MyApp.(*)", app: :my_app, except: ["MyApp.Application"])
|> enforce_isolation()Code Conventions
use ArchTest, app: :my_app
use ArchTest.Conventions
no_io_puts_in(modules_matching("MyApp.**"), app: :my_app)
no_process_sleep_in(modules_matching("MyApp.**"), app: :my_app)
no_application_get_env_in(modules_matching("MyApp.Domain.**"), app: :my_app)
no_dbg_in(modules_matching("MyApp.**"), app: :my_app)
no_raise_string_in(modules_matching("MyApp.**"), app: :my_app)
no_plug_in(modules_matching("MyApp.Domain.**"), app: :my_app)
all_public_functions_documented(modules_matching("MyApp.**.*Repo"), app: :my_app)Coupling Metrics
alias ArchTest.Metrics
# Martin metrics for a namespace
# %{Module => %{instability: 0.6, abstractness: 0.0, distance: 0.4}}
metrics = Metrics.martin("MyApp.**")
Enum.each(metrics, fn {mod, m} ->
assert m.distance < 0.5, "#{mod} too far from main sequence"
end)
# Single module
Metrics.instability("MyApp.Orders") # 0.0..1.0
Metrics.abstractness("MyApp.Orders") # 0.0..1.0
# Afferent / efferent coupling counts
Metrics.afferent("MyApp.Orders") # who depends on Orders
Metrics.efferent("MyApp.Orders") # who Orders depends onGradual Adoption (Freeze)
# Wrap any rule with freeze/2 to baseline current violations
ArchTest.Freeze.freeze("domain_web_deps", fn ->
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"))
end)# Establish or update the baseline
ARCH_TEST_UPDATE_FREEZE=true mix test
Baseline files live in test/arch_test_violations/. Commit them. Delete the file when all violations are fixed.
Setup
Single app
defmodule MyApp.ArchTest do
use ExUnit.Case
use ArchTest, app: :my_app
endUmbrella — scope to one app
use ArchTest, app: :my_appAuto-freeze all rules in a test module
use ArchTest, app: :my_app, freeze: trueUse rule_id: for stable baseline filenames.
Intentional empty matches
modules_matching("MyApp.Legacy.**")
|> should_not_exist(allow_empty: true)Configure freeze directory
# config/test.exs
config :arch_test, freeze_store: "test/arch_violations"Igniter Generators
Add {:igniter, "~> 0.7", only: [:dev, :test], runtime: false}, then:
| Command | Guide | Further reading |
|---|---|---|
mix igniter.install arch_test / mix arch_test.install | Basic cycle-check file | |
mix arch_test.gen.phoenix | Layers + naming + conventions combined | Phoenix directory structure · N-tier architecture |
mix arch_test.gen.layers | Layered Architecture | N-tier architecture |
mix arch_test.gen.onion | Layered Architecture — Onion | Onion Architecture · Hexagonal / Ports & Adapters |
mix arch_test.gen.modulith | Modulith / Bounded Contexts | Modular Monolith Primer |
mix arch_test.gen.naming | Naming Rules section above | |
mix arch_test.gen.conventions | Code Conventions section above | |
mix arch_test.gen.freeze | Gradual Adoption — Freeze |
Generated tests are scoped with use ArchTest, app: :your_app; edit the
generated namespace patterns before treating them as policy.