# Freezing (Gradual Adoption)
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:
```elixir
test "domain doesn't depend on web" do
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"))
end
```
It 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`:
```elixir
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)
end
```
For a test module full of architecture assertions, enable auto-freeze once:
```elixir
defmodule MyApp.LegacyArchTest do
use ExUnit.Case
use ArchTest, app: :my_app, freeze: true
test "domain doesn't depend on web" do
modules_matching("MyApp.Domain.**")
|> should_not_depend_on(modules_matching("MyApp.Web.**"),
rule_id: "domain_web_deps")
end
end
```
`rule_id:` is optional, but recommended for long-lived baselines because it
keeps the baseline filename stable.
On 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:
```sh
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:
1. Runs the assertion and collects all current violations
2. Reads the baseline from the file
3. Computes `current − baseline`
4. 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:
```sh
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:
```elixir
# 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:
```elixir
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)
end
```
Files: `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
ArchTest violations are compared as structured text keys derived from
`%ArchTest.Violation{}`. For dependency rules the key includes fields such as
`type`, `caller`, and `callee`; other rule types include their relevant module,
path, or message fields. Assertion failures without violation structs, such as a
module-count constraint, use normalized assertion text. If a key 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.
Auto-freeze generated rule ids include the test module, assertion name,
assertion arguments, and scope options (`:app`, `:apps`, `:paths`, `:include`,
`:exclude`). Empty-subject failures are not freezable; fix the pattern or pass
`allow_empty: true` when an empty match is intentional.
---
## Next steps
- [Getting Started](getting-started.md) — basic dependency assertions without freezing
- [Modulith Rules](modulith-rules.md) — bounded-context isolation, a common place to start freezing