Freezing (Gradual Adoption)

Copy Markdown View Source

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.**"))
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:

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

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:

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:

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)
end

Files: test/arch_test_violations/web_domain.txt, test/arch_test_violations/no_managers.txt.


StepCommandEffect
First adoptionARCH_TEST_UPDATE_FREEZE=true mix testWrites baseline for all frozen rules
Normal CImix testFails on new violations only
After fixing violationsARCH_TEST_UPDATE_FREEZE=true mix testShrinks baseline files
Rule fully cleanDelete the baseline file, remove freeze wrapperEnforces 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