Adding architecture rules to an existing codebase almost always reveals violations. You can't fix them all on day one — but you also can't leave the rules permanently disabled. The freeze mechanism solves this: you baseline the current violations and fail only on new ones. The codebase can only improve, never regress.
1. The problem
You add a rule:
test "domain doesn't depend on web" do
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"))
endIt fails with 47 violations. You can't fix 47 violations today. You could comment the test out — but then new violations will go undetected.
2. The solution: freeze
Wrap the rule in ArchTest.Freeze.freeze/2:
test "domain doesn't depend on web" do
ArchTest.Freeze.freeze("domain_web_deps", fn ->
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"))
end)
endOn the first run with no baseline, all violations are reported as new and the test fails. That's expected — the next step is to establish the baseline.
3. Establish the baseline
Run once with the update flag:
ARCH_TEST_UPDATE_FREEZE=true mix test
This writes a file at test/arch_test_violations/domain_web_deps.txt listing every current violation. Commit that file to version control.
On all subsequent runs, the freeze mechanism:
- Runs the assertion and collects all current violations
- Reads the baseline from the file
- Computes
current − baseline - Fails only if there are new violations not in the baseline
The 47 existing violations are silently ignored. Any new violation — introduced in a PR — causes a failure.
4. Clean up violations over time
As you fix legacy violations, re-run with the update flag to shrink the baseline:
ARCH_TEST_UPDATE_FREEZE=true mix test
The baseline file now has fewer entries. Eventually you can delete it entirely and the rule will enforce with zero tolerance.
A smaller baseline file on each PR is a visible, trackable signal of architectural progress.
5. Configure the baseline directory
Default location: test/arch_test_violations/
To change it:
# config/test.exs
config :arch_test, freeze_store: "test/arch_violations"6. Multiple frozen rules
Each rule gets its own key and its own baseline file:
test "web ↛ domain (freeze)" do
ArchTest.Freeze.freeze("web_domain", fn ->
modules_matching("MyApp.Web.**")
|> should_not_depend_on(modules_matching("MyApp.Domain.**"))
end)
end
test "no Manager modules (freeze)" do
ArchTest.Freeze.freeze("no_managers", fn ->
modules_matching("MyApp.**.*Manager") |> should_not_exist()
end)
endFiles: test/arch_test_violations/web_domain.txt, test/arch_test_violations/no_managers.txt.
7. Recommended workflow
| Step | Command | Effect |
|---|---|---|
| First adoption | ARCH_TEST_UPDATE_FREEZE=true mix test | Writes baseline for all frozen rules |
| Normal CI | mix test | Fails on new violations only |
| After fixing violations | ARCH_TEST_UPDATE_FREEZE=true mix test | Shrinks baseline files |
| Rule fully clean | Delete the baseline file, remove freeze wrapper | Enforces at zero tolerance |
How the freeze key is matched
Violations are compared as strings. The key format is "CallerModule → CalleeModule". If a violation string appears in the baseline file, it is ignored. If it is not in the file, the test fails.
This means renaming a module removes it from the baseline and treats it as a new violation — which is correct, because the new name should not carry forward the old waiver.
Next steps
- Getting Started — basic dependency assertions without freezing
- Modulith Rules — bounded-context isolation, a common place to start freezing