Frontier

Hex.pm][hex-badge]][hex] [![Docs][docs-badge] [ CI

Note: I created Frontier because I wanted guardrails for my AI-led projects with minimal oversight.

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. I wanted to avoid this maintenance burden by enforcing contexts and nothing else.

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".

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.

Open a context and slap:

defmodule MyApp.Accounts do
  use Frontier
end

That's it. Schemas are publicly available, external deps as well. But your module now can't access other contexts' internal modules and vice-versa.

Installation

Add frontier to your list of dependencies in mix.exs:

def deps do
  [{:frontier, "~> 0.2.0"}]
end

Then choose how to run checks:

Option A: Compile-time (default) — warnings on every compile:

def project do
  [
    compilers: [:frontier] ++ Mix.compilers(),
    # ...
  ]
end

Option B: On-demand — run checks manually or in CI:

No compiler setup needed. Use this when you don't want checks on every recompile but still want to verify boundaries before merging.

$ mix frontier.check
$ mix frontier.check --warnings-as-errors  # exit 1 on violations

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.

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

defmodule MyApp.Notifications do
  use Frontier
end

# That's it. No changes anywhere else.
# If Billing shouldn't call it, restrict it.

2. Schemas

Boundary: you must manually export every schema.

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.

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.

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

# 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

# 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

Optional, used for global configuration.

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

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

# 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

Hierarchical configuration

Frontier contexts can be nested, and configuration cascades downward. This gives you granular control without touching parent config.

defmodule MyApp.Accounts do
  use Frontier
  # All schemas under Accounts are public by default
  # MyApp.Accounts.User, MyApp.Accounts.Team — both auto-exported
end

defmodule MyApp.Accounts.Security do
  use Frontier, public_schemas: false
  # Schemas under Security are private — applies downward
  # MyApp.Accounts.Security.Session, MyApp.Accounts.Security.Token — internal
  # But MyApp.Accounts.User is still public — siblings are unaffected
end

No parent needs to know about the child's restrictions. The child declares its own rules and they apply to everything underneath it.

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

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.

Mix Tasks

mix frontier.check

Run boundary checks on demand (without adding :frontier to compilers):

$ mix frontier.check                       # report violations
$ mix frontier.check --warnings-as-errors  # exit 1 on violations (for CI)

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

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: Contextbelongs_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. I don't give a shit, knock yourself out.