ArchTest.Modulith (ArchTest v0.2.0)

Copy Markdown View Source

Modulith / bounded context isolation enforcement.

Define named slices (bounded contexts) and enforce that internals of one slice are not accessed by other slices. Only the public API module (the root context module) may be called cross-slice.

Slice structure

Given define_slices(orders: "MyApp.Orders", ...):

  • Public API: MyApp.Orders (the exact root module)
  • Internals: MyApp.Orders.* and deeper (sub-modules)

Example

define_slices(
  orders:    "MyApp.Orders",
  inventory: "MyApp.Inventory",
  accounts:  "MyApp.Accounts"
)
|> allow_dependency(:orders, :accounts)
|> enforce_isolation()

Summary

Functions

Asserts that every module under namespace_pattern belongs to a declared slice.

Permits from_slice to call the public API (root module) of to_slice.

Defines named slices (bounded contexts).

Enforces that slice internals are not accessed by other slices.

Sets the OTP application to introspect (default: :all).

Asserts that there are no circular dependencies between slices.

Asserts that slices have absolutely no cross-slice dependencies.

Types

slice_name()

@type slice_name() :: atom()

t()

@type t() :: %ArchTest.Modulith{
  allowed_deps: [{slice_name(), slice_name()}],
  app: atom() | nil,
  slices: [{slice_name(), String.t()}]
}

Functions

all_modules_covered_by(m, namespace_pattern, opts \\ [])

@spec all_modules_covered_by(t(), String.t(), keyword()) :: :ok

Asserts that every module under namespace_pattern belongs to a declared slice.

Any module that does not match any slice's namespace is a violation. This prevents new modules from silently escaping slice coverage.

Options

  • :except — list of glob patterns to exclude from the check
  • :graph — pre-built dependency graph (useful for testing, avoids xref)

Example

define_slices(auth: "Vireale.Auth", feeds: "Vireale.Feeds")
|> all_modules_covered_by("Vireale.**",
     except: ["Vireale.Application", "Vireale.Repo"])

allow_dependency(m, from_slice, to_slice)

@spec allow_dependency(t(), slice_name(), slice_name()) :: t()

Permits from_slice to call the public API (root module) of to_slice.

Without this, cross-slice calls to internal sub-modules are always violations, and calls to other slices' public root modules are also violations by default.

With allow_dependency(:orders, :accounts), MyApp.Orders.* may call MyApp.Accounts (but not MyApp.Accounts.Repo, etc.).

define_slices(slice_defs)

@spec define_slices(keyword()) :: t()

Defines named slices (bounded contexts).

Accepts a keyword list of slice_name: "RootNamespace" pairs.

enforce_isolation(m)

@spec enforce_isolation(t()) :: :ok

Enforces that slice internals are not accessed by other slices.

Rules:

  1. Module A (in slice X) calling Module B (in slice Y's internals) is a violation, unless allow_dependency(X, Y) has been declared.
  2. Even with allow_dependency(X, Y), only the public root module of Y may be called (not sub-modules).

for_app(m, app)

@spec for_app(t(), atom()) :: t()

Sets the OTP application to introspect (default: :all).

should_be_free_of_cycles(m)

@spec should_be_free_of_cycles(t()) :: :ok

Asserts that there are no circular dependencies between slices.

Each slice is treated as a single node; a cycle exists when slice A depends on slice B which (transitively) depends on slice A.

should_not_depend_on_each_other(m)

@spec should_not_depend_on_each_other(t()) :: :ok

Asserts that slices have absolutely no cross-slice dependencies.

This is stricter than enforce_isolation/1 — not even the public root module of another slice may be called. Use this for completely independent bounded contexts.

Example

define_slices(
  orders:    "MyApp.Orders",
  inventory: "MyApp.Inventory",
  accounts:  "MyApp.Accounts"
)
|> should_not_depend_on_each_other()