25m read
2 current readers
Tags: elixir, phoenix, architecture, domain-driven-design

Phoenix contexts are one of the most misunderstood features in the framework. Developers either ignore them entirely—dumping everything into a single module—or they over-engineer boundaries that create more friction than value. Both paths end the same way. A codebase that fights you at every turn.

The problem isn't contexts themselves. Most tutorials show you how to generate a context without explaining why you'd want one; context generators are training wheels. At some point, you need to understand the bicycle.


What Contexts Actually Solve

Phoenix contexts are the framework's take on bounded contexts from Domain-Driven Design. Eric Evans laid this out in his 2003 book: large systems inevitably contain multiple conceptual models.bounded-contexts A "user" in your billing system is not the same as a "user" in your authentication system. They share an identifier; their behaviors, attributes, and invariants are completely different.

Contexts draw explicit boundaries around these models. They define a public API for a specific domain and hide everything else behind it. When you call Accounts.create_user/1, you don't care whether it uses Ecto, talks to an external service, or writes to a flat file. You care about the contract. That's it.

This isn't about organizing files into folders; it's about defining seams where change can happen independently. A well-designed context boundary means you can rewrite the internals of your billing system without touching authentication. You can swap your payment processor without modifying order management logic. The boundaries buy you that freedom—but only if you draw them in the right places.

Phoenix generators create contexts by default because Chris McCord and the core team understood something that takes most developers years to learn: extracting boundaries later costs an order of magnitude more than establishing them early.extraction-cost The generators shipped with Phoenix 1.3, and they were controversial at the time.phoenix-1-3 People thought it was unnecessary ceremony. Those people were building small apps. The question isn't whether to use contexts. It's where to draw the lines.


Signs You Need to Split a Context

The initial context the generator creates is a starting point. Not a destination. As your application grows, you'll encounter signals that a context has exceeded its natural boundaries.

Size and Cognitive Load

When a context file exceeds 500 lines, something has gone wrong.cognitive-load Not because 500 is a magic number, but because a single module should represent a coherent concept that fits in your head. If you can't explain what Accounts does in one sentence, it's doing too much.

I've seen context files balloon to 2,000 lines. At that point, developers stop reading the module; they search for the function they need, make their change, and leave. No one maintains a mental model of the whole. The context becomes a dumping ground—a junk drawer that everyone opens but nobody organizes.

Coupling Between Unrelated Operations

Look at the function signatures in your context. If half of them take a %User{} struct and the other half take an %Organization{} struct, you've got two contexts masquerading as one. The tell is when changes to user-related functions force you to understand organization-related code. Or vice versa.

# Before: Mixed concerns in a single context
defmodule MyApp.Accounts do
  def create_user(attrs), do: # ...
  def update_user(user, attrs), do: # ...
  def create_organization(attrs), do: # ...
  def add_member(organization, user), do: # ...
  def update_billing_info(organization, attrs), do: # ...
  def list_invoices(organization), do: # ...
end

This module handles user management, organization management, membership, and billing. Each has distinct invariants; each changes at different frequencies. Billing logic changes when you switch payment providers. User logic changes when you add authentication methods. Coupling them means coordinating changes that have nothing to do with each other.

Team Boundaries

Conway's Law applies here.conways-law If two teams own different parts of a context, you will experience friction—pull request conflicts, unclear ownership, divergent conventions. Context boundaries should align with team boundaries when possible.

This doesn't mean one team per context. A single team can own multiple contexts. But multiple teams sharing one context? That invites chaos.

Different Data Lifecycles

Some data is transactional and changes constantly. Other data is reference data that changes rarely. When your context mixes both, you end up with awkward caching strategies and unclear consistency guarantees.

A product catalog and an order system have fundamentally different lifecycles. Products are created occasionally and read constantly; orders are created constantly and rarely modified after completion. Combining them in a single Commerce context obscures these differences—and the performance characteristics that follow from them.


Cross-Context Communication Patterns

Once you split contexts, they need to talk to each other. This is where many teams stumble; they either create tight coupling that defeats the purpose of separation, or they build event systems that add complexity without benefit.

Direct Function Calls

The simplest approach. Context A calls a public function on Context B. Appropriate when the dependency is clear and unidirectional.

defmodule MyApp.Orders do
  alias MyApp.Accounts
  alias MyApp.Inventory

  def create_order(user_id, items) do
    with {:ok, user} <- Accounts.get_user(user_id),
         :ok <- Inventory.reserve_items(items),
         {:ok, order} <- do_create_order(user, items) do
      {:ok, order}
    end
  end
end

The Orders context depends on Accounts and Inventory. It knows they exist; it calls their public functions. Don't overthink this. Direct calls aren't evil—they're the right choice when you need a synchronous, transactional operation that spans contexts and failure in the dependency should fail the entire operation.

Domain Events

