Phoenix Contexts in the Real World: From Theory to Survival

elixir

There's a file in your Phoenix app — thousands of lines, and the bigger it got, the faster it grew. Upfront modeling was never the fix. Your domain has rivers: real boundaries you can't see on day one. This is how you find them, and split along them without a big-bang PR.

Juan Azambuja avatar

Juan Azambuja

14 min read - September 22, 2026


This article is the written version of my ElixirConf 2026 talk of the same name.

There's a file I want to tell you about. lib/ticket_hub/events.ex — 6,217 lines. Forty-plus public functions: create_event, register_attendee, charge_card, apply_coupon, send_confirmation_email, check_in, refund_order, notify_organizer... It's a real file; names changed to protect the guilty.

Nobody designed this file. Everybody built it.

If you've worked on a Phoenix app past its second year, there is a big chance you have this file too. And it has a strange property that took me a long time to see clearly: the bigger it got, the faster it grew. The last 2,000 lines arrived twice as fast as the first 2,000. Growth wasn't linear — it was accelerating.

Hold that thought. We'll come back to it at the end.

The controversy

In 2025, a blog post titled "Did contexts kill Phoenix?" made the rounds, along with a long r/elixir thread. The argument: contexts, introduced in Phoenix 1.3, raised the barrier to entry and hurt adoption. The specific criticisms:

  • You're forced to name domains before requirements exist.
  • It's DDD overhead imposed on small projects.
  • The result is often thin wrappers of "dubious architectural benefit."
  • Contexts are "a consistent source of confusion" for beginners.
  • I want to concede something immediately and sincerely: every one of these criticisms describes real pain. I've felt all of them. The critics are right about the symptoms.

    They're wrong about the diagnosis. This article is the alternative diagnosis.

    You can opt out. You'll be on your own.

    First, let's be honest about what contexts are: convention, not law. Call Repo straight from your LiveView — nothing breaks. Plenty of apps ship this way.

    But look at what assumes the convention: the phx.gen.* generators. phx.gen.auth. Phoenix 1.8's scopes (more on those later — they're the clincher). Every guide and tutorial. Your next teammate. Your next codebase.

    Opting out doesn't make you wrong. It makes you alone. A codebase without contexts is an unrecognized state: functional internally, no diplomatic relations with the ecosystem.

    The wrong diagnosis: it's not contexts, it's day one

    Here's the moment the critics are actually complaining about:

    $ mix phx.gen.context Events Attendee attendees

    That word — Events — is a permanent architectural decision, demanded at the moment of maximum ignorance. You've written zero features. You've met zero real requirements. And the framework is asking you to draw a national border.

    Notice what the critics and the DDD purists have in common: both assume boundaries are designed upfront. One says "do it rigorously," the other says "it hurts, remove it." Same wrong premise.

    This is also why this is not a DDD article. Bounded contexts are the output of deep domain learning — upfront DDD asks you to do DDD's hardest step first, with the least information you'll ever have. (We'll steal exactly one thing from DDD later. It's the right thing to steal.)

    Start with one god context. On purpose.

    So what's the pragmatic alternative? It's more radical than you'd expect:

    For a new Phoenix app, don't invent domain boundaries on day one. Start with one coarse context — plus whatever phx.gen.auth hands you — and let the real boundaries emerge. If boundaries are supposed to emerge, the god-context phase is part of the plan, not a lapse to feel guilty about.

    But wait — didn't I open with a horror story about exactly this? Here's the distinction that matters: that 6,217-line file wasn't a failure of size. It was a failure of attention. Nobody was watching what the file was doing to every new feature decision.

    So the deal is this:

    You only get to skip the upfront modeling if you commit to watching the signals. That's the price.

    A god context under watch is a starting point. A god context unattended is a 6,217-line file. The middle sections of this article are the watchlist.

    One more reason to bias toward starting coarse: splitting later is cheap (I'll prove it with a playbook), but un-merging a wrongly guessed boundary is expensive. When in doubt, err big.

    What a boundary actually is: countries

    Before we can watch for boundaries, we need a working model of what one is. Here's mine: contexts are countries.

    Within a country, provinces interact freely. Events.Attendee and Events.Talk can know each other, share queries, skip the ceremony — that's domestic affairs.

    Between countries, all traffic goes through the embassy — the context's public module. Events never talks to Billing.Invoice; it talks to Billing. A province of one country doesn't negotiate with a province of another. There's a hierarchy, and it's respected.

    And each context is sovereign over its own schemas. Structs may cross the border as read-only facts — that's idiomatic Phoenix, and 1.8's own %Scope{} carries a user struct into everything. What never crosses: queries against another context's schemas, and changesets or writes on structs you don't own.

    You can read another country's documents. You can't write in them, and you can't rummage through their filing cabinets.

    Legal vs. illegal crossing

    Both of these live inside Events. Same feature, two borders:

    # ✅ through the embassy
    def cancel_registration(attendee_id) do
      attendee = get_attendee!(attendee_id)
    
      with {:ok, _refund} <- Billing.refund(attendee.order_id),
           do: mark_cancelled(attendee)
    end
    # 🚫 through a farm field at night
    def cancel_registration(attendee_id) do
      attendee = get_attendee!(attendee_id)
    
      Repo.one!(from i in Billing.Invoice,
        where: i.order_id == ^attendee.order_id)
      |> Ecto.Changeset.change(status: :refunded)
      |> Repo.update!()
    
      mark_cancelled(attendee)
    end

    On the left, Events needs a refund, so it asks Billing for one. Events has no idea how refunds work — and it doesn't want to know. That's the whole embassy.

    On the right: it compiles. Tests pass. It ships the same afternoon. And it's two violations, not one — the query is reading a foreign filing cabinet, and the update is writing in another country's documents. Billing can no longer guarantee a single invariant about its own data.

    The uncomfortable part is that the right side works. Nobody stops you at this border. It keeps working right up until Billing changes how refunds are stored — refunds become their own table, or that status becomes a state machine — and on that day you find out three other countries had troops on that farm field.

    Rivers vs. rulers

    One more thing the country metaphor gives us, and it's the thesis of this whole article. Look at a map. Borders drawn by committee, far from the territory, in straight lines — those are the ones with a century of conflict behind them. Borders that follow rivers, mountains, language — those hold.

    Your domain has rivers. You can't see them on day one. You find them by watching the terrain.

    Here's how.

    The signals

    Five signals that a context wants to split; two that contexts want to merge. Each has a name — use the names in code review. This is the diligence you signed up for when you took the god-context deal.

    Signal 1 — Naming drift

    def billing_address_for(attendee)
    def billing_status(order)
    def apply_billing_coupon(order, code)
    def billing_receipt_pdf(order)

    Nobody decided to create a Billing namespace. The language wore a track through the module on its own. When a non-generic prefix appears three or four times, the module is telling you its name. Listen.

    Signal 2 — The "and" test

    Describe what the context does, out loud. Count the "and"s.

    "Events handles the schedule and registration and payments and check-in and notifications."

    If you can't describe a context without "and," the sentence found the seam before you did. This one is cheap enough to run in a standup — no tooling required.

    Signal 3 — Border smuggling

    Who's reaching across the border without going through the embassy?

    $ grep -rn "Billing\.[A-Z]" lib/ticket_hub/events*
    
      Billing.refund(...)   # lowercase: a call to the embassy
      Billing.Invoice       # capital:   you're inside their filing cabinet

    The heuristic is the capitalization after the dot. Lowercase means a function on the public module — legal embassy traffic. A capital letter means a schema or internal module — you're inside another country's territory. No hand-maintained exclusion list needed.

    Cross-context Repo reads and writes against another context's schemas mean ownership of the behavior has diverged from ownership of the data. And here's the productive way to read the results: where smuggling concentrates is exactly where the real border wants to be.

    Signal 4 — Test setup drag

    setup do
      org      = org_fixture()
      event    = event_fixture(org)
      attendee = attendee_fixture(event)
      order    = order_fixture(attendee)
      coupon   = coupon_fixture(org)
      %{attendee: attendee, order: order, coupon: coupon}
    end

    One function under test. Four contexts of setup.

    Setup pain is coupling made visible. If testing one function requires assembling half the app, that function's context has half the app inside it.

    Signal 5 — The PR heatmap

    $ git log --format= --name-only | sort | uniq -c | sort -rn | head
    
       312  lib/ticket_hub/events.ex
       104  lib/ticket_hub_web/live/event_live.ex
        57  lib/ticket_hub/events/attendee.ex
        41  mix.exs

    If every feature, regardless of domain, touches the same file — that's not a popular file. That's a border dispute. There's a social version of this signal too: if every planning discussion ends with "eh, just put it in Events," your team is feeling the same pull the codebase is.

    The merge signals

    Splitting isn't the only move. Over-splitting is how "pragmatic contexts" becomes 37 microservices in a trenchcoat — and it's behind the critics' fairest complaint, the thin wrapper of dubious benefit. The answer to a pass-through context is merging it, not abolishing contexts. Watch for:

  • Two contexts that always change together — same PRs, forever.
  • Heavy embassy traffic — every operation in A needs three calls into B.
  • A thin pass-through — a context whose embassy just forwards paperwork.
  • The ceremony at the border is information. If two countries need an embassy call for everything, maybe they're one country.

    The playbook: a peaceful secession

    Signals fired; the team agrees Billing wants out of Events. Now what?

    Here's the playbook, and here's its one structural trick: every step ships independently, and the app works after each one. No six-week refactor branch that dies in review. No big-bang PR. Ever.

    Step 1 — Name it first

    The new context starts as vocabulary, not code. Before any file moves, get the team saying "Billing" — in standups, in tickets, in PR titles. If the name doesn't stick in conversation, it won't stick in code.

    This is the one thing we steal from DDD: ubiquitous language. We're doing DDD backwards — evidence first, model second. Backwards is the direction that works.

    Step 2 — Facade before move

    defmodule TicketHub.Billing do
      defdelegate refund(order_id), to: TicketHub.Events
      defdelegate apply_coupon(order, code), to: TicketHub.Events
      defdelegate receipt_pdf(order), to: TicketHub.Events
    end

    The embassy opens before the border moves. This PR is zero-risk and ships today; the implementation hasn't moved an inch. But from this moment, every new piece of billing code has an obviously correct home — you get the benefit before the migration is even finished.

    Step 3 — Migrate callers, then move behavior — not schemas

    Callers switch to the new name, one mechanical PR per calling area (the web layer, each other context, the tests). Then function bodies move from events.ex into billing.ex.

    But Events.Order — the schema — stays put. This is where most refactors stall: teams think the split isn't real until schemas move. Wrong. Decouple "who owns the code path" from "who owns the table."

    Why schemas wait: moving Events.Order touches every alias, every query, every changeset, every factory, and — worst of all — every belongs_to :order, Events.Order association, often in other contexts. A schema move structurally wants to be a big-bang PR, the exact thing this playbook exists to avoid.

    Behavior ownership is the real coupling. Once every read and write goes through Billing, the file's physical location is nearly cosmetic. Schema relocation is the last step of a split — a cheap, mechanical rename once all callers are migrated — and never the measure of progress. (The database table never needs renaming at all.)

    Step 4 — Deprecate loudly

    @deprecated "Use TicketHub.Billing.refund/1"
    def refund(order_id), do: Billing.refund(order_id)

    Deprecation makes stragglers surface in CI gently. Also won't stop moving forward if you absolutely need a temporal exception.

    Step 5 — Border patrol

    defmodule TicketHub.Events do
      use Boundary, deps: [TicketHub.Billing]

    First you draw the border. Then you staff it. The boundary library promotes the border from honor system to law — illegal crossings become compile errors. And even with no new dependency, mix xref graph --sink lib/ticket_hub/billing.ex shows you the smuggling routes today, for free. This is also the step that retires the Signal 3 grep: once the border is compiler-enforced, you stop needing to police it by hand.

    Phoenix 1.8 staffs the border for you: scopes

    If you still think of contexts as a style choice, Phoenix 1.8 should change your mind. The generators now pass a %Scope{} — current user, org, permissions — as the first argument to every context function, and every query filters by it:

    def list_orders(%Scope{} = scope) do
      Repo.all(from o in Order,
        where: o.user_id == ^scope.user.id)
    end

    The scope is a passport: every border crossing gets identity-checked. The target is broken access control — OWASP's #1 web application risk.

    Here's the point that matters for this article: scopes only work because the embassy is the single chokepoint. If your LiveView calls Repo directly, there is no checkpoint — no single place to enforce who may see what.

    And this settles the controversy. After eight years of "did contexts kill Phoenix?", the framework's answer in 1.8 was to make contexts load-bearing for security. Contexts stopped being a style choice; they're now where your authorization lives.

    The playbook is a checklist — and checklists can be automated

    Notice what just happened across the last two sections: every signal was a command, and every playbook step was a mechanical transformation.

    That was on purpose. Evidence-based process is automatable; vibes-based process is not. You can't write "do DDD properly" as an agent skill. You can write this one — so I did:

    $ /context-split border report
    $ /context-split split Billing out of Events

    It runs the signal diagnostics on demand (or weekly, as a border report: "smuggling into Events up 40% this quarter"), and once you decide to split, it executes steps 2–5: facade PR, caller migration, deprecation, enforcement. Each PR small, reviewable, boring. Boring is the goal.

    Three hard rules are built into it:

    1. It never names a context

    2. It never moves schemas first.

    3. It never ships a big-bang PR.

    Step 1 stays human. The agent can pave; only your team knows what the country is called. Naming — the language — is the judgment the loop can't close for you. After reading all of this, you may have come up with your own heuristics on how to split or merge contexts. You can personalize your skill with your own mental framework!

    Testing the seam

    Two testing practices protect everything above, and two destroy it.

    Your fixtures are your first API consumer.

    # 🚫 the test bypasses the border
    order = Repo.insert!(%Billing.Order{status: :paid, ...})
    
    # ✅ the test IS a border crossing
    {:ok, order} = Billing.create_order(scope, attendee, ticket_type)

    If fixtures call Repo.insert! directly, your tests bypass the boundary you just fought for — they'll happily keep passing while the border rots. Fixtures that go through the public API mean every test run exercises the embassy, scope check included, for free. A boundary leak then shows up as test pain immediately — which, remember Signal 4, is exactly the early-warning system you want.

    And a don't:

  • Don't Mox between your own contexts. Mocks are for external borders — Stripe, S3 — not internal ones. Mocking your own contexts freezes the embassy's API in test doubles, and now the boundary can't evolve — which defeats the entire evolutionary premise. Internal borders are cheap to cross for real in tests. Cross them for real.
  • Where does it go?

    Let me show you what you actually buy with all of this. Three product requirements, straight off a backlog. Where does each one go?

    1. Partial refunds for early cancellations.

    2. Auto-promote from the waitlist when a ticket frees up.

    3. Sponsors scan attendee badges and export leads.

    You already said Billing for the first one, in your head, before finishing the sentence. The second: Events — no hesitation.

    The third one made you pause. Sponsors. Leads. Those words don't belong to any country on our map.

    Here's what just happened. You sorted two features in about two seconds, in a codebase you have never seen, without reading a line of code. And the third one made you stop. In the god-context world, you never stop — everything fits in Events, because everything fits in a junk drawer. And the junk drawer never says no. That's how the 6,217-line file happened: misfits, absorbed silently, one at a time.

    So boundaries did two things just now: they sorted the routine work for free, and they made the misfit visible — a new context announcing itself before anyone has written a line of it.

    What do you do with the misfit? Not a new context — not yet. One feature isn't evidence; a cluster is. Park it in the nearest context deliberately, keep its vocabulary intact (sponsor_this, lead_that), and when that prefix shows up three or four times — you know the playbook.

    Your instinct pulled two features home and flagged a third. That pull has a name.

    That was gravity

    Remember the thing I asked you to hold onto? The bigger the file got, the faster it grew.

    That was gravity. And once you name the force, you see it was everywhere in this article: the file that grew faster the bigger it got. The team ending every discussion with "eh, just put it in Events." The pull that sorted refunds into Billing in your head a minute ago. The pause on the misfit. All of that was one force. Code attracts code.

    This is why "start with one god context" is safe advice and dangerous advice at the same time. Gravity is the engine either way. Watched, it organizes: features fall into place, misfits stand out. Unwatched, it accretes: that's the 6,217-line file. Same force, same object — the only variable is attention. The signals are your instruments.

    Structure amplifies whatever it already is. Bad boundaries train bad instincts; good ones train good ones. The whole game is making gravity work for you.

    The takeaway

    Start with one god context, on purpose, and keep it under watch. That was the deal, and the rest of this article was about how to hold up your end of it.

    Holding up your end means noticing when the context wants to split. A prefix like billing_ keeps showing up in function names. You can't say what the module does without an "and" in the sentence. Other code reaches past the embassy into schemas it doesn't own. A test for one function needs four contexts of setup. Every PR, whatever the feature, touches the same file. Any one of those is worth a comment in code review. Several at once mean a country is asking for independence.

    It runs the other way too. Two contexts that change together in every PR, that need three embassy calls to get anything done, or where one of them only forwards paperwork, are probably one country with a border drawn through the middle. Merge them.

    When a split is due, do it in small PRs that each ship on their own. Get the team saying the name before any code moves; that part is yours and can't be delegated. Then open the facade, migrate the callers, move the behavior and leave the schemas where they are, deprecate the old functions, and let the boundary library and Phoenix scopes enforce the border. Everything after the naming is mechanical, which is why an agent can do it for you.

    Good borders follow the rivers. Let your domain show you where they are.

    Sources: "Did contexts kill Phoenix?" (Arrowsmith Labs) · Phoenix scopes guide · the boundary library

    paper airplane

    Thoughts? Like what you just read?

    Let's keep the conversation going. Share it on social or reach out and tell us your take.

    mimiquate petmimiquate pet