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 in ExUnit from compiled bytecode 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
If your contexts follow a regular namespace shape, discover them with one captured segment:
define_slices_by("MyApp.(*)", app: :my_app,
except: ["MyApp.Application", "MyApp.Repo"])
|> enforce_isolation()The capture can appear after a wildcard, for example "MyApp.*.(*)".
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, app: :my_app
@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.OrdersWeb.**",
service: "MyApp.Orders.Services.**",
repo: "MyApp.Orders.Repos.**"
)
|> enforce_direction()
endNext steps
- Layered Architecture — enforce dependency direction within a context
- Freezing — when you have existing violations to baseline before enforcing