Packages

ArchUnit-inspired architecture testing library for Elixir. Write ExUnit tests that enforce dependency rules, layered architecture, modulith bounded contexts, and naming conventions.

Current section

Files

Jump to
arch_test lib arch_test.ex
Raw

lib/arch_test.ex

defmodule ArchTest do
@moduledoc """
ArchUnit-inspired architecture testing library for Elixir.
Write ExUnit tests that enforce architectural rules — dependency direction,
layer boundaries, bounded-context isolation, and naming conventions —
using a fluent, pipe-based DSL.
## Quick start
defmodule MyApp.ArchitectureTest do
use ExUnit.Case
use ArchTest, app: :my_app
test "service modules don't call repos directly" do
modules_matching("MyApp.**.*Service")
|> should_not_depend_on(modules_matching("MyApp.**.*Repo"))
end
test "no Manager modules exist" do
modules_matching("MyApp.**.*Manager") |> should_not_exist()
end
end
## Options for `use ArchTest`
- `:app` — OTP app atom to limit introspection (default: `:all`)
- `:freeze` — `true` to auto-freeze all rules in the module (default: `false`)
## Module reference
- `ArchTest.ModuleSet` — module selection and filtering DSL
- `ArchTest.Assertions` — core assertion functions
- `ArchTest.Layers` — layered architecture enforcement
- `ArchTest.Modulith` — bounded-context / modulith isolation
- `ArchTest.Freeze` — violation baseline / freezing
- `ArchTest.Metrics` — coupling/instability metrics
- `ArchTest.Conventions` — pre-built Elixir convention rules
- `ArchTest.Collector` — BEAM dependency graph builder
- `ArchTest.Pattern` — glob pattern matching
"""
alias ArchTest.{Assertions, Layers, ModuleSet, Modulith}
@doc false
defmacro __using__(opts \\ []) do
app = Keyword.get(opts, :app, :all)
freeze = Keyword.get(opts, :freeze, false)
excluded_imports = __using_import_excludes__()
# credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks
quote do
import ArchTest, except: unquote(excluded_imports)
# Stash the app option so DSL functions can pick it up.
@arch_test_app unquote(app)
@arch_test_freeze unquote(freeze)
@doc false
def arch_test_opts, do: [app: @arch_test_app]
@doc false
def arch_test_opts(opts) when is_list(opts), do: Keyword.merge(arch_test_opts(), opts)
defp __arch_test_run__(rule_name, args, opts, assertion_fn) do
opts = arch_test_opts(opts)
if @arch_test_freeze do
rule_id =
Keyword.get_lazy(opts, :rule_id, fn ->
ArchTest.Freeze.rule_id(__MODULE__, rule_name, [
args,
__arch_test_scope_opts__(opts)
])
end)
ArchTest.Freeze.freeze(rule_id, assertion_fn)
else
assertion_fn.()
end
end
defp __arch_test_scope_opts__(opts) do
Keyword.take(opts, [:app, :apps, :paths, :include, :exclude])
end
def modules_matching(pattern), do: ArchTest.modules_matching(pattern)
def modules_in(namespace), do: ArchTest.modules_in(namespace)
def all_modules, do: ArchTest.all_modules()
def modules_satisfying(filter_fn), do: ArchTest.modules_satisfying(filter_fn)
def should_not_depend_on(subject, object, opts \\ []) do
__arch_test_run__("should_not_depend_on", [subject, object], opts, fn ->
ArchTest.should_not_depend_on(subject, object, arch_test_opts(opts))
end)
end
def should_only_depend_on(subject, allowed, opts \\ []) do
__arch_test_run__("should_only_depend_on", [subject, allowed], opts, fn ->
ArchTest.should_only_depend_on(subject, allowed, arch_test_opts(opts))
end)
end
def should_not_be_called_by(object, callers, opts \\ []) do
__arch_test_run__("should_not_be_called_by", [object, callers], opts, fn ->
ArchTest.should_not_be_called_by(object, callers, arch_test_opts(opts))
end)
end
def should_only_be_called_by(object, allowed_callers, opts \\ []) do
__arch_test_run__("should_only_be_called_by", [object, allowed_callers], opts, fn ->
ArchTest.should_only_be_called_by(object, allowed_callers, arch_test_opts(opts))
end)
end
def should_not_transitively_depend_on(subject, object, opts \\ []) do
__arch_test_run__("should_not_transitively_depend_on", [subject, object], opts, fn ->
ArchTest.should_not_transitively_depend_on(subject, object, arch_test_opts(opts))
end)
end
def should_not_exist(subject, opts \\ []) do
__arch_test_run__("should_not_exist", [subject], opts, fn ->
ArchTest.should_not_exist(subject, arch_test_opts(opts))
end)
end
def should_reside_under(subject, namespace_pattern, opts \\ []) do
__arch_test_run__("should_reside_under", [subject, namespace_pattern], opts, fn ->
ArchTest.should_reside_under(subject, namespace_pattern, arch_test_opts(opts))
end)
end
def should_have_name_matching(subject, name_pattern, opts \\ []) do
__arch_test_run__("should_have_name_matching", [subject, name_pattern], opts, fn ->
ArchTest.should_have_name_matching(subject, name_pattern, arch_test_opts(opts))
end)
end
def should_be_free_of_cycles(subject, opts \\ []) do
__arch_test_run__("should_be_free_of_cycles", [subject], opts, fn ->
ArchTest.should_be_free_of_cycles(subject, arch_test_opts(opts))
end)
end
def should_have_module_count(subject, constraints, opts \\ []) do
run_opts =
constraints
|> Keyword.take([:app, :apps, :paths, :include, :exclude, :rule_id])
|> Keyword.merge(opts)
semantic_constraints =
Keyword.drop(constraints, [
:graph,
:app,
:apps,
:paths,
:include,
:exclude,
:force,
:message,
:rule_id,
:allow_empty
])
__arch_test_run__(
"should_have_module_count",
[subject, semantic_constraints],
run_opts,
fn ->
constraints_with_default_scope = Keyword.merge(arch_test_opts(), constraints)
ArchTest.should_have_module_count(subject, constraints_with_default_scope, opts)
end
)
end
def satisfying(subject, check_fn, opts \\ []) do
__arch_test_run__("satisfying", [subject, check_fn], opts, fn ->
ArchTest.satisfying(subject, check_fn, arch_test_opts(opts))
end)
end
def should_implement_behaviour(subject, behaviour, opts \\ []) do
__arch_test_run__("should_implement_behaviour", [subject, behaviour], opts, fn ->
ArchTest.should_implement_behaviour(subject, behaviour, arch_test_opts(opts))
end)
end
def should_not_implement_behaviour(subject, behaviour, opts \\ []) do
__arch_test_run__("should_not_implement_behaviour", [subject, behaviour], opts, fn ->
ArchTest.should_not_implement_behaviour(subject, behaviour, arch_test_opts(opts))
end)
end
def should_implement_protocol(subject, protocol, opts \\ []) do
__arch_test_run__("should_implement_protocol", [subject, protocol], opts, fn ->
ArchTest.should_implement_protocol(subject, protocol, arch_test_opts(opts))
end)
end
def should_not_implement_protocol(subject, protocol, opts \\ []) do
__arch_test_run__("should_not_implement_protocol", [subject, protocol], opts, fn ->
ArchTest.should_not_implement_protocol(subject, protocol, arch_test_opts(opts))
end)
end
def should_have_attribute(subject, attr_key, opts \\ []) do
__arch_test_run__("should_have_attribute", [subject, attr_key], opts, fn ->
ArchTest.should_have_attribute(subject, attr_key, arch_test_opts(opts))
end)
end
def should_not_have_attribute(subject, attr_key, opts \\ []) do
__arch_test_run__("should_not_have_attribute", [subject, attr_key], opts, fn ->
ArchTest.should_not_have_attribute(subject, attr_key, arch_test_opts(opts))
end)
end
def should_have_attribute_value(subject, attr_key, attr_value, opts \\ []) do
__arch_test_run__(
"should_have_attribute_value",
[subject, attr_key, attr_value],
opts,
fn ->
ArchTest.should_have_attribute_value(
subject,
attr_key,
attr_value,
arch_test_opts(opts)
)
end
)
end
def should_not_have_attribute_value(subject, attr_key, attr_value, opts \\ []) do
__arch_test_run__(
"should_not_have_attribute_value",
[subject, attr_key, attr_value],
opts,
fn ->
ArchTest.should_not_have_attribute_value(
subject,
attr_key,
attr_value,
arch_test_opts(opts)
)
end
)
end
def should_use(subject, used_module, opts \\ []) do
__arch_test_run__("should_use", [subject, used_module], opts, fn ->
ArchTest.should_use(subject, used_module, arch_test_opts(opts))
end)
end
def should_not_use(subject, used_module, opts \\ []) do
__arch_test_run__("should_not_use", [subject, used_module], opts, fn ->
ArchTest.should_not_use(subject, used_module, arch_test_opts(opts))
end)
end
def should_export(subject, fun_name, arity, opts \\ []) do
__arch_test_run__("should_export", [subject, fun_name, arity], opts, fn ->
ArchTest.should_export(subject, fun_name, arity, arch_test_opts(opts))
end)
end
def should_not_export(subject, fun_name, arity, opts \\ []) do
__arch_test_run__("should_not_export", [subject, fun_name, arity], opts, fn ->
ArchTest.should_not_export(subject, fun_name, arity, arch_test_opts(opts))
end)
end
def should_have_public_functions_matching(subject, pattern, opts \\ []) do
__arch_test_run__("should_have_public_functions_matching", [subject, pattern], opts, fn ->
ArchTest.should_have_public_functions_matching(subject, pattern, arch_test_opts(opts))
end)
end
def should_not_have_public_functions_matching(subject, pattern, opts \\ []) do
__arch_test_run__(
"should_not_have_public_functions_matching",
[subject, pattern],
opts,
fn ->
ArchTest.should_not_have_public_functions_matching(
subject,
pattern,
arch_test_opts(opts)
)
end
)
end
def define_layers(layer_defs), do: ArchTest.define_layers(layer_defs, arch_test_opts())
def define_layers(layer_defs, opts),
do: ArchTest.define_layers(layer_defs, arch_test_opts(opts))
def define_onion(layer_defs), do: ArchTest.define_onion(layer_defs, arch_test_opts())
def define_onion(layer_defs, opts),
do: ArchTest.define_onion(layer_defs, arch_test_opts(opts))
def define_slices(slice_defs), do: ArchTest.define_slices(slice_defs, arch_test_opts())
def define_slices(slice_defs, opts),
do: ArchTest.define_slices(slice_defs, arch_test_opts(opts))
def define_slices_by(pattern, opts \\ []),
do: ArchTest.define_slices_by(pattern, arch_test_opts(opts))
def enforce_direction(layers, opts \\ []) do
__arch_test_run__("enforce_direction", [layers], opts, fn ->
ArchTest.enforce_direction(layers, arch_test_opts(opts))
end)
end
def enforce_onion_rules(layers, opts \\ []) do
__arch_test_run__("enforce_onion_rules", [layers], opts, fn ->
ArchTest.enforce_onion_rules(layers, arch_test_opts(opts))
end)
end
def allow_dependency(modulith, from_slice, to_slice),
do: ArchTest.allow_dependency(modulith, from_slice, to_slice)
def allow_layer_dependency(layers, from_layer, to_layer),
do: ArchTest.allow_layer_dependency(layers, from_layer, to_layer)
def enforce_isolation(modulith, opts \\ []) do
__arch_test_run__("enforce_isolation", [modulith], opts, fn ->
ArchTest.enforce_isolation(modulith, arch_test_opts(opts))
end)
end
def should_not_depend_on_each_other(modulith, opts \\ []) do
__arch_test_run__("should_not_depend_on_each_other", [modulith], opts, fn ->
ArchTest.should_not_depend_on_each_other(modulith, arch_test_opts(opts))
end)
end
def all_modules_covered_by(modulith, namespace_pattern, opts \\ []) do
__arch_test_run__("all_modules_covered_by", [modulith, namespace_pattern], opts, fn ->
ArchTest.all_modules_covered_by(modulith, namespace_pattern, arch_test_opts(opts))
end)
end
end
end
@doc false
def __using_import_excludes__ do
[
modules_matching: 1,
modules_in: 1,
all_modules: 0,
modules_satisfying: 1,
should_not_depend_on: 2,
should_not_depend_on: 3,
should_only_depend_on: 2,
should_only_depend_on: 3,
should_not_be_called_by: 2,
should_not_be_called_by: 3,
should_only_be_called_by: 2,
should_only_be_called_by: 3,
should_not_transitively_depend_on: 2,
should_not_transitively_depend_on: 3,
should_not_exist: 1,
should_not_exist: 2,
should_reside_under: 2,
should_reside_under: 3,
should_have_name_matching: 2,
should_have_name_matching: 3,
should_be_free_of_cycles: 1,
should_be_free_of_cycles: 2,
should_have_module_count: 2,
should_have_module_count: 3,
satisfying: 2,
satisfying: 3,
should_implement_behaviour: 2,
should_implement_behaviour: 3,
should_not_implement_behaviour: 2,
should_not_implement_behaviour: 3,
should_implement_protocol: 2,
should_implement_protocol: 3,
should_not_implement_protocol: 2,
should_not_implement_protocol: 3,
should_have_attribute: 2,
should_have_attribute: 3,
should_not_have_attribute: 2,
should_not_have_attribute: 3,
should_have_attribute_value: 3,
should_have_attribute_value: 4,
should_not_have_attribute_value: 3,
should_not_have_attribute_value: 4,
should_use: 2,
should_use: 3,
should_not_use: 2,
should_not_use: 3,
should_export: 3,
should_export: 4,
should_not_export: 3,
should_not_export: 4,
should_have_public_functions_matching: 2,
should_have_public_functions_matching: 3,
should_not_have_public_functions_matching: 2,
should_not_have_public_functions_matching: 3,
define_layers: 1,
define_layers: 2,
define_onion: 1,
define_onion: 2,
define_slices: 1,
define_slices: 2,
define_slices_by: 1,
define_slices_by: 2,
enforce_direction: 1,
enforce_direction: 2,
enforce_onion_rules: 1,
enforce_onion_rules: 2,
allow_dependency: 3,
allow_layer_dependency: 3,
enforce_isolation: 1,
enforce_isolation: 2,
should_not_depend_on_each_other: 1,
should_not_depend_on_each_other: 2,
all_modules_covered_by: 2,
all_modules_covered_by: 3
]
end
# ------------------------------------------------------------------
# Module selection DSL
# ------------------------------------------------------------------
@doc """
Returns a `ModuleSet` for modules matching the given glob pattern.
## Pattern semantics
| Pattern | Matches |
|---------|---------|
| `"MyApp.Orders.*"` | Direct children only |
| `"MyApp.Orders.**"` | All descendants at any depth |
| `"MyApp.Orders"` | Exact match only |
| `"**.*Service"` | Last segment ends with `Service` |
| `"**.*Service*"` | Last segment contains `Service` |
## Example
modules_matching("**.*Controller")
|> should_not_depend_on(modules_matching("**.*Repo"))
"""
@spec modules_matching(String.t()) :: ModuleSet.t()
def modules_matching(pattern), do: ModuleSet.new(pattern)
@doc """
Returns a `ModuleSet` for all direct children of `namespace`.
Shorthand for `modules_matching("Namespace.*")`.
## Example
modules_in("MyApp.Orders")
# equivalent to modules_matching("MyApp.Orders.*")
"""
@spec modules_in(String.t()) :: ModuleSet.t()
def modules_in(namespace), do: ModuleSet.in_namespace(namespace)
@doc """
Returns a `ModuleSet` matching every module in the application.
"""
@spec all_modules() :: ModuleSet.t()
def all_modules, do: ModuleSet.all()
@doc """
Returns a `ModuleSet` matching modules that satisfy a custom predicate.
## Example
modules_satisfying(fn mod ->
function_exported?(mod, :__schema__, 1)
end)
|> should_reside_under("MyApp.**.Schemas")
"""
@spec modules_satisfying((module() -> boolean())) :: ModuleSet.t()
def modules_satisfying(filter_fn), do: ModuleSet.satisfying(filter_fn)
# ------------------------------------------------------------------
# ModuleSet composition (re-exported for convenience)
# ------------------------------------------------------------------
@doc """
Excludes modules matching `pattern` from a `ModuleSet`.
## Example
modules_matching("MyApp.**")
|> excluding("MyApp.Test.*")
"""
defdelegate excluding(module_set, pattern), to: ModuleSet
@doc """
Combines two `ModuleSet`s (union / OR).
## Example
modules_matching("**.*Service")
|> union(modules_matching("**.*View"))
"""
defdelegate union(a, b), to: ModuleSet
@doc """
Returns modules present in both `ModuleSet`s (intersection / AND).
"""
defdelegate intersection(a, b), to: ModuleSet
# ------------------------------------------------------------------
# Assertion functions (delegated to ArchTest.Assertions)
# ------------------------------------------------------------------
@doc """
Asserts that no module in `subject` directly depends on modules in `object`.
"""
defdelegate should_not_depend_on(subject, object, opts \\ []), to: Assertions
@doc """
Asserts that modules in `subject` only depend on modules in `allowed`.
"""
defdelegate should_only_depend_on(subject, allowed, opts \\ []), to: Assertions
@doc """
Asserts that no module in `callers` calls any module in `object`.
"""
defdelegate should_not_be_called_by(object, callers, opts \\ []), to: Assertions
@doc """
Asserts only modules in `allowed_callers` may call modules in `object`.
"""
defdelegate should_only_be_called_by(object, allowed_callers, opts \\ []), to: Assertions
@doc """
Asserts no transitive dependency from `subject` to modules in `object`.
"""
defdelegate should_not_transitively_depend_on(subject, object, opts \\ []), to: Assertions
@doc """
Asserts that no module in `subject` exists.
"""
defdelegate should_not_exist(subject, opts \\ []), to: Assertions
@doc """
Asserts that all modules in `subject` reside under `namespace_pattern`.
"""
defdelegate should_reside_under(subject, namespace_pattern, opts \\ []), to: Assertions
@doc """
Asserts that all modules in `subject` have names matching `name_pattern`.
"""
defdelegate should_have_name_matching(subject, name_pattern, opts \\ []), to: Assertions
@doc """
Asserts no circular dependencies among modules in `subject`.
Accepts either a `ModuleSet` or a `Modulith` slice definition.
"""
@spec should_be_free_of_cycles(ModuleSet.t() | Modulith.t(), keyword()) :: :ok
def should_be_free_of_cycles(subject, opts \\ [])
def should_be_free_of_cycles(%ModuleSet{} = subject, opts) do
Assertions.should_be_free_of_cycles(subject, opts)
end
def should_be_free_of_cycles(%Modulith{} = modulith, opts) do
modulith
|> maybe_modulith_for_app(opts)
|> Modulith.should_be_free_of_cycles(opts)
end
@doc """
Asserts that the number of modules matching `subject` satisfies the given constraints.
"""
defdelegate should_have_module_count(subject, constraints, opts \\ []), to: Assertions
@doc """
Applies a custom check function `(graph, module -> [Violation.t()])` to
each module in `subject`.
"""
defdelegate satisfying(subject, check_fn, opts \\ []), to: Assertions
@doc """
Asserts that all modules in `subject` implement the given behaviour.
"""
defdelegate should_implement_behaviour(subject, behaviour, opts \\ []), to: Assertions
@doc """
Asserts that no module in `subject` implements the given behaviour.
"""
defdelegate should_not_implement_behaviour(subject, behaviour, opts \\ []), to: Assertions
@doc """
Asserts that all modules in `subject` implement the given protocol.
"""
defdelegate should_implement_protocol(subject, protocol, opts \\ []), to: Assertions
@doc """
Asserts that no module in `subject` implements the given protocol.
"""
defdelegate should_not_implement_protocol(subject, protocol, opts \\ []), to: Assertions
@doc """
Asserts all modules in `subject` have the given module attribute.
"""
defdelegate should_have_attribute(subject, attr_key, opts \\ []), to: Assertions
@doc """
Asserts all modules in `subject` do NOT have the given module attribute.
"""
defdelegate should_not_have_attribute(subject, attr_key, opts \\ []), to: Assertions
@doc """
Asserts all modules in `subject` have the given attribute with the given value.
"""
defdelegate should_have_attribute_value(subject, attr_key, attr_value, opts \\ []),
to: Assertions
@doc """
Asserts all modules in `subject` do NOT have the given attribute with the given value.
"""
defdelegate should_not_have_attribute_value(subject, attr_key, attr_value, opts \\ []),
to: Assertions
@doc """
Asserts all modules in `subject` use the given module (via `use ModuleName`).
"""
defdelegate should_use(subject, used_module, opts \\ []), to: Assertions
@doc """
Asserts no module in `subject` uses the given module (via `use ModuleName`).
"""
defdelegate should_not_use(subject, used_module, opts \\ []), to: Assertions
@doc """
Asserts all modules in `subject` export the given function.
"""
defdelegate should_export(subject, fun_name, arity, opts \\ []), to: Assertions
@doc """
Asserts no module in `subject` exports the given function.
"""
defdelegate should_not_export(subject, fun_name, arity, opts \\ []), to: Assertions
@doc """
Asserts all modules in `subject` have at least one public function whose name
matches the given glob pattern.
"""
defdelegate should_have_public_functions_matching(subject, pattern, opts \\ []), to: Assertions
@doc """
Asserts no module in `subject` has public functions whose names match the pattern.
"""
defdelegate should_not_have_public_functions_matching(subject, pattern, opts \\ []),
to: Assertions
# ------------------------------------------------------------------
# Architecture pattern DSL
# ------------------------------------------------------------------
@doc """
Defines an ordered list of architecture layers (top to bottom).
## Example
define_layers(
web: "MyApp.Web.**",
context: "MyApp.Context.**",
repo: "MyApp.Repo.**"
)
|> enforce_direction()
"""
@spec define_layers(keyword(), keyword()) :: Layers.t()
def define_layers(layer_defs, opts \\ []) do
layer_defs
|> Layers.define_layers()
|> maybe_layer_for_app(opts)
end
@doc """
Defines an onion/hexagonal architecture (innermost layer first).
## Example
define_onion(
domain: "MyApp.Domain.**",
application: "MyApp.Application.**",
adapters: "MyApp.Adapters.**"
)
|> enforce_onion_rules()
"""
@spec define_onion(keyword(), keyword()) :: Layers.t()
def define_onion(layer_defs, opts \\ []) do
layer_defs
|> Layers.define_onion()
|> maybe_layer_for_app(opts)
end
@doc """
Defines bounded-context slices for a modulith architecture.
## Example
define_slices(
orders: "MyApp.Orders",
inventory: "MyApp.Inventory",
accounts: "MyApp.Accounts"
)
|> allow_dependency(:orders, :accounts)
|> enforce_isolation()
"""
@spec define_slices(keyword(), keyword()) :: Modulith.t()
def define_slices(slice_defs, opts \\ []) do
slice_defs
|> Modulith.define_slices()
|> maybe_modulith_for_app(opts)
end
@doc """
Defines modulith slices by capturing one namespace segment from existing modules.
Use `(*)` to mark the captured segment:
define_slices_by("MyApp.(*)", app: :my_app)
"""
@spec define_slices_by(String.t(), keyword()) :: Modulith.t()
defdelegate define_slices_by(pattern, opts \\ []), to: Modulith
@doc """
Allows `from_slice` to call the public API of `to_slice`.
"""
@spec allow_dependency(Modulith.t(), atom(), atom()) :: Modulith.t()
defdelegate allow_dependency(modulith, from_slice, to_slice), to: Modulith
@doc """
Enforces bounded-context isolation (see `ArchTest.Modulith`).
"""
@spec enforce_isolation(Modulith.t(), keyword()) :: :ok
def enforce_isolation(modulith, opts \\ []) do
modulith |> maybe_modulith_for_app(opts) |> Modulith.enforce_isolation(opts)
end
@doc """
Asserts that slices have absolutely no cross-slice dependencies (strict isolation).
"""
@spec should_not_depend_on_each_other(Modulith.t(), keyword()) :: :ok
def should_not_depend_on_each_other(modulith, opts \\ []) do
modulith |> maybe_modulith_for_app(opts) |> Modulith.should_not_depend_on_each_other(opts)
end
@doc "Asserts every module under namespace_pattern belongs to a declared slice."
def all_modules_covered_by(modulith, namespace_pattern, opts \\ []) do
modulith
|> maybe_modulith_for_app(opts)
|> Modulith.all_modules_covered_by(namespace_pattern, opts)
end
@doc """
Enforces layer direction (each layer may only depend on layers below it).
"""
@spec enforce_direction(Layers.t(), keyword()) :: :ok
def enforce_direction(layers, opts \\ []) do
layers |> maybe_layer_for_app(opts) |> Layers.enforce_direction(opts)
end
@doc """
Enforces onion architecture rules (dependencies point only inward).
"""
@spec enforce_onion_rules(Layers.t(), keyword()) :: :ok
def enforce_onion_rules(layers, opts \\ []) do
layers |> maybe_layer_for_app(opts) |> Layers.enforce_onion_rules(opts)
end
@doc "Adds a custom layer allowlist rule."
defdelegate layer_may_only_depend_on(layers, layer, allowed_layers), to: Layers
@doc "Adds a custom layer denylist rule."
defdelegate layer_may_not_depend_on(layers, layer, forbidden_layers), to: Layers
@doc "Allows a specific dependency from one layer to another."
defdelegate allow_layer_dependency(layers, from_layer, to_layer), to: Layers
defp maybe_layer_for_app(%Layers{} = layers, opts) do
case Keyword.get(opts, :app) do
nil -> layers
:all -> layers
app -> Layers.for_app(layers, app)
end
end
defp maybe_modulith_for_app(%Modulith{} = modulith, opts) do
case Keyword.get(opts, :app) do
nil -> modulith
:all -> modulith
app -> Modulith.for_app(modulith, app)
end
end
# ------------------------------------------------------------------
# Public helper used by Layers/Modulith (not part of user API)
# ------------------------------------------------------------------
@doc false
def assert_no_violations_public(violations, rule_name) do
Assertions.assert_no_violations_public(violations, rule_name)
end
end