Packages

Enforce architecture rules in Elixir projects — without touching production code. Define layer boundaries in arch.exs, run mix arch.check, and get CI-friendly violations.

Current section

Files

Jump to
ex_arch_unit lib ex_arch_unit.ex
Raw

lib/ex_arch_unit.ex

defmodule ExArchUnit do
@moduledoc """
ExUnit-first architecture testing helpers for Elixir projects.
"""
@doc """
Sets up architecture testing in an ExUnit test module.
## Options
* `:config` — path to the config file (default `"arch.exs"`)
* `:enforce_config_rules` — whether to enforce layer rules from the config
file during `setup_all` (default `true`)
Imports the `forbid/2`, `allow/2`, and `assert_no_cycles/1` macros and
injects a `setup_all` callback that builds (or reuses) the dependency graph.
"""
defmacro __using__(opts) do
config_path = Keyword.get(opts, :config, ExArchUnit.Config.default_config_path())
enforce_config_rules = Keyword.get(opts, :enforce_config_rules, true)
quote bind_quoted: [config_path: config_path, enforce_config_rules: enforce_config_rules] do
import ExArchUnit, only: [forbid: 2, allow: 2, assert_no_cycles: 1]
@ex_arch_config_path config_path
@ex_arch_enforce_config_rules enforce_config_rules
setup_all do
config = ExArchUnit.Config.load!(@ex_arch_config_path)
{graph, stats} = ExArchUnit.Graph.Cache.get_or_build(config)
if @ex_arch_enforce_config_rules do
ExArchUnit.__assert_config_rules__(graph, config)
end
{:ok, graph: graph, arch_stats: stats, arch_config: config}
end
end
end
@doc """
Fails when any module matching `source_selector` depends on a module
matching the `:depends_on` target selector.
## Example
test "domain does not call web" do
forbid "MyApp.Domain.*", depends_on: "MyAppWeb.*"
end
"""
defmacro forbid(source_selector, opts) do
config_path =
Module.get_attribute(__CALLER__.module, :ex_arch_config_path) ||
ExArchUnit.Config.default_config_path()
quote bind_quoted: [source_selector: source_selector, opts: opts, config_path: config_path] do
ExArchUnit.__assert_forbid__(binding(), source_selector, opts, config_path)
end
end
@doc """
Fails when any module matching `source_selector` depends on a module
*not* matching the `:depends_on` target selector. Dependencies within
the source set are always permitted (intra-layer references are ok).
## Example
test "web only depends on domain" do
allow "MyAppWeb.*", depends_on: "MyApp.Domain.*"
end
"""
defmacro allow(source_selector, opts) do
config_path =
Module.get_attribute(__CALLER__.module, :ex_arch_config_path) ||
ExArchUnit.Config.default_config_path()
quote bind_quoted: [source_selector: source_selector, opts: opts, config_path: config_path] do
ExArchUnit.__assert_allow__(binding(), source_selector, opts, config_path)
end
end
@doc """
Fails when strongly-connected-component cycles are found among modules
matching the given selector.
## Options
* `:prefix` — selector string (e.g. `"MyApp.Domain.*"`)
* `:in` — alias for `:prefix`
## Example
test "domain has no cycles" do
assert_no_cycles prefix: "MyApp.Domain.*"
end
"""
defmacro assert_no_cycles(opts) do
config_path =
Module.get_attribute(__CALLER__.module, :ex_arch_config_path) ||
ExArchUnit.Config.default_config_path()
quote bind_quoted: [opts: opts, config_path: config_path] do
ExArchUnit.__assert_no_cycles__(binding(), opts, config_path)
end
end
@doc false
def __assert_forbid__(binding, source_selector, opts, config_path) do
target_selector =
Keyword.get(opts, :depends_on) ||
raise ArgumentError, "forbid/2 expects :depends_on"
{graph, config} = resolve_context(binding, config_path)
case ExArchUnit.Rule.Evaluator.forbid(graph, config, source_selector, target_selector) do
:ok ->
:ok
{:error, violations} ->
ExUnit.Assertions.flunk(
ExArchUnit.Reporter.format_forbid(source_selector, target_selector, violations)
)
end
end
@doc false
def __assert_allow__(binding, source_selector, opts, config_path) do
target_selector =
Keyword.get(opts, :depends_on) ||
raise ArgumentError, "allow/2 expects :depends_on"
{graph, config} = resolve_context(binding, config_path)
case ExArchUnit.Rule.Evaluator.allow(graph, config, source_selector, target_selector) do
:ok ->
:ok
{:error, violations} ->
ExUnit.Assertions.flunk(
ExArchUnit.Reporter.format_allow(source_selector, target_selector, violations)
)
end
end
@doc false
def __assert_no_cycles__(binding, opts, config_path) do
selector = Keyword.get(opts, :prefix) || Keyword.get(opts, :in)
unless selector do
raise ArgumentError, "assert_no_cycles/1 expects :prefix or :in option"
end
{graph, config} = resolve_context(binding, config_path)
case ExArchUnit.Rule.Evaluator.assert_no_cycles(graph, config, opts) do
:ok ->
:ok
{:error, cycles} ->
ExUnit.Assertions.flunk(ExArchUnit.Reporter.format_cycles(selector, cycles))
end
end
@doc false
def __assert_config_rules__(graph, config) do
case ExArchUnit.Rule.Evaluator.evaluate_layer_rules(graph, config) do
:ok ->
:ok
{:error, layer_rule_violations} ->
ExUnit.Assertions.flunk(ExArchUnit.Reporter.format_layer_rules(layer_rule_violations))
end
end
defp resolve_context(binding, config_path) do
context = binding[:context] || binding[:_context] || %{}
case context do
%{graph: graph, arch_config: config} ->
{graph, config}
_ ->
config = ExArchUnit.Config.load!(config_path)
{graph, _stats} = ExArchUnit.Graph.Cache.get_or_build(config)
{graph, config}
end
end
end