When operations in one context should trigger reactions elsewhere without tight coupling, events are the right tool. The source context publishes what happened; interested contexts subscribe and react.

defmodule MyApp.Orders do
  alias MyApp.Events

  def complete_order(order) do
    with {:ok, order} <- do_complete_order(order) do
      Events.publish(%OrderCompleted{
        order_id: order.id,
        user_id: order.user_id,
        total: order.total
      })
      {:ok, order}
    end
  end
end

defmodule MyApp.Notifications do
  use MyApp.Events.Subscriber

  def handle_event(%OrderCompleted{} = event) do
    send_order_confirmation_email(event.user_id, event.order_id)
  end
end

defmodule MyApp.Analytics do
  use MyApp.Events.Subscriber

  def handle_event(%OrderCompleted{} = event) do
    track_purchase(event.user_id, event.total)
  end
end

Orders doesn't know about Notifications or Analytics. It publishes what happened. Other contexts decide how to react—and if a subscriber fails, the order still went through. Phoenix PubSub handles in-process events; for durability and cross-node delivery, Broadway with a message queue backend gives you stronger guarantees.pubsub-broadway

The trap here is reaching for events too early. If only one context cares about an event, you've added indirection for no reason. Direct calls are simpler; use them until you actually need the decoupling.

Shared Schemas: Handle With Care

Sometimes two contexts need to read the same data. The question is whether they should share an Ecto schema or define their own.

Shared schemas create coupling. When Context A and Context B both use %User{}, changes to that schema affect both. Sometimes acceptable. Sometimes not.

A useful heuristic: share schemas when both contexts need the same view of the data; define separate schemas when they need different views.

# Shared: Both contexts need the same user data
defmodule MyApp.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :email, :string
    field :name, :string
    field :hashed_password, :string
    timestamps()
  end
end

# Separate: Analytics needs a different view
defmodule MyApp.Analytics.UserProfile do
  use Ecto.Schema

  @primary_key false
  schema "users" do
    field :id, :integer
    field :created_at, :utc_datetime, source: :inserted_at
    # No password field - Analytics should not see it
  end
end

The Analytics context reads from the same table but defines only the fields it needs.read-only-schema It can't accidentally expose password hashes. It can't depend on fields that might change. The coupling is explicit and minimal.


The God Context Anti-Pattern

Every Phoenix application I've inherited has at least one god context. It starts innocently. The team generates a context, adds features, keeps adding. Six months later, lib/my_app/accounts.ex is 1,800 lines and imports half the application.

You can spot a god context by its symptoms. Draw a dependency graph of your contexts; if one node has arrows pointing to every other node, that's your god context. Check your git history; if one file appears in 60% of your commits, that file is doing too much.churn-metrics Ask three developers what the context does. If you get three different answers, the context has no identity.

Tests are the other canary. God contexts require extensive setup because they touch everything; tests break when unrelated features change. Slow, brittle tests are often an architecture problem wearing a performance mask.

Refactoring Out of the God Context

Escaping a god context requires surgery. Not one big pull request—incremental extraction over weeks or months. Identify a cohesive subset, extract it, redirect callers, delete the old code. Repeat.

Identify extraction candidates. Group functions by the primary entity they operate on. Fifteen functions taking %User{} as the first argument and ten functions taking %Organization{}? Two candidate contexts, right there.

Define the new boundary. Create the new module with the functions you're extracting. Initially, delegate to the old implementations.

defmodule MyApp.Organizations do
  @moduledoc """
  Manages organizations and their settings.
  """

  # Temporary delegation during migration
  defdelegate create_organization(attrs), to: MyApp.Accounts
  defdelegate update_organization(org, attrs), to: MyApp.Accounts
  defdelegate get_organization(id), to: MyApp.Accounts
end

The defdelegate trick is underappreciated.defdelegate It lets you change every call site before moving any implementation code. Callers migrate to the new API immediately; the actual logic moves later in a separate, low-risk PR.

Migrate callers. Update call sites throughout your application to use the new context. This is mechanical work, but it reveals hidden dependencies. When you find a controller calling Accounts.create_organization/1, change it to Organizations.create_organization/1. Some of these discoveries will surprise you.

Move the implementation. Once all callers use the new context, move the actual logic—schemas, queries, business rules.

defmodule MyApp.Organizations do
  alias MyApp.Organizations.Organization
  alias MyApp.Repo

  def create_organization(attrs) do
    %Organization{}
    |> Organization.changeset(attrs)
    |> Repo.insert()
  end

  def update_organization(%Organization{} = org, attrs) do
    org
    |> Organization.changeset(attrs)
    |> Repo.update()
  end

  def get_organization(id) do
    Repo.get(Organization, id)
  end
end

Delete the old code. Remove the delegations, the old functions, and any schemas that moved entirely. Run your tests. Ship it.

This process takes time. On a large codebase, extracting a single context might span multiple sprints. That's fine. Rushed extractions create new problems; incremental, tested changes compound into a clean architecture.


