guides/modulith-rules.md

# Modulith / Bounded-Context Rules

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)](https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer)

---

## 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

```elixir
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

```elixir
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:

```elixir
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):

```elixir
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:

```elixir
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.

---

## Recommended test structure

Combine isolation with cycle detection in a single test file:

```elixir
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:

```elixir
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](layered-architecture.md) — enforce dependency direction within a context
- [Freezing](freezing.md) — when you have existing violations to baseline before enforcing