In a multi-tenant system, every database query that touches tenant data must be filtered by organisation_id. This sounds straightforward — just add a where clause — but the failure mode isn’t “it doesn’t work.” The failure mode is “it works for 99 queries and silently leaks data on the 100th.”
Before we landed on Scope, tenant isolation was done ad-hoc. Each context function accepted an organisation_id integer parameter:
# Before: easy to forget, easy to misuse
def list_items(organisation_id) do
Item |> where(organisation_id: ^organisation_id) |> Repo.all()
end
# Dangerous: nothing stops someone from calling without filtering
def list_items do
Item |> Repo.all() # returns ALL tenants' data
end
The trouble is that this convention is invisible to the compiler. We ran into several classes of bugs:
- Forgetting the filter entirely — a new function that queries the DB without scoping leaks data across tenants. There’s no compiler error, no test failure (unless you specifically write a cross-tenant isolation test), nothing. It just silently works and exposes Org A’s data to Org B.
- Passing the wrong ID — when
organisation_idis just an integer, it’s easy to accidentally pass auser_idoritem_idin its place. No type safety. - Inconsistent function signatures — some functions take
organisation_id, some take an%Organisation{}struct, some take a user and extract the org from it. The caller has to know which convention each function uses. - No single place to carry auth context — if a function needs to know both which org and which user (for authorization), you end up threading two separate parameters through every call.
We needed something that made unscoped queries structurally impossible — not just “against the rules.”
The %Scope{} struct
The idea is a tiny value object that bundles tenant identity and optionally user identity into one explicit token:
defstruct [:organisation_id, :organisation, :user]
@enforce_keys [:organisation_id]
@enforce_keys is doing real work here — you literally cannot construct a %Scope{} without providing organisation_id. The struct is created through constructor functions that match on the right types:
Scope.for_organisation(%Organisation{} = org) # from a full org struct
Scope.for_organisation_id(123) # from just an ID (e.g. JWT claims)
Scope.with_user(scope, %Account{} = user) # attach user for authz
Every context function then pattern-matches on %Scope{} as its first argument:
def list_items(%Scope{organisation_id: org_id}, opts \\ []) do
Item
|> where(organisation_id: ^org_id)
|> Repo.all()
end
I covered a more involved example of this in Why I Replaced GraphQL + Next.js with Phoenix LiveView — the list_available_items function uses Scope to filter a booking availability query with Postgres range overlap checks.
What this actually buys you
The thing that surprised me is how many bug categories just disappear once you adopt this consistently:
Unscoped queries become structurally impossible. If every public context function requires %Scope{} as the first param, you can’t call it without one. There’s no “I forgot to pass the org” — the function clause simply won’t match. A new developer adding a function either follows the pattern or gets immediate FunctionClauseError failures.
Wrong-type-of-ID bugs are caught at the call site. Scope.for_organisation_id/1 has a guard when is_integer(org_id). The struct match %Scope{organisation_id: org_id} in every function head means you can’t accidentally pass a bare integer where a scope is expected. If you pass user.id instead of a scope, it fails immediately — not silently with wrong data.
Function signatures are self-documenting. When you see def get_item(%Scope{organisation_id: org_id}, id, preload \\ []), the contract is immediately clear: this function operates within a tenant boundary. There’s no ambiguity about whether filtering happens or not.
Auth context travels with the scope. Scope.with_user/2 lets you attach the current user so downstream code can do authorization checks without threading a separate user parameter. The scope becomes a single “request context” token.
Lazy org loading. Sometimes you only have an org ID (from a JWT), but later need the full struct (for display). Scope.preload_organisation/1 handles this — it loads once and is a no-op if already loaded. No redundant queries.
Functions that operate outside tenant boundaries — system admin, background jobs with explicit org loading — deliberately omit Scope. The absence is the signal that a function crosses tenant boundaries, which makes it stand out in code review.
How it flows through the app
In LiveView mounts, the scope is built from the authenticated session and stored in socket assigns:
def mount(_params, _session, socket) do
scope = Scope.for_organisation(socket.assigns.current_organisation)
|> Scope.with_user(socket.assigns.current_account)
items = Items.list_items(scope)
{:ok, assign(socket, scope: scope, items: items)}
end
In event handlers, the scope from assigns is passed to every context call:
def handle_event("delete", %{"id" => id}, socket) do
item = Items.get_item(socket.assigns.scope, id)
Items.delete_item(socket.assigns.scope, item)
...
end
In tests, scope creation is explicit, making isolation tests natural:
test "items scoped to org" do
org1 = insert(:organisation)
org2 = insert(:organisation)
scope1 = Scope.for_organisation_id(org1.id)
scope2 = Scope.for_organisation_id(org2.id)
insert(:item, organisation: org1)
assert length(Items.list_items(scope1)) == 1
assert length(Items.list_items(scope2)) == 0 # org2 sees nothing
end
The scope is built as early as possible — in the plug or mount — and threaded through from there. We never destructure it to pass a raw organisation_id to another context function. The downstream function takes the scope itself. This keeps the chain unbroken.
Trade-offs
This isn’t free. There are a few things worth being honest about:
- Boilerplate. Every context function gains a
%Scope{}first parameter, even ones where the scoping feels obvious. For a small app with one or two tenants, this can feel like ceremony for its own sake. It starts paying off once you have enough context functions that you can’t keep them all in your head. - Background jobs need special handling. An Oban job doesn’t have a session or a LiveView socket — it has to reconstruct the scope from whatever was passed in the job args. We typically pass
organization_idand rebuild withScope.for_organisation_id/1at the top of theperform/1function. It works, but it’s an extra step to remember. - Doesn’t solve row-level permissions. Scope handles tenant isolation (which org), but not authorization within a tenant (can this user do this action). We use separate authorization checks for that. The scope can carry the user via
with_user/2, but the actual permission logic is elsewhere. - Testing requires scope setup. Every test that touches a context function needs to build a scope. It’s a one-liner, but it adds up across a large test suite. We use a helper to keep it concise.
We considered other approaches — a Bodyguard-style policy module, a set_tenant function on the repo connection, even Postgres row-level security. The struct-as-first-param approach won because it’s the simplest thing that makes the wrong thing impossible. No macros, no runtime configuration, no database-level setup — just a pattern match in every function head.
One extra parameter, one struct — and a runtime concern becomes a structural one. Violations are obvious in code review: if a context function doesn’t take %Scope{}, something is wrong.
It’s worth noting that this isn’t a framework feature or a library — it’s a language feature. Pattern matching on a struct in every function head, @enforce_keys preventing construction without required fields, function clause errors on wrong types. In most languages you’d need a dependency injection container, a middleware layer, or a decorator pattern to achieve something similar. In Elixir, it’s just how functions work.