A Refactoring Case Study

Say you've got an e-commerce application with a single Shop context. Over eighteen months, it grew to include user registration, product catalog management, shopping carts, order processing, payment handling, inventory tracking, shipping calculations, and customer support tickets.

The Shop module is 2,400 lines. Tests take forty-five seconds. Developers avoid touching it; new features take three times longer than they should because understanding the blast radius of any change requires reading thousands of lines.

The decomposition:

# Before: The god context
defmodule MyApp.Shop do
  # 2,400 lines of everything
  def register_user(attrs), do: # ...
  def authenticate(email, password), do: # ...
  def list_products(filters), do: # ...
  def add_to_cart(user, product, qty), do: # ...
  def checkout(cart, payment_info), do: # ...
  def process_payment(order, payment), do: # ...
  def update_inventory(product, qty), do: # ...
  def calculate_shipping(order, address), do: # ...
  def create_ticket(user, subject, body), do: # ...
  # ... 100+ more functions
end
# After: Decomposed into focused contexts

defmodule MyApp.Accounts do
  @moduledoc "User registration, authentication, and profile management."
  def register_user(attrs), do: # ...
  def authenticate(email, password), do: # ...
  def get_user(id), do: # ...
end

defmodule MyApp.Catalog do
  @moduledoc "Product listings, categories, and search."
  def list_products(filters \\ []), do: # ...
  def get_product(id), do: # ...
  def search_products(query), do: # ...
end

defmodule MyApp.Cart do
  @moduledoc "Shopping cart operations."
  alias MyApp.Catalog

  def add_item(cart, product_id, quantity) do
    with {:ok, product} <- Catalog.get_product(product_id),
         :ok <- validate_availability(product, quantity) do
      do_add_item(cart, product, quantity)
    end
  end

  def remove_item(cart, product_id), do: # ...
  def get_cart(user_id), do: # ...
end

defmodule MyApp.Orders do
  @moduledoc "Order creation, status, and history."
  alias MyApp.{Cart, Payments, Inventory}

  def create_order(cart, shipping_address) do
    Repo.transaction(fn ->
      with {:ok, order} <- build_order(cart, shipping_address),
           {:ok, _} <- Inventory.reserve(order.items),
           {:ok, order} <- Repo.insert(order) do
        order
      else
        {:error, reason} -> Repo.rollback(reason)
      end
    end)
  end
end

defmodule MyApp.Payments do
  @moduledoc "Payment processing and refunds."
  def process(order, payment_method), do: # ...
  def refund(payment, amount), do: # ...
end

defmodule MyApp.Inventory do
  @moduledoc "Stock levels and reservations."
  def reserve(items), do: # ...
  def release(items), do: # ...
  def update_stock(product_id, quantity), do: # ...
end

defmodule MyApp.Shipping do
  @moduledoc "Shipping calculations and carrier integration."
  def calculate_rates(order, address), do: # ...
  def create_shipment(order), do: # ...
end

defmodule MyApp.Support do
  @moduledoc "Customer support tickets."
  def create_ticket(user_id, subject, body), do: # ...
  def list_tickets(user_id), do: # ...
  def respond_to_ticket(ticket_id, response), do: # ...
end

Each context has a clear responsibility. Each can be understood in isolation; each can change independently. The dependency graph is explicit: Orders depends on Cart, Payments, and Inventory. Cart depends on Catalog. Support depends on nothing.

Tests get faster because you test each context with minimal setup. New developers onboard faster because they can understand one context at a time. But the real win is something you don't notice until you've lived with it—changes stay localized. You stop worrying about what breaks when you touch a file.


What I Keep Coming Back To

I've been building Phoenix applications for years now, and the pattern I see most often is teams that treat context design as a one-time decision. They draw boundaries in month two and never revisit them. The codebase they ship in month three won't have the same needs as month eighteen; the domain understanding they have on day one is always incomplete.

Name contexts after business concepts, not technical ones. Accounts, Billing, Inventory—those work. Database, External, Helpers—those are symptoms of not thinking hard enough about what the code actually does.

Keep context APIs small. If a context has fifty public functions, it's doing too much; ten to fifteen is a reasonable target for most domains. Avoid circular dependencies—if Context A depends on B and B depends on A, you haven't found the right boundaries yet.circular-deps Extract a third context or use events to break the cycle.

And accept some duplication. Two contexts with similar helper functions are better than two contexts coupled through a shared utility module.duplication-coupling The instinct to DRY everything across context boundaries is strong. Resist it. Duplication is cheaper than the wrong abstraction; coupling across boundaries defeats the entire point of having boundaries in the first place.

Good architecture isn't about getting it right the first time. It's about making change cheap when you learn you were wrong.


What do you think of what I said?

Share with me your thoughts. You can tweet me at @allanmacgregor.