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 module —
MyApp.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.Ordersitself — public APIMyApp.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()
endenforce_isolation/1 forbids two things:
- Any module calling internals of another slice (
MyApp.Orders.CheckoutcallingMyApp.Inventory.Repo) - 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()
endallow_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()
endshould_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()
endA cycle (:orders → :inventory → :orders) means the two contexts aren't really separate — they should be merged or redesigned.
Recommended test structure
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
endLayered 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()
endNext steps
- Layered Architecture — enforce dependency direction within a context
- Freezing — when you have existing violations to baseline before enforcing