Modulith / Bounded-Context Rules

Copy Markdown View Source

A modulith is a monolith with well-defined internal boundaries — bounded contexts that own their data and expose a clean public API, but run in the same process and share the same database. ArchTest's modulith support enforces those boundaries at compile time so they can't erode silently.

Further reading: Modular Monolith: A Primer (Kamil Grzybek)


The core idea

Each bounded context (called a slice) has:

  • A public root moduleMyApp.Orders — the only entry point other contexts may call
  • Internals — everything under it (MyApp.Orders.Checkout, MyApp.Orders.Schema, etc.) — off-limits to other contexts

This mirrors what the Boundary hex library does at compile time, but as ExUnit tests evaluated against bytecode.


1. Define slices

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

Each value is the root namespace of a context. ArchTest considers:

  • MyApp.Orders itself — public API
  • MyApp.Orders.* and deeper — internal implementation

2. Enforce isolation

test "bounded contexts don't reach into each other's internals" do
  define_slices(
    orders:    "MyApp.Orders",
    inventory: "MyApp.Inventory",
    accounts:  "MyApp.Accounts"
  )
  |> enforce_isolation()
end

enforce_isolation/1 forbids two things:

  1. Any module calling internals of another slice (MyApp.Orders.Checkout calling MyApp.Inventory.Repo)
  2. Any module calling another slice's public root without an explicit allow_dependency

What a violation looks like

Architecture rule violated (enforce_isolation)  2 violation(s):

  MyApp.Orders.Checkout  MyApp.Inventory.Repo
    :orders must not access internals of :inventory.
    Only MyApp.Inventory (public API) is accessible.

  MyApp.Orders.Service  MyApp.Inventory.Schema
    :orders must not access internals of :inventory.
    Only MyApp.Inventory (public API) is accessible.

3. Allow cross-context dependencies

Real applications need contexts to talk to each other. Use allow_dependency/3 to grant that access explicitly:

test "bounded contexts are isolated with permitted dependencies" do
  define_slices(
    orders:    "MyApp.Orders",
    inventory: "MyApp.Inventory",
    accounts:  "MyApp.Accounts"
  )
  |> allow_dependency(:orders, :accounts)      # orders may call MyApp.Accounts
  |> allow_dependency(:orders, :inventory)     # orders may call MyApp.Inventory
  |> enforce_isolation()
end

allow_dependency(:orders, :accounts) permits :orders to call MyApp.Accounts — the public root only. It still cannot touch MyApp.Accounts.User, MyApp.Accounts.Repo, or any other internal.

This makes the allowed dependency graph explicit and visible in version control.


4. Strict mode — zero cross-context dependencies

When contexts should be completely independent (e.g., plugin-style extensions, or core vs. plugins):

test "plugins don't depend on each other" do
  define_slices(
    core:     "MyApp.Core",
    billing:  "MyApp.Billing",
    reporting: "MyApp.Reporting"
  )
  |> should_not_depend_on_each_other()
end

should_not_depend_on_each_other/1 fails if any module in one slice calls any module in any other slice — public root included.


5. Cycle detection across contexts

Even with allow_dependency granted, you shouldn't have cycles between contexts:

test "no circular context dependencies" do
  define_slices(
    orders:    "MyApp.Orders",
    inventory: "MyApp.Inventory",
    accounts:  "MyApp.Accounts"
  )
  |> should_be_free_of_cycles()
end

A cycle (:orders:inventory:orders) means the two contexts aren't really separate — they should be merged or redesigned.


Combine isolation with cycle detection in a single test file:

defmodule MyApp.BoundedContextTest do
  use ExUnit.Case
  use ArchTest

  @slices [
    orders:    "MyApp.Orders",
    inventory: "MyApp.Inventory",
    accounts:  "MyApp.Accounts",
    notifications: "MyApp.Notifications"
  ]

  test "contexts don't access each other's internals" do
    define_slices(@slices)
    |> allow_dependency(:orders, :accounts)
    |> allow_dependency(:orders, :inventory)
    |> allow_dependency(:notifications, :accounts)
    |> enforce_isolation()
  end

  test "no cycles between contexts" do
    define_slices(@slices) |> should_be_free_of_cycles()
  end
end

Layered architecture inside a modulith

define_slices and define_layers compose naturally. Run one test for cross-context isolation and another for intra-context layer direction:

test "cross-context isolation" do
  define_slices(orders: "MyApp.Orders", accounts: "MyApp.Accounts")
  |> allow_dependency(:orders, :accounts)
  |> enforce_isolation()
end

test "orders context internal layers" do
  define_layers(
    web:     "MyApp.Orders.Controllers.**",
    context: "MyApp.Orders.**",
    repo:    "MyApp.Orders.Repo.**"
  )
  |> enforce_direction()
end

Next steps

  • Layered Architecture — enforce dependency direction within a context
  • Freezing — when you have existing violations to baseline before enforcing