Contexts and Architecture
Contexts are Phoenix's answer to a question every web framework has to answer: where does the business logic live? Rails punted and let it sprawl into fat models. Django punted and let it sprawl into managers and views. Phoenix's answer is contexts — modules that group related domain logic into a single boundary, sitting between the web layer and the database.
Contexts are a convention, not a magic feature. There's no defcontext macro. A context is just an Elixir module that exposes a focused API for a piece of your domain — something like Accounts, Billing, Inventory, or Posts. The controller calls into the context. The context handles the database, the validation, the side effects, the orchestration. The controller doesn't know how any of that works.
This sounds simple, and it mostly is. But contexts also generate the most discussion of any Phoenix design decision, because the line between "good context" and "leaky abstraction" or "over-engineered facade" isn't always obvious.
What a Context Looks Like
A typical context module:
defmodule Hello.Accounts do
@moduledoc """
The Accounts context — handles users, registration, authentication, profiles.
"""
alias Hello.Repo
alias Hello.Accounts.{User, Token}
# Read
def get_user!(id), do: Repo.get!(User, id)
def get_user_by_email(email), do: Repo.get_by(User, email: email)
def list_users, do: Repo.all(User)
# Write
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
def update_profile(%User{} = user, attrs) do
user
|> User.profile_changeset(attrs)
|> Repo.update()
end
# Auth
def authenticate(email, password) do
case get_user_by_email(email) do
nil ->
# protect against timing attacks
Argon2.no_user_verify()
{:error, :invalid_credentials}
user ->
if Argon2.verify_pass(password, user.password_hash) do
{:ok, user}
else
{:error, :invalid_credentials}
end
end
end
# Tokens — internal implementation detail not exposed
defp generate_token(user) do
# ...
end
end
The schemas (User, Token) live as submodules under the context's namespace. The web layer never sees them directly — it calls Accounts.register_user(params), gets back {:ok, user} or {:error, changeset}, and renders accordingly.
The Web/Domain Split
Phoenix generates two top-level namespaces:
lib/hello/— your application. Contexts, schemas, Repo, business logic.lib/hello_web/— your web layer. Controllers, components, router, endpoint.
The rule: hello_web/ calls into hello/. Never the reverse. Your business logic doesn't know that a web framework exists. It doesn't know about controllers, conns, params, sessions, or any of it. It deals in plain Elixir values.
This means you can:
- Use the same context from a controller, a LiveView, a Channel, or a CLI task.
- Test the context without touching Phoenix at all.
- Replace the web layer (or add a second one — say, an admin interface) without rewriting business logic.
- Reason about your domain without web concerns leaking in.
The opposite happens too often in frameworks that don't enforce this split. You end up with controllers that do too much, models that depend on the request cycle, and a tangled mess that's impossible to extract or test.
Schemas vs Structs
A common confusion: when something is an Ecto schema and when it's a plain struct.
Schemas are for things that map to database tables. They have changesets (validation), associations, and Repo-aware behavior:
defmodule Hello.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password_hash, :string
field :name, :string
has_many :posts, Hello.Content.Post
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :password])
|> validate_required([:email, :name])
|> validate_format(:email, ~r/@/)
|> hash_password()
end
end
Structs are for everything else — value objects, intermediate computations, domain concepts that don't have their own table:
defmodule Hello.Billing.Receipt do
defstruct [:id, :user_id, :amount, :currency, :line_items, :issued_at]
end
A common pattern: a function takes a schema, does some processing, and returns a struct:
def receipt_for(%Order{} = order) do
%Receipt{
id: order.id,
user_id: order.user_id,
amount: order.total_cents,
currency: order.currency,
line_items: build_line_items(order),
issued_at: DateTime.utc_now()
}
end
The Receipt isn't persisted — it's a presentation concern, an output of the context. Don't make it a schema just because it has fields.
Why Contexts Matter
The argument for contexts isn't aesthetic. It's about scaling the codebase past the point where one developer can hold the whole thing in their head.
Boundaries make change safe. If Accounts.register_user/1 is the only way the rest of your app creates users, you can refactor everything inside it — change the schema, add an audit trail, send to a CRM, fire off a Kafka event — without touching any caller.
Tests stay sharp. Context tests touch the database but not Phoenix. Controller tests touch Phoenix but mock the context. Each test is fast and focused. When everything's tangled, you end up with slow integration tests for every feature because you can't isolate anything.
The API is discoverable. A new developer reading lib/hello/accounts.ex learns what the system does to users. They don't have to chase callbacks across schemas, reading three files to understand one operation.
Side effects are visible. Sending an email when a user registers, logging an audit entry, updating a counter — these all live in the context. The controller doesn't sneak in side effects; the schema doesn't trigger them via callbacks. They're explicit in one function.
The Critique
Contexts get criticized too, and the criticism is fair when the pattern is misapplied.
The "thin facade" problem. If your context is just def list_users, do: Repo.all(User) and twenty similar one-liners, you've built a delegation layer that adds no value. The context should mean something — it should encapsulate decisions, not just relay calls.
The "where does this go?" problem. When a feature crosses contexts — say, "creating an order needs to debit inventory and charge a card and send an email" — none of the existing contexts is obviously right. Do you put it in Orders? Billing? A new OrderProcessing context? People disagree, and the wrong choice creates awkward dependencies.
The premature-abstraction problem. For a small app, contexts can feel like overkill. You don't need a boundary between modules when the whole app is one developer's side project. Phoenix's generators encourage them anyway, which can make starter apps feel over-architected.
The pragmatic response: start simple. One context per major domain area. Don't split until the file gets unwieldy or two distinct concerns are crammed together. When a piece of logic doesn't fit anywhere, it usually means a new context wants to exist — listen to that signal rather than forcing it into an existing one.
A Real-World Shape
For a moderately complex e-commerce app, the contexts might look like:
lib/shop/
accounts.ex # users, sessions, profiles
accounts/
user.ex
session.ex
catalog.ex # products, categories, search
catalog/
product.ex
category.ex
cart.ex # shopping cart
cart/
cart.ex
line_item.ex
orders.ex # placing orders, status, history
orders/
order.ex
fulfillment.ex
billing.ex # payments, refunds, invoices
billing/
charge.ex
refund.ex
inventory.ex # stock levels, reservations
inventory/
sku.ex
reservation.ex
notifications.ex # email, SMS, push
The order placement logic might live in Orders.place_order/2, which calls into Inventory.reserve/1, Billing.charge/2, and Notifications.send_order_confirmation/1. The Orders context coordinates the workflow; it doesn't reach into the schemas of other contexts.
This kind of cross-context orchestration is sometimes called a "service" context — a context whose job is to orchestrate other contexts. It's a fine pattern. It's also a sign that maybe Orders is the right home and the others are supporting it.
Don't Mistake the Map for the Territory
Phoenix's generators (mix phx.gen.context, mix phx.gen.html) are a starting point. They produce a generic CRUD context with list_x, get_x!, create_x, update_x, delete_x. That's fine for scaffolding, but production contexts almost never look like that. They have functions named for what the business actually does — register_user, cancel_subscription, mark_order_shipped — not generic verbs.
The generated code is a hint, not a destination. Take what's useful, rename functions to match your domain, delete the ones you don't need.
Common Pitfalls
Calling Repo from controllers. If your controller has import Ecto.Query or calls Repo.something/1, you've leaked persistence into the web layer. Move the operation into a context.
Schemas with business logic. Putting def register/2 on the User schema feels reasonable until you realize the schema now needs to know about email sending, password hashing, audit logging, etc. The schema is the data shape. Logic lives in contexts.
One context for everything. A MyApp.Core context that has 200 functions covering accounts, billing, inventory, and orders is just a god module with extra steps. Split it.
Cross-context schema references. When Hello.Orders.Order belongs_to :user, Hello.Accounts.User, that's fine — schemas can reference each other across contexts. But code that does Orders.get_user_for_order/1 and reaches into Accounts.User directly is bypassing the abstraction. Either expose Accounts.get_user_for_order/1, or pass the user in.
Context-per-table. A context per database table produces a flat, meaningless layering. Group by domain concept — Accounts covers users, sessions, tokens, password resets, all of it. Billing covers charges, refunds, invoices, line items. The context is a piece of your domain, not a database aliaser.
Premature splitting. Two functions that look related might not stay related. Don't extract a context until you have at least a few functions that cohere. Splitting too early means renaming and reorganizing later.
Key Takeaways
- Contexts are modules that expose a focused API for a piece of your domain. They sit between the web layer and persistence.
- The split between
lib/my_app/(domain) andlib/my_app_web/(web) is the architectural backbone. Domain doesn't depend on web. - Schemas are for data that maps to tables. Use plain structs for everything else.
- Contexts make refactoring safe, tests fast, and the API discoverable. They're a boundary, not a layer.
- The pattern gets criticized when applied mechanically — thin facades, generated CRUD that doesn't match the domain, premature splits. Use it as a tool, not a recipe.
- When generators produce CRUD contexts, treat them as scaffolding. Production contexts have function names that match the business, not generic verbs.