# Configuration
Reach reads architecture, change-safety, advisory candidate, and smell policy from `.reach.exs`.
```bash
mix reach.check --arch
mix reach.check --changed
mix reach.check --candidates
mix reach.check --smells
mix reach.inspect TARGET --candidates
```
The file must evaluate to a keyword list. Start from [`examples/reach.exs`](../examples/reach.exs), then tune it to your project.
```elixir
[
layers: [
web: "MyAppWeb.*",
domain: "MyApp.*",
data: ["MyApp.Repo", "MyApp.Schemas.*"]
],
deps: [
forbidden: [
{:domain, :web},
{:data, :web}
]
],
source: [
forbidden_modules: ["MyApp.Legacy.*"],
forbidden_files: ["lib/my_app/legacy/**"]
],
calls: [
forbidden: [
{"MyApp.Domain.*", ["IO.puts", "Jason.encode!"]},
{"MyApp.Workers.*", ["System.cmd"], except: ["MyApp.Workers.Cleanup"]}
]
],
effects: [
allowed: [
{"MyApp.Pure.*", [:pure, :unknown]}
]
],
boundaries: [
public: ["MyApp.Accounts"],
internal: ["MyApp.Accounts.Internal.*"],
internal_callers: [
{"MyApp.Accounts.Internal.*", ["MyApp.Accounts", "MyApp.Accounts.*"]}
]
],
risk: [
changed: [
many_direct_callers: 5,
wide_transitive_callers: 10,
branch_heavy: 8,
high_risk_reason_count: 3
]
],
candidates: [
thresholds: [
mixed_effect_count: 2,
branchy_function_branches: 8,
high_risk_direct_callers: 4
],
limits: [
per_kind: 20,
representative_calls: 10,
representative_calls_per_edge: 3
]
],
clone_analysis: [
provider: :ex_dna,
min_mass: 30,
min_similarity: 1.0,
max_clones: 50
],
smells: [
fixed_shape_map: [
min_keys: 3,
min_occurrences: 3,
evidence_limit: 10
],
behaviour_candidate: [
min_modules: 3,
min_callbacks: 3,
module_display_limit: 8,
callback_display_limit: 8
]
],
tests: [
hints: [
{"lib/my_app/accounts/**", ["test/my_app/accounts_test.exs"]}
]
]
]
```
The `deps`, `source`, `calls`, `effects`, `boundaries`, `risk`, `candidates`, `smells`, and `tests` sections use a uniform grouped shape: the section names the concern, and nested entries name the policy direction or threshold being tuned.
## Architecture hardening recipe
For larger codebases, start with broad layer ownership and then add precise guardrails for boundaries that should never regress:
```elixir
[
layers: [
cli: ["Mix.Tasks.*", "MyApp.CLI.*"],
domain: ["MyApp.Accounts.*", "MyApp.Billing.*"],
adapters: ["MyApp.Repo", "MyApp.Adapters.*"],
web: "MyAppWeb.*"
],
deps: [
forbidden: [
{:domain, :cli},
{:domain, :web},
{:domain, :adapters, except: ["MyApp.Billing.PersistenceBoundary"]}
]
],
calls: [
forbidden: [
{"MyApp.Domain.*", ["MyApp.CLI.*", "Mix.Task.run/1", "Mix.Task.run/2"]},
{"MyApp.Adapters.*", ["MyAppWeb.*"]}
]
],
source: [
forbidden_modules: ["MyApp.Legacy.*", "MyApp.OldTaskRunner"],
forbidden_files: ["lib/my_app/legacy/**", "lib/my_app/old_task_runner.ex"]
],
checks: [
layer_coverage: [
require_all_modules: true,
forbid_multiple_matches: true,
ignore: ["Mix.Tasks.*", "MyApp.Generated.*"]
]
]
]
```
Use each policy layer for a different kind of guarantee:
- `layers` plus `layer_coverage` makes architectural ownership explicit.
- `deps` catches broad layer direction violations and reports concrete call-edge witnesses.
- `calls` catches precise banned APIs such as `Mix.Task.run/2`, CLI renderers, or framework escape hatches.
- `source` keeps removed namespaces and files from silently coming back.
- `except` and `except_edges` document intentional seams instead of weakening the whole rule.
- baselines should be reserved for known transitional findings; new findings still fail.
Reach uses this pattern in its own `.reach.exs` to keep CLI/Mix orchestration out of evidence, smell, frontend, plugin, and visualization modules while preserving a baseline for one known broad layer cycle.
## Keys
### `layers`
Assign modules to architectural layers.
```elixir
layers: [
web: "MyAppWeb.*",
domain: ["MyApp.Accounts", "MyApp.Billing", "MyApp.Catalog"],
data: "MyApp.Repo"
]
```
Patterns are module-name strings with `*` wildcards. A layer may have one pattern or a list of patterns.
Reach validates layer references in dependency policy. A dependency rule that names an undeclared layer fails config validation before analysis runs.
Layer coverage can be enabled when you want every project module to belong to exactly one layer:
```elixir
checks: [
layer_coverage: [
require_all_modules: true,
forbid_multiple_matches: true,
ignore: ["Mix.Tasks.*", "MyApp.Generated.*"]
]
]
```
`require_all_modules` reports modules that match no layer. `forbid_multiple_matches` reports modules that match more than one layer. `ignore` excludes generated code, tasks, or other modules from coverage checks.
### `deps[:forbidden]`
Declare layer-to-layer dependencies that should not exist.
```elixir
deps: [
forbidden: [
{:domain, :web},
{:data, :web},
{:domain, :data, except: ["MyApp.Domain.Migrations"]}
]
]
```
`mix reach.check --arch` reports `forbidden_dependency` violations with caller, callee, call, file, and line evidence. Layer cycle violations include concrete edge witnesses so you can see which calls create the cycle.
Use `except` to allow matching caller modules through an otherwise-forbidden layer edge. Use `except_edges` when only a specific caller-to-callee seam is allowed:
```elixir
deps: [
forbidden: [
{:domain, :data,
except_edges: [
{"MyApp.Domain.RepoBoundary", "MyApp.Repo"}
]}
]
]
```
For strict architectures, use allowlist mode instead of enumerating forbidden pairs:
```elixir
deps: [
mode: :allowlist,
allowed: [
web: [:domain],
domain: [],
data: [:domain]
]
]
```
In allowlist mode, same-layer calls are allowed and every cross-layer edge not listed in `allowed` is reported.
### `source[:forbidden_modules]`
Declare module names or namespaces that must not appear in the analyzed source tree. This is useful for making removed architecture impossible to reintroduce.
```elixir
source: [
forbidden_modules: [
"MyApp.Legacy.*",
"MyApp.OldTaskRunner"
]
]
```
`mix reach.check --arch` reports `forbidden_module` violations with module, file, and line evidence.
### `source[:forbidden_files]`
Declare source paths that must not appear in the analyzed source tree.
```elixir
source: [
forbidden_files: [
"lib/my_app/legacy/**",
"lib/my_app/old_task_runner.ex"
]
]
```
Path globs use the same `*` / `**` matching rules as module patterns. `mix reach.check --arch` reports `forbidden_file` violations.
### `calls[:forbidden]`
Declare calls that matching modules must not make. This is useful for enforcing presentation/IO boundaries or other call-level rules that are more precise than layer dependencies.
```elixir
calls: [
forbidden: [
{"MyApp.Domain.*", ["IO.puts", "Jason.encode!"]},
{"MyApp.Workers.*", ["System.cmd", "File.rm"], except: ["MyApp.Workers.Cleanup"]}
]
]
```
Each entry is either:
```elixir
{caller_patterns, call_patterns}
{caller_patterns, call_patterns, except: except_caller_patterns}
```
Patterns use the same module/call glob syntax as layers. Call patterns may include or omit arity:
```elixir
"IO.puts"
"IO.puts/1"
"Reach.CLI.Format.render"
"Jason.encode!"
```
`mix reach.check --arch` reports `forbidden_call` violations with caller module, call, file, and line evidence.
### `effects[:allowed]`
Limit side-effect classes for matching modules.
```elixir
effects: [
allowed: [
{"MyApp.Pure.*", [:pure, :unknown]},
{"MyAppWeb.*", [:pure, :read, :write, :send, :io, :unknown]}
],
by_layer: [
domain: [:pure, :exception],
web: :any
]
]
```
`allowed` applies to matching module patterns. `by_layer` applies to modules assigned through `layers`; direct module-pattern policies take precedence. Use `:any` for layers where all effects are allowed.
Known effect atoms include:
- `:pure`
- `:io`
- `:read`
- `:write`
- `:send`
- `:receive`
- `:exception`
- `:nif`
- `:unknown`
Use this for architectural boundaries, not style linting. For example, keeping parsers or pure domain modules free from writes is a good fit; replacing Credo rules is not.
### `boundaries[:public]`
Declare top-level public modules that callers should use as boundaries.
```elixir
boundaries: [
public: [
"MyApp.Accounts",
"MyApp.Billing"
]
]
```
If a caller reaches into another module under the same namespace instead of going through the declared public API, `mix reach.check --arch` may report a `public_api_boundary` violation.
### `boundaries[:internal]`
Declare modules that should be treated as internal implementation details.
```elixir
boundaries: [
internal: [
"MyApp.Accounts.Internal.*",
"MyApp.Billing.Calculators.*"
]
]
```
Calls into these modules from outside approved callers produce `internal_boundary` violations.
### `boundaries[:internal_callers]`
Allow specific callers to reach specific internal modules.
```elixir
boundaries: [
internal_callers: [
{"MyApp.Accounts.Internal.*", ["MyApp.Accounts", "MyApp.Accounts.*"]}
]
]
```
Use this to make policy precise instead of making internal modules public.
### `risk[:changed]`
Tune changed-code risk thresholds used by `mix reach.check --changed`.
```elixir
risk: [
changed: [
many_direct_callers: 5,
wide_transitive_callers: 10,
branch_heavy: 8,
high_risk_reason_count: 3
]
]
```
These thresholds control when a changed function is marked with risk reasons such as `many direct callers`, `wide transitive impact`, and `branch-heavy function`.
### `candidates[:thresholds]` and `candidates[:limits]`
Tune advisory refactoring candidate generation used by `mix reach.check --candidates` and `mix reach.inspect TARGET --candidates`.
```elixir
candidates: [
thresholds: [
mixed_effect_count: 2,
branchy_function_branches: 8,
high_risk_direct_callers: 4
],
limits: [
per_kind: 20,
representative_calls: 10,
representative_calls_per_edge: 3
]
]
```
Thresholds decide when Reach reports mixed-effect and branch-heavy extraction candidates. Limits bound candidate evidence and per-kind generation while preserving exact cycle-component detection.
### `clone_analysis`
Configure optional structural clone evidence. Reach uses clone evidence to raise confidence or find consistency drift in semantic checks; it does not emit an `ex_dna` smell by itself.
```elixir
checks: [
baseline: ".reach-baseline.json"
]
clone_analysis: [
provider: :ex_dna,
min_mass: 30,
min_similarity: 1.0,
max_clones: 50
]
smells: [
strict: true,
custom_checks: [MyApp.ReachSmells.NoFoo],
ignore: [
paths: ["vendor/**", "lib/my_app/generated/**"],
modules: ["MyApp.Generated.*"]
],
fixed_shape_map: [
min_keys: 3,
min_occurrences: 3,
evidence_limit: 10,
ignore: [paths: ["lib/my_app/openapi/**"]]
],
behaviour_candidate: [
min_modules: 3,
min_callbacks: 3,
module_display_limit: 8,
callback_display_limit: 8,
ignore: [modules: ["MyApp.Legacy.*"]]
]
]
```
Reach runs ExDNA when the package is available; package consumers can disable clone evidence with `provider: false` or tune clone mass/similarity when needed.
### `checks[:baseline]`
Use a baseline to adopt `reach.check` gates in an existing codebase without hiding new issues. Baselines apply across check modes such as architecture violations and smell findings.
```bash
mix reach.check --arch --smells --write-baseline .reach-baseline.json
mix reach.check --arch --smells --baseline .reach-baseline.json
```
When a baseline is configured, known findings are suppressed before gate failure is evaluated. New architecture violations still fail `--arch`, and new smell findings fail when `--strict` or `smells: [strict: true]` is enabled.
### `smells[:strict]`
`mix reach.check --smells` is advisory by default. Set `strict: true` or pass `--strict` to fail on non-baseline smell findings.
### `smells[:ignore]`
Use config-level ignores for generated, vendored, or intentionally noisy code. Global ignores apply to all smell checks; per-check ignores apply only to that smell kind.
```elixir
smells: [
ignore: [paths: ["vendor/**"], modules: ["MyApp.Generated.*"]],
fixed_shape_map: [ignore: [paths: ["lib/my_app/openapi/**"]]],
behaviour_candidate: [ignore: [modules: ["MyApp.Legacy.*"]]]
]
```
Paths use glob patterns. Modules use glob patterns matched against the inspected module name. Suppressions are applied before baseline filtering and strict failure checks.
For local, source-level exceptions, use Credo-style comments:
```elixir
# reach:disable-for-this-file fixed_shape_map
# reach:disable-next-line bare_rescue
def run, do: rescue_fallback()
```
Use `smells` or `all` instead of a specific kind to suppress every smell finding at that scope. Unknown comment tokens are ignored without creating atoms.
### `smells[:custom_checks]`
Projects can add local smell checks by implementing `Reach.Smell.Check` in their own application and listing the modules in `.reach.exs`.
```elixir
defmodule MyApp.ReachSmells.NoFoo do
@behaviour Reach.Smell.Check
alias Reach.Smell.Finding
@impl true
def run(project) do
for {_id, node} <- project.nodes,
node.type == :call,
node.meta[:module] == MyApp.Foo do
Finding.new(
kind: :my_app_no_foo,
message: "Use MyApp.Bar instead of MyApp.Foo",
location: location(node)
)
end
end
defp location(%{source_span: %{file: file, start_line: line}}), do: "#{file}:#{line}"
defp location(_node), do: "unknown"
end
```
Enable it explicitly:
```elixir
smells: [
strict: true,
custom_checks: [MyApp.ReachSmells.NoFoo]
]
```
Custom checks run alongside Reach's built-in smell checks and participate in `--strict` and baseline filtering. A custom check must implement `Reach.Smell.Check` and define `run/1`. See the custom smells guide for a deeper walkthrough.
### Built-in smell examples
Reach combines generic Elixir smells with plugin-provided Phoenix, Ecto, and Oban checks. Examples include:
| Kind | Example flagged pattern | Prefer |
| --- | --- | --- |
| `unsafe_atom_creation` | `String.to_atom(input)` | explicit mapping or `String.to_existing_atom/1` |
| `unsafe_binary_to_term` | `:erlang.binary_to_term(payload)` | `:erlang.binary_to_term(payload, [:safe])` for untrusted input |
| `missing_external_resource` | `@schema File.read!("priv/schema.json")` | add matching `@external_resource "priv/schema.json"` |
| `ecto_float_money` | `field :amount, :float` / `add :price, :float` | integer cents or `Decimal` |
| `ecto_repo_call_in_loop` | `Enum.map(users, &Repo.get(Order, &1.order_id))` | preload or batch query |
| `ecto_filter_after_repo_all` | `Repo.all(User) |> Enum.filter(...)` | push predicates into the Ecto query |
| `ecto_count_after_repo_all` | `Repo.all(User) |> length()` | `Repo.aggregate/3`, `Repo.exists?/1`, or query aggregate |
| `ecto_interpolated_fragment` | `fragment("name = '#{name}'")` | `fragment("name = ?", ^name)` |
| `ecto_interpolated_repo_query` | `Repo.query("select ... #{input}")` | parameterized SQL |
| `ecto_implicit_cross_join` | `from u in User, p in Post` | explicit `join:` with `on:` |
| `ecto_unpinned_query_value` | `where: u.id == user_id` | `where: u.id == ^user_id` |
| `oban_atom_args` | `%Oban.Job{args: %{user_id: id}}` | match string keys: `%{"user_id" => id}` |
| `oban_struct_args` | `MyWorker.new(%{user: %User{}})` | store IDs / JSON primitives |
| `phoenix_assign_async_captures_socket` | `assign_async(socket, :x, fn -> socket.assigns.x end)` | copy needed assign values before the callback |
| `phoenix_assign_new_refreshed_value` | `assign_new(socket, :current_user, ...)` inside `mount/3` | use `assign/3` for values refreshed every mount |
| `phoenix_pubsub_subscribe_without_connected` | `Phoenix.PubSub.subscribe(...)` in `mount/3` | guard with `if connected?(socket)` |
Some intentionally context-sensitive checks, such as dynamic `Phoenix.HTML.raw/1`, are kept available as direct check modules but are not enabled by the default Phoenix plugin because real applications often use them in sanitizer, markdown, or compiler helpers.
### `smells[:fixed_shape_map]` and `smells[:behaviour_candidate]`
Use smell-specific thresholds when a codebase intentionally uses small map contracts, when you want stronger pressure toward structs/contracts, or when behaviour-candidate hints are too noisy for small module families.
### `tests[:hints]`
Suggest tests for changed paths.
```elixir
tests: [
hints: [
{"lib/my_app/accounts/**", ["test/my_app/accounts_test.exs"]},
{"lib/my_app_web/live/**", ["test/my_app_web/live"]}
]
]
```
`mix reach.check --changed` combines these hints with nearby test paths and caller impact data.
## Compatibility aliases
Reach accepts the previous flat keys as compatibility aliases, but new configs should use the grouped form.
| Preferred | Compatibility alias |
| --- | --- |
| `deps[:forbidden]` | `forbidden_deps` |
| `calls[:forbidden]` | `forbidden_calls` |
| `effects[:allowed]` | `allowed_effects` |
| `boundaries[:public]` | `public_api` |
| `boundaries[:internal]` | `internal` |
| `boundaries[:internal_callers]` | `internal_callers` |
| `tests[:hints]` | `test_hints` |
| `source[:forbidden_modules]` | `forbidden_modules` |
| `source[:forbidden_files]` | `forbidden_files` |
## Validation
Reach validates `.reach.exs` shape and reports `config_error` entries for:
- unknown top-level or grouped keys
- invalid `layers`
- invalid `deps[:forbidden]`
- invalid `source[:forbidden_modules]`
- invalid `source[:forbidden_files]`
- invalid `calls[:forbidden]`
- invalid `effects[:allowed]`
- invalid `boundaries[:public]`
- invalid `boundaries[:internal]`
- invalid `boundaries[:internal_callers]`
- invalid `risk[:changed]` thresholds
- invalid `candidates[:thresholds]`
- invalid `candidates[:limits]`
- invalid `smells[:fixed_shape_map]`
- invalid `smells[:behaviour_candidate]`
- invalid `clone_analysis`
- invalid `tests[:hints]`
## Practical guidance
Start permissive and tighten gradually:
1. Define broad layers.
2. Add only the forbidden dependencies you are confident about.
3. Add boundary policies for namespaces with clear public/internal modules.
4. Add effect policies for modules that should stay pure or effect-limited.
5. Tune `risk[:changed]`, `candidates`, and `smells` thresholds to match your repository size and tolerance for advisory output.
6. Run `mix reach.check --arch --format json` in CI once the policy is stable.
Refactoring candidates are advisory. They include `confidence`, `actionability`, and `proof` fields. Treat those fields as preconditions for editing, especially for cycle and extraction candidates.