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 loaded moduleCustom 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(max: 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.**",
repo: "MyApp.Repo.**"
)
|> 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([...]) |> should_be_free_of_cycles()Code Conventions
use ArchTest.Conventions
no_io_puts_in(modules_matching("MyApp.**"))
no_process_sleep_in(modules_matching("MyApp.**"))
no_application_get_env_in(modules_matching("MyApp.Domain.**"))
no_dbg_in(modules_matching("MyApp.**"))
no_raise_string_in(modules_matching("MyApp.**"))
no_plug_in(modules_matching("MyApp.Domain.**"))
all_public_functions_documented(modules_matching("MyApp.**.*Repo"))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
endUmbrella — scope to one app
use ArchTest, app: :my_appConfigure 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 | 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 |