Phoenix Contexts Done Right
When to split contexts, cross-context communication, and avoiding the god context anti-pattern
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.