ArchTest 0.3 keeps the 0.2 DSL shape, but tightens correctness defaults. Most projects can upgrade by changing the dependency, adding app scoping, and fixing rules that were passing because their subject matched no modules.
1. Upgrade the dependency
# mix.exs
defp deps do
[
{:arch_test, "~> 0.3", only: :test, runtime: false}
]
endThen run:
mix deps.update arch_test
mix test
2. Scope each architecture test module
0.2 users often wrote:
use ArchTestPrefer this in 0.3:
use ArchTest, app: :my_appThis scopes assertion, layer, onion, and modulith checks to the OTP app under test. In umbrella projects, use the app for the code being checked:
defmodule MyAppWeb.ArchTest do
use ExUnit.Case
use ArchTest, app: :my_app_web
endArchTest.Conventions helpers are plain functions, so pass scope options to
them explicitly:
no_io_puts_in(modules_matching("MyApp.**"), app: :my_app)
no_dbg_in(modules_matching("MyApp.**"), app: :my_app)3. Fix empty-subject failures
0.2 allowed many rules to pass when the subject matched zero modules. 0.3 fails those rules by default because an empty subject usually means a typo:
modules_matching("MyApp.Legacy.**")
|> should_not_depend_on(modules_matching("MyApp.NewCore.**"))If MyApp.Legacy.** no longer exists, decide which case applies:
# Typo or stale rule: fix/delete the pattern
modules_matching("MyApp.LegacyCode.**")
# Empty is intentional: say so
modules_matching("MyApp.Legacy.**")
|> should_not_depend_on(modules_matching("MyApp.NewCore.**"), allow_empty: true)This applies to dependency, caller, naming, behaviour/protocol, attribute,
function, custom, and cycle assertions. should_not_exist/2 is still naturally
allowed to pass when no forbidden modules exist. For count policies, prefer an
explicit count rule:
modules_matching("MyApp.Legacy.**")
|> should_have_module_count(exactly: 0)4. Refresh freeze baselines
0.3 freeze files use structured violation keys instead of rendered assertion text. Existing 0.2 baseline files should be regenerated once after review:
ARCH_TEST_UPDATE_FREEZE=true mix test
git diff test/arch_test_violations
Keep explicit rule_id: values for long-lived baselines:
use ArchTest, app: :my_app, freeze: true
test "legacy deps do not get worse" do
modules_matching("MyApp.**")
|> should_not_depend_on(modules_matching("MyApp.Legacy.**"),
rule_id: "legacy_deps")
endEmpty-subject failures are not freezable. Fix the pattern or pass
allow_empty: true.
5. Check layer patterns
0.3 generators use safer non-overlapping defaults. If you copied old generated layer rules, avoid broad middle layers that also match web or repo modules:
# Avoid: context overlaps web/repo
define_layers(
web: "MyApp.Web.**",
context: "MyApp.**",
repo: "MyApp.Repo.**"
)
# Prefer: each layer owns a distinct namespace
define_layers(
web: "MyApp.Web.**",
context: "MyApp.Context.**",
repo: "MyApp.Repo.**"
)If you intentionally allow one upward dependency, document it:
define_layers(
web: "MyApp.Web.**",
context: "MyApp.Context.**"
)
|> allow_layer_dependency(:context, :web)
|> enforce_direction()6. Code examples: common rewrites
Test module scoping
Before:
defmodule MyApp.ArchitectureTest do
use ExUnit.Case, async: true
use ArchTest
test "contexts do not call web" do
modules_matching("MyApp.**")
|> should_not_depend_on(modules_matching("MyAppWeb.**"))
end
endAfter:
defmodule MyApp.ArchitectureTest do
use ExUnit.Case, async: true
use ArchTest, app: :my_app
test "contexts do not call web" do
modules_matching("MyApp.**")
|> should_not_depend_on(modules_matching("MyAppWeb.**"))
end
endConvention helpers
Before:
defmodule MyApp.ConventionsTest do
use ExUnit.Case, async: true
import ArchTest
import ArchTest.Conventions
test "debug helpers are not committed" do
no_io_puts_in(modules_matching("MyApp.**"))
no_dbg_in(modules_matching("MyApp.**"))
end
endAfter:
defmodule MyApp.ConventionsTest do
use ExUnit.Case, async: true
import ArchTest
import ArchTest.Conventions
test "debug helpers are not committed" do
no_io_puts_in(modules_matching("MyApp.**"), app: :my_app)
no_dbg_in(modules_matching("MyApp.**"), app: :my_app)
end
endOptional or deleted namespaces
Before:
modules_matching("MyApp.Legacy.**")
|> should_not_depend_on(modules_matching("MyApp.Core.**"))After, if the namespace is optional:
modules_matching("MyApp.Legacy.**")
|> should_not_depend_on(modules_matching("MyApp.Core.**"), allow_empty: true)After, if the namespace should stay deleted:
modules_matching("MyApp.Legacy.**")
|> should_have_module_count(exactly: 0)Freeze baselines
Before:
test "legacy dependencies do not get worse" do
ArchTest.Freeze.freeze("legacy_deps", fn ->
modules_matching("MyApp.**")
|> should_not_depend_on(modules_matching("MyApp.Legacy.**"))
end)
endAfter:
defmodule MyApp.LegacyArchTest do
use ExUnit.Case, async: true
use ArchTest, app: :my_app, freeze: true
test "legacy dependencies do not get worse" do
modules_matching("MyApp.**")
|> should_not_depend_on(modules_matching("MyApp.Legacy.**"),
rule_id: "legacy_deps")
end
endLayer rules
Before:
define_layers(
web: "MyAppWeb.**",
context: "MyApp.**",
repo: "MyApp.Repo.**"
)
|> enforce_direction()After:
define_layers(
web: "MyAppWeb.**",
context: "MyApp.Context.**",
repo: "MyApp.Repo.**"
)
|> enforce_direction()If you need a known exception, make it explicit:
define_layers(
web: "MyAppWeb.**",
context: "MyApp.Context.**",
repo: "MyApp.Repo.**"
)
|> allow_layer_dependency(:context, :web)
|> enforce_direction()Modulith slices
Before:
define_slices(
orders: "MyApp.Orders",
accounts: "MyApp.Accounts",
inventory: "MyApp.Inventory"
)
|> enforce_isolation()After, when slices follow namespace roots:
define_slices_by("MyApp.(*)", app: :my_app,
except: ["MyApp.Application", "MyApp.Repo"])
|> allow_dependency(:orders, :accounts)
|> enforce_isolation()Add a slice-cycle check when slices should form an acyclic dependency graph:
define_slices_by("MyApp.(*)", app: :my_app,
except: ["MyApp.Application", "MyApp.Repo"])
|> should_be_free_of_cycles()Reusable custom rules
Before:
violations =
for {caller, deps} <- ArchTest.Collector.build_graph(:my_app),
caller |> Atom.to_string() |> String.starts_with?("Elixir.MyAppWeb."),
dep <- deps,
dep |> Atom.to_string() |> String.ends_with?("Repo") do
ArchTest.Violation.forbidden_dep(caller, dep, "web must call contexts")
end
ArchTest.assert_no_violations_public(violations, "web must not call repos")After:
ArchTest.Rule.new("web must not call repos", fn graph ->
for {caller, deps} <- graph,
caller |> Atom.to_string() |> String.starts_with?("Elixir.MyAppWeb."),
dep <- deps,
dep |> Atom.to_string() |> String.ends_with?("Repo") do
ArchTest.Violation.forbidden_dep(caller, dep, "web must call contexts")
end
end)
|> ArchTest.Rule.ignore(callee: "MyApp.LegacyRepo")
|> ArchTest.Rule.freeze("web_repo_rule")
|> ArchTest.Rule.assert(app: :my_app)7. Optional 0.3 improvements
After tests pass, consider adopting the new features:
define_slices_by/2to discover modulith slices from namespaces.should_be_free_of_cycles/2on modulith slice definitions.ArchTest.Collector.calls/2for function-level call-site rules.ArchTest.Rulefor reusable, ignorable, freezable custom rules.ArchTest.PlantUML.enforce/2to keep component diagrams honest.ArchTest.Metrics.afferent/2,efferent/2,fan_in/2,fan_out/2, anddependency_depth/2.
See Advanced Rules for examples.
Recommended upgrade checklist
- Bump dependency to
~> 0.3. - Add
use ArchTest, app: :your_appto architecture test modules. - Pass
app:toArchTest.Conventionshelpers. - Run
mix test. - Fix typoed empty patterns or add
allow_empty: truewhere intentional. - Regenerate freeze baselines with
ARCH_TEST_UPDATE_FREEZE=true mix test. - Review generated baseline diffs.
- Run
mix format --check-formatted,mix compile --warnings-as-errors, andmix testin CI.