# Frontier
[![Hex.pm][hex-badge]][hex] [![Docs][docs-badge]][docs] [![CI][ci-badge]][ci]
> **Note:** This codebase was 99% AI-written, with a few guidelines to ensure
> maintainability, code structure, and quality.
>
> I created Frontier because I wanted guardrails for my AI-led projects with
> minimal oversight. [Boundary][boundary] is a great
> library, but it requires ongoing maintenance of boundary declarations. Every
> context, schema and dependency needs manual config updates. In an AI-managed
> codebase, that maintenance doesn't happen reliably.
>
> To avoid this maintenance burden, I made this so that I can enforce contexts
> and nothing else, with external deps and schemas publicly available.
>
> This repo is public out of convenience. Contributions are very welcome, but I
> won't be maintaining it too actively beyond its original purpose: ensure AI
> doesn't access modules it shouldn't, where it shouldn't, without having to
> maintain rules.
>
> **Boundary is a better answer for most projects.** It has a well-thought-out
> philosophy and enforces boundaries rigorously. My only philosophy was "prevent
> the AI from writing shitty code". Mine is a particular AI-led use case and I
> optimised for that. You're probably not in the same boat, consider using
> Boundary instead.
Frontier enforces architectural boundaries using convention-based defaults.
Context roots are public, schemas are auto-exported, and internal modules are
private. You configure exceptions, rather than explicit rules.
## Installation
Add `frontier` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[{:frontier, "~> 0.1.2"}]
end
```
Add the compiler to your project:
```elixir
def project do
[
compilers: [:frontier] ++ Mix.compilers(),
# ...
]
end
```
## Principles
1. **Convention over configuration.** Elixir projects already have a natural
architecture - contexts are public APIs, schemas are shared data structures,
internal modules are implementation details. Frontier enforces what your code
already implies.
2. **Open by default.** A new context should work immediately without touching
other files. Restrictions are opt-in, applied only where you want to enforce
specific rules.
3. **Zero maintenance for the common case.** Schemas are auto-detected. Context
roots are auto-public. Top-level utility modules are free. You only configure
deviations from the norm.
4. **Guardrails, not gates.** Frontier is designed for codebases where AI agents
make most of the changes. The rules should be discoverable from conventions.
Avoid having an agent read through files to know what's allowed.
5. **Actionable feedback.** Every warning tells you exactly how to fix it, with
both the "right" fix and the escape hatch. No cryptic error codes.
## How's This Different from Boundary?
Boundary requires a lot of maintenance. Every new context needs `deps:`
declarations in every consumer. Every schema needs to be manually exported.
Every utility module needs workarounds. The config grows with the codebase and
becomes another thing to keep in sync.
Frontier adheres to Elixir conventions and ensures guardrails are in place with
minimal oversight - particularly for AI-managed codebases where no one is
manually maintaining boundary declarations.
### 1. Adding a new context
**Boundary:** every consumer must declare the new dependency.
```elixir
# You create a new Notifications context...
defmodule MyApp.Notifications do
use Boundary, deps: [], exports: []
end
# ...then update EVERY context that needs it:
defmodule MyAppWeb do
use Boundary, deps: [MyApp, MyApp.Notifications], exports: [Endpoint]
# ^^^^^^^^^^^^^^^^^^^^^^ add here
end
defmodule MyApp.Billing do
use Boundary, deps: [MyApp, MyApp.Notifications], exports: []
# ^^^^^^^^^^^^^^^^^^^^^^ and here
end
# Forget one? Silent architectural violation until you check.
```
**Frontier:** just declare the context. Everyone can use it.
```elixir
defmodule MyApp.Notifications do
use Frontier
end
# That's it. No changes anywhere else.
# If Billing shouldn't call it, restrict Billing, not Notifications.
```
### 2. Schemas
**Boundary:** you must manually export every schema.
```elixir
defmodule MyApp do
use Boundary, deps: [], exports: [User, Subscription, Invoice, Order, Product]
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# grows with every new schema
end
```
**Frontier:** schemas are auto-detected and exported. No config needed.
```elixir
defmodule MyApp.Accounts do
use Frontier
end
# MyApp.Accounts.User is auto-exported because it defines __schema__/1.
# Add a new schema? It's automatically public. Zero maintenance.
# Need schemas to stay internal? Opt out per-context:
defmodule MyApp.Billing do
use Frontier, public_schemas: false
# Billing schemas are now internal — not accessible from other contexts
end
# Or allow only specific schemas:
defmodule MyApp.Inventory do
use Frontier, public_schemas: [Product]
# Only MyApp.Inventory.Product is auto-exported
# Other schemas like MyApp.Inventory.Warehouse stay internal
end
```
### 3. Utility modules
**Boundary:** utility modules like `Result` or `AuthToken` need workarounds.
```elixir
# Option A: Add it as a dep to every boundary (tedious)
defmodule MyApp.Auth do
use Boundary, deps: [MyApp.Result], exports: []
end
defmodule MyApp.Billing do
use Boundary, deps: [MyApp.Result], exports: []
end
# ...repeat for every context that uses Result
```
**Frontier:** top-level modules (not under any context) are free by default.
```elixir
# lib/result.ex - not under any context namespace, accessible everywhere.
# No config needed. No Frontier declaration required.
# For modules inside a context that need global access:
defmodule MyApp do
use Frontier, globals: [Auth.AuthToken]
end
# Auth.AuthToken is now accessible everywhere, even from contexts
# that can't reach Auth.
```
### The core difference
| | Boundary | Frontier |
| ------------------- | ------------------------------- | ----------------------------------- |
| **Default** | Closed - must declare every dep | Open - restrict only where needed |
| **Schemas** | Manual exports | Auto-detected (configurable) |
| **Utility modules** | Manual dep in every consumer | Top-level = free, or use `globals:` |
| **New context** | Update every consumer's deps | Just `use Frontier` |
| **Maintenance** | Grows with codebase | Convention-based, minimal upkeep |
| **AI-friendly** | Agent must know full dep graph | Agent follows conventions |
## Real-World Example
```
lib/
result.ex # top-level - free, no Frontier needed
result/
type.ex # submodule of top-level - also free
my_app.ex # use Frontier, globals: [Auth.AuthToken]
my_app/
application.ex # use Application - god mode, can call anything
auth.ex # use Frontier (unrestricted)
auth/
auth_token.ex # global - plain struct, accessible everywhere
guardian.ex # internal to Auth
accounts.ex # use Frontier (unrestricted)
accounts/
user.ex # Ecto schema - auto-exported
user_notifier.ex # internal
billing.ex # use Frontier, reaches: [MyApp.Accounts], externals: [:stripe]
billing/
subscription.ex # Ecto schema - auto-exported
payment_worker.ex # internal
notifications.ex # use Frontier, reaches: [MyApp.Auth]
notifications/
sender.ex # internal
my_app_web.ex # use Frontier, reaches: [MyApp.Accounts, MyApp.Billing]
my_app_web/
account_helpers.ex # use Frontier, belongs_to: MyApp.Accounts
```
```elixir
# Root - declares globals
defmodule MyApp do
use Frontier,
# Auth.AuthToken becomes publicly accessible regardless of
# reaches: or any other restriction
globals: [Auth.AuthToken],
# MyApp.Seeds is excluded from all boundary checks
ignore: [Seeds]
end
# Unrestricted context - can reach any other context's public API
# but not their internal modules
defmodule MyApp.Accounts do
use Frontier
end
# Restricted context - can only reach Accounts, can only use :stripe
defmodule MyApp.Billing do
use Frontier,
reaches: [MyApp.Accounts],
externals: [:stripe]
end
# Reclassified - lives under Web namespace but belongs to Accounts
defmodule MyAppWeb.AccountHelpers do
use Frontier, belongs_to: MyApp.Accounts
end
```
**What gets enforced at compile time:**
```
✅ Anywhere → Result.ok(value) top-level, no Frontier
✅ Anywhere → %MyApp.Auth.AuthToken{} global
✅ Billing → MyApp.Accounts.get_user!() context root, in reaches
✅ Billing → %MyApp.Accounts.User{} schema, in reaches
❌ Billing → MyApp.Accounts.UserNotifier.deliver() internal module
❌ Billing → MyApp.Notifications.notify() not in reaches
✅ Notifications → %MyApp.Auth.AuthToken{} global, bypasses reaches
❌ Notifications → MyApp.Auth.Guardian.decode() internal to Auth
❌ Notifications → MyApp.Accounts.get_user!() not in reaches
✅ AccountHelpers → MyApp.Accounts.UserNotifier.deliver() reclassified to Accounts
❌ AccountHelpers → MyApp.Billing.PaymentWorker.perform() different context, internal
✅ Application → MyApp.Billing.PaymentWorker.start_link() application module, god mode
```
## Options Reference
### Root module
```elixir
defmodule MyApp do
use Frontier,
globals: [Auth.AuthToken], # modules accessible everywhere (including submodules)
ignore: [SeedHelper], # modules excluded from all checks
public_schemas: true # auto-export schemas (default: true, false to disable, or list)
end
```
### Context modules
```elixir
defmodule MyApp.Billing do
use Frontier,
reaches: [MyApp.Accounts], # restrict which contexts this one can call (nil = unrestricted)
externals: [:stripe], # restrict which hex packages this one can use (nil = unrestricted)
exports: [InvoiceGenerator], # expose internal non-schema modules (or :all)
skip_violations: [MyApp.Accounts.UserNotifier], # known violations to silence
enforce: [callers: true, calls: true], # toggle enforcement per direction
public_schemas: false # true/false or a list of schemas to allow public access (overrides root)
end
```
### Per-module directives
```elixir
# Exclude a module from all checks
defmodule MyApp.WeirdThing do
use Frontier, ignore: true
end
# Reclassify a module into a different context
defmodule MyAppWeb.AccountHelpers do
use Frontier, belongs_to: MyApp.Accounts
end
```
## Module Classification
Frontier classifies modules in priority order:
1. **Ignored** - `ignore: true` or listed in root `ignore:`
2. **Global** - listed in root `globals:` (includes submodules)
3. **Reclassified** - has `belongs_to:`
4. **Context root** - has `use Frontier`
5. **Schema** - defines `__schema__/1` (Ecto schemas), auto-exported unless `public_schemas: false`
6. **Exported** - listed in context's `exports:`
7. **Internal** - under a context namespace (private by default)
8. **Unowned** - not under any context (free, no restrictions)
Modules that `use Application` are automatically exempt from all boundary
checks as callers — they can reach any module, including internals, since they
wire up supervision trees during startup.
## Actionable Warnings
Frontier warnings tell you exactly how to fix them:
```
warning: MyApp.Accounts.UserNotifier is internal to MyApp.Accounts
To allow this, either:
- Export it: use Frontier, exports: [UserNotifier] (in MyApp.Accounts)
- Skip it: use Frontier, skip_violations: [MyApp.Accounts.UserNotifier] (in MyApp.Billing)
lib/my_app/billing/invoice_generator.ex:42
```
```
warning: MyApp.Billing reaches MyApp.Notifications, but only [MyApp.Accounts] is declared
To allow this, either:
- Add it: use Frontier, reaches: [MyApp.Accounts, MyApp.Notifications] (in MyApp.Billing)
- Skip it: use Frontier, skip_violations: [MyApp.Notifications] (in MyApp.Billing)
lib/my_app/billing/invoice_generator.ex:58
```
## Mix Tasks
### `mix frontier.spec`
Print a text summary of all boundaries:
```
$ mix frontier.spec
MyApp (root)
globals: MyApp.Auth.AuthToken
MyApp.Accounts
exports: MyApp.Accounts.User (schema)
internals: MyApp.Accounts.UserNotifier
reaches: (unrestricted)
externals: (unrestricted)
MyApp.Billing
exports: MyApp.Billing.Subscription (schema)
internals: MyApp.Billing.PaymentWorker
reaches: MyApp.Accounts
externals: :stripe
```
### `mix frontier.visualize`
Generate a dependency graph:
```
$ mix frontier.visualize # outputs frontier.dot
$ mix frontier.visualize --format png # outputs frontier.png (requires graphviz)
$ mix frontier.visualize --output ./docs # custom output directory
```
![Example frontier graph][graph]
## Migrating from Boundary
| Boundary | Frontier |
| ------------------------------------ | --------------------------------------------- |
| `deps: [OtherContext]` | Not needed (open by default) |
| `deps: [OtherContext]` (to restrict) | `reaches: [OtherContext]` |
| `exports: [Module]` | `exports: [Module]` (same, but rarely needed) |
| Schemas in `exports:` | Auto-detected, no config needed |
| `dirty_xrefs: [Module]` | `skip_violations: [Module]` |
| `check: [in: false]` | `enforce: [callers: false]` |
| `check: [out: false]` | `enforce: [calls: false]` |
| `classify_to: Context` | `belongs_to: Context` |
| `type: :strict` (for externals) | `externals: [:app1, :app2]` |
| No global deps concept | `globals: [Module]` in root |
| No auto schema detection | Schemas auto-exported |
## License
This software is distributed under [The Unlicense][license]. I don't give a
shit, knock yourself out.
[boundary]: https://github.com/sasa1977/boundary
[graph]: docs/graph.png
[license]: LICENSE
[hex]: https://hex.pm/packages/frontier
[hex-badge]: https://img.shields.io/hexpm/v/frontier.svg?style=flat-square
[ci]: https://github.com/frm/frontier/actions/workflows/ci.yml
[ci-badge]: https://img.shields.io/github/actions/workflow/status/frm/frontier/ci.yml?branch=main&style=flat-square&label=CI
[docs]: https://hexdocs.pm/frontier
[docs-badge]: https://img.shields.io/badge/docs-hexdocs-blue.svg?style=flat-square