brett

Scaling Elixir Applications with Context Boundaries

Building large-scale Elixir applications presents unique challenges. As codebases grow beyond the initial team of 3-5 developers, the lack of enforced architectural boundaries becomes a significant bottleneck. Teams spend more time reading and understanding code than writing new features, and the dynamic nature of Elixir makes code navigation increasingly difficult without proper structure.

Lately I’ve been exploring how context boundaries, enforced by compile-time checks, can transform unwieldy monoliths into well-organized, maintainable applications that can scale to teams of 20+ engineers.

The Scaling Problem

Why Large Elixir Codebases Become Unwieldy

When Elixir applications start small, the flexibility of the language is a tremendous asset. You can quickly prototype features, leverage pattern matching, and build robust systems with relatively few lines of code. However, as applications grow, several problems emerge:

  1. Lack of Enforced Structure: Phoenix contexts provide organizational guidance, but there’s no compile-time enforcement preventing modules from calling deep into other contexts’ internals. An organization module might directly access user database schemas, creating tight coupling that makes refactoring dangerous.
  2. Inconsistent Organization Patterns: Different teams or developers organize code differently. Some put business logic in controllers, others in contexts, and still others in custom service modules. These inconsistencies cause immense mental overhead for teams I’ve been on.
  3. Circular Dependencies: Without boundaries, it’s easy to create circular dependencies that slow compilation times in Elixir and make the code harder to reason about. Module A calls Module B, which calls Module C, which calls Module A. This creates a tangled web and slow CI.
  4. Testing Complexity: When modules are tightly coupled, testing becomes complex. You end up testing through the web API layer because it’s the only stable interface, leading to slow, brittle tests.

Reading vs. Writing Code

In mature applications or when onboarding in a new team, I find myself spending more time reading code than writing it. This ratio becomes problematic when functions are scattered across the codebase with no clear ownership and business logic is mixed with infrastructure concerns.

One approach to dealing with readability is to address organizational patterns for code, such as well-defined and enforceable “boundaries”.

The Boundary Library

Boundary by Saša Jurić provides compile-time warnings of architectural boundaries in Elixir applications. It transforms organizational guidelines into compiler checks, catching boundary violations before they reach production.

mix clean && \  # Cleaning prevents false positives
  mix compile --warnings-as-errors
# count boundary errors
mix compile | grep -E 'warning:.*(boundary|forbidden reference)'  | wc -l

How Boundary Works

The library operates through registering module attributes that define named groups of modules called boundaries. These boundaries can export inner modules from within a boundary and make them publicly accessible. Boundaries have dependencies from other boundary exports.

Here’s a simple example:

defmodule MyApp.Catalog do
  @moduledoc """
  This boundary can use MyApp.Users
  and exports Products and Categories modules
  """
  use Boundary,
    deps: [
      MyApp.Users
    ],
    exports: [
      Products,     # MyApp.Catalog.Products
      Categories,   # MyApp.Catalog.Categories
    ]
end

defmodule MyApp.Catalog.Products do
  @moduledoc "This module is exported and can be used by other boundaries"
end

defmodule MyApp.Catalog.Internal.PriceCalculator do
  @moduledoc "This module is internal and cannot be used outside the boundary"
end

If module functions in another boundary try to call MyApp.Catalog.Internal.PriceCalculator, the compiler will raise an error.

Setting Up Boundary

Add Boundary to your mix.exs:

defp deps do
  [
    {:boundary, "~> 0.10", runtime: false},
  ]
end

The runtime: false option is important. Boundary is a compile-time tool and doesn’t need to be included in your production release.

Context Boundaries

On my Elixir team, we’ve extended the ideas from the Boundary library into an opinionated design pattern we call “Context Boundaries”.

What Are Context Boundaries?

Context boundaries are architectural constraints that group related functionality together and define explicit interfaces for interaction between different parts of your application. Think of them as microservices within a monolith. Each boundary owns a specific business domain and exposes a well-defined API.

In an e-commerce application, you might have top-level boundaries like:

lib/
└── my_app/
    ├─ billing/     # Payment processing, invoicing, subscriptions
    ├─ catalog/     # Product management, categories, pricing
    ├─ orders/      # Order processing, fulfillment, tracking  
    ├─ users/       # Authentication, authorization, user profiles
    ├─ billing.ex
    ├─ catalog.ex
    ├─ orders.ex
    └─ users.ex

Each boundary would have its own schemas, business logic, and database concerns, but could only interact with other boundaries through their public interfaces defined in the outer context file.

In larger organizations, boundaries naturally form around teams and interfaces would likely be used by other teams, so design them thoughtfully.

Benefits of Context Boundaries

  1. Reduced Cognitive Load: Developers working on the catalog system don’t need to understand the intricacies of billing logic. They just need to know the public API. They should also spend less time figuring out where code lives.
  2. Parallel Development: Teams can shape themselves around business domains and can work independently on different boundaries without merge conflicts.
  3. Clearer Testing Strategy: Each boundary can be thoroughly tested in isolation, with integration tests covering the interactions between boundaries.
  4. Future Microservice Extraction: If you ever need to split off pieces of your monolith, boundaries provide natural seams for extraction.
  5. Documentation: The boundary definitions serve as living documentation of your system’s architecture. Boundaries are also good places to define actual documentation within a more narrow context for both humans and LLMs (e.g. CLAUDE.md).
  6. Improved Discoverability: Public interfaces make it clear what operations are available and how to use them.
  7. Performance: Boundaries should reduce compilation time by eliminating circular dependencies. Boundary checks happen at compile time, not runtime, so there’s no performance impact in production.

An honest implementation of context boundaries should turn spaghetti code into a well-organized tree with each domain branching out in a visually traceable call stack.

Implementing Context Boundaries

There are some additional steps to take to maximize boundaries for larger organizations.

Directory Structure

A well-organized boundary-based application follows a consistent directory structure.
lib/
  my_app/
    catalog/
      products/
        schemas/
          product.ex
        actions/
          list_products.ex
          create_product.ex
          update_product.ex
      categories/
        schemas/
          category.ex
        actions/
          list_categories.ex
      utils/
        price_calculator.ex
    catalog.ex

    orders/
      order_processing/
        schemas/
          order.ex
          line_item.ex
        actions/
          create_order.ex
          process_payment.ex
      fulfillment/
        actions/
          ship_order.ex
          track_shipment.ex
    orders.ex

    users/
      authentication/
        actions/
          login.ex
          register.ex
      profiles/
        schemas/
          user.ex
        actions/
          update_profile.ex
    users.ex

Context Interface Design

Each context exposes a clean public interface through its main module. Here’s how you might structure the Catalog context:

defmodule MyApp.Catalog do
  @moduledoc """
  Context for managing product catalog.

  Handles products, categories, pricing, and inventory.
  """

  @behaviour  MyApp.Catalog.Products.Actions.ListProducts
  @behaviour  MyApp.Catalog.Products.Actions.GetProduct
  @behaviour  MyApp.Catalog.Products.Actions.CreateProduct
  @behaviour  MyApp.Catalog.Products.Actions.UpdateProduct
  @behaviour  MyApp.Catalog.Categories.Actions.ListCategories
  @behaviour  MyApp.Catalog.Categories.Actions.CreateCategory

  use Boundary,
    deps: [MyApp.Users],
    exports: [Products.Schemas.Product, Categories.Schemas.Category]

  alias MyApp.Catalog.Products.Actions
  alias MyApp.Catalog.Categories.Actions, as: CategoryActions

  # Product operations
  @impl Actions.ListProducts
  defdelegate list_products(params \\ []), to: Actions.ListProducts, as: :list_products

  @impl Actions.GetProduct
  defdelegate get_product(id), to: Actions.GetProduct, as: :get_product

  @impl Actions.CreateProduct
  defdelegate create_product(attrs), to: Actions.CreateProduct, as: :create_product

  @impl Actions.UpdateProduct
  defdelegate update_product(product, attrs), to: Actions.UpdateProduct, as: :update_product

  # Category operations  
  @impl Actions.ListCategories
  defdelegate list_categories(), to: CategoryActions.ListCategories, as: :list_categories

  @impl Actions.CreateCategory
  defdelegate create_category(attrs), to: CategoryActions.CreateCategory, as: :create_category
end

This interface provides several benefits:

  1. Discoverability: All public operations are visible in one place
  2. Delegation: Implementation details are hidden in specialized modules
  3. Consistency: All contexts follow the same interface pattern
  4. Type Safety: You can use behaviors to ensure implementations match @callback interfaces
  5. Mockable: Behaviour definitions make each action easily mocked

Action Modules

Action modules contain the actual business logic and should follow a consistent pattern. They expose and implement a callback for their interface. At the org I work at now, we generally have one file contain only one action so that an action has one public function but can have unlimited private functions.

defmodule MyApp.Catalog.Products.Actions.CreateProduct do
  @moduledoc false

  @behaviour __MODULE__

  alias MyApp.Catalog.Products.Schemas.Product
  alias MyApp.Users
  alias MyApp.Repo

  @type params_t :: %{
    name: String.t(),
    description: String.t(),
    price: Decimal.t(),
    category_id: String.t(),
    created_by: String.t()
  }

  @type result_t :: {:ok, Product.t()} | {:error, Ecto.Changeset.t()}

  @doc """
  Creates a new product.
  """
  @callback create_product(params_t()) :: result_t()
  def create_product(params) do
    with {:ok, user} <- Users.get_user(params.created_by),
         :ok <- validate_permissions(user),
         {:ok, product} <- create_product(params) do
      {:ok, product}
    end
  end

  defp validate_permissions(user) do
    if Users.has_permission?(user, :create_products) do
      :ok
    else
      {:error, :unauthorized}
    end
  end

  defp create_product(params) do
    %Product{}
    |> Product.changeset(params)
    |> Repo.insert()
  end
end

The @behaviour __MODULE__ is a self-referencing behavior that replaces @spec with @callback to provide compile-time documentation of our action functions in our context API. Each action module is now a swappable backend that can be easily mocked in tests or refactored.

Key principles for action modules that we’ve implemented with our large Elixir monolith:

Schema Organization

Schemas should be pure data definitions with minimal logic and only pure functions:

defmodule MyApp.Catalog.Products.Schemas.Product do
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{
    id: String.t(),
    name: String.t(),
    description: String.t(),
    price: Decimal.t(),
    category_id: String.t(),
    inserted_at: DateTime.t(),
    updated_at: DateTime.t()
  }

  schema "products" do
    field :name, :string
    field :description, :string  
    field :price, :decimal

    belongs_to :category, MyApp.Catalog.Categories.Schemas.Category

    timestamps()
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [:name, :description, :price, :category_id])
    |> validate_required([:name, :price, :category_id])
    |> validate_number(:price, greater_than: 0)
    |> foreign_key_constraint(:category_id)
  end
end

Schemas are typically exported by boundaries since they’re part of the public interface. Other contexts will likely need to pattern match on them and pass them around. You may want to bulk export schemas in an inner boundary.

# lib/my_app/catalog/products/schemas.ex
defmodule MyApp.Catalog.Products.Schemas do
  @moduledoc """
  Product schemas boundary
  """
  use Boundary,
    deps: [
      MyApp.Catalog.Categories.Schemas
    ],
    exports: [
      Product   # Ecto schema definition
    ]
end

This is one example of how boundaries interface with each other.

Cross-Boundary Communication

Synchronous Communication

For synchronous operations where you need immediate results, call through the boundary’s public interface:

defmodule MyApp.Orders.Actions.CreateOrder do
  @moduledoc false

  @behaviour __MODULE__

  alias MyApp.Catalog
  alias MyApp.Orders.Schemas.Order
  alias MyApp.Users


  @callback create_order(map()) :: {:ok, Order.t()} | nil
  def create_order(params) do
    with {:ok, user} <- Users.get_user(params.user_id),
         {:ok, products} <- validate_products(params.line_items),
         {:ok, order} <- create_order(user, products, params) do
      {:ok, order}
    end
  end

  defp validate_products(line_items) do
    product_ids = Enum.map(line_items, & &1.product_id)

    # Call through Catalog boundary interface
    case Catalog.get_products_by_ids(product_ids) do
      {:ok, products} when length(products) == length(product_ids) ->
        {:ok, products}
      _ ->
        {:error, :invalid_products}
    end
  end
end

Asynchronous Communication

For side effects that don’t need immediate consistency, use event-driven communication:

defmodule MyApp.Orders.Actions.CompleteOrder do
  @moduledoc false

  @behaviour __MODULE__

  alias MyApp.EventBus
  alias MyApp.Orders.Schemas.Order

  @type complete_order(String.t()) :: {:ok, Order.t()}
  def complete_order(order_id) do
    with {:ok, order} <- get_order(order_id),
         {:ok, updated_order} <- mark_completed(order) do

      # Publish event for other contexts to react
      Phoenix.PubSub.broadcast(MyApp.PubSub, "order_completed:#{updated_order.user_id}", %{
        order_id: updated_order.id,
        user_id: updated_order.user_id,
        total: updated_order.total
      })

      {:ok, updated_order}
    end
  end
end

Web Layer Organization

The web layer should also define its own boundary in my_app_web.ex.

  deps: [
    Absinthe.Subscription,
    Absinthe,
    MyApp.Billing,
    MyApp.Catalog,
    MyApp.Orders,
    MyApp.Users,
    Phoenix,
  ],
  exports: [
    Endpoint
  ]

Keep controllers or resolvers thin. For example, a GraphQL endpoint’s resolver should only handle request-specific concerns. Similarly, controllers should delegate business logic to contexts.

Challenges

There are a few non-trivial challenges we encountered at our org with our implementation. The initial refactor was significant overhead since moving files around and function calls was so wide-spread. We were all hands on deck for a few weeks to make this work. Following our initial refactor, continuing education and discipline became a priority for us.

Database queries that span boundaries become more complex and may require boundary coupling, duplication or event-driven updates. We’ve elected to use a controlled coupling approach for interdependent schemas and joins. Each domain has a dedicated schemas module that acts as a child boundary that exports all of its schemas.

# lib/my_app/orders/order_processing/schemas.ex
  exports: [
    Order,      # Ecto schema definition
    LineItem    # Ecto schema definition
  ]
  deps: [
    MyApp.Orders.OrderProcessing.Schemas
  ]

We may eventually have to decouple some domains that need to become their own microservices. This needs to be balanced against architectural benefits.

Conclusion

The dynamic nature and loose conventions of Elixir makes it challenging to navigate large codebases. Context boundaries enforced through the Boundary library provide a practical path for scaling Elixir applications beyond small teams.

Whether you’re currently feeling the pain of a growing monolith or planning for future scale, context boundaries offer a pragmatic solution that preserves the benefits of monolithic deployment while providing the organizational clarity of business domains and microservices.