phoenix-contexts
Phoenix context design — creating/splitting contexts, Scope (1.8+), Ecto.Multi,
Install
npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/targets/amp/skills/phoenix-contexts
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
git clone https://github.com/oliver-kriska/claude-elixir-phoenix.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oliver-kriska/claude-elixir-phoenix collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Phoenix Contexts Reference
Ash projects:
Ash.Domainreplaces Phoenix contexts for data access — use theash-frameworkskill. Context boundary and PubSub patterns still apply.
Reference for designing and implementing Phoenix contexts (bounded contexts).
Iron Laws — Never Violate These
- CONTEXTS OWN THEIR DATA — Never query another context's schema directly via Repo
- SCOPES ARE MANDATORY (Phoenix 1.8+) — Every context function MUST accept scope as first parameter
- THIN CONTROLLERS/LIVEVIEWS — Controllers translate HTTP, business logic stays in contexts
- NO SIDE EFFECTS IN SCHEMAS — Use
Ecto.Multifor transactions with side effects
Context Structure
lib/my_app/
├── accounts/ # Context directory
│ ├── user.ex # Schema
│ ├── scope.ex # Scope struct (Phoenix 1.8+)
├── accounts.ex # Context module (public API)
Phoenix 1.8+ Scopes (CRITICAL)
Context functions take the scope as their first parameter, so queries are filtered to the caller:
def list_posts(%Scope{} = scope) do
from(p in Post, where: p.user_id == ^scope.user.id)
|> Repo.all()
end
def create_post(%Scope{} = scope, attrs) do
%Post{user_id: scope.user.id}
|> Post.changeset(attrs)
|> Repo.insert()
|> broadcast(scope, :created)
end
Quick Decisions
When to SPLIT contexts?
- Module exceeds ~400 lines
- Functions don't share domain language
- Could theoretically be a separate microservice
- Team member could own it independently
When to KEEP together?
- Resources share vocabulary and domain concepts
- Functions frequently operate on same data together
- Splitting would create excessive cross-context calls
Cross-Context References
# ✅ Reference by ID, convert at boundary
def create_order(%Scope{} = scope, user_id, product_ids) do
with {:ok, user} <- Accounts.fetch_user(scope, user_id) do
do_create_order(scope, user.id, product_ids)
end
end
# ❌ Reaching into other context's internals
alias MyApp.Accounts.User # Don't do this
Repo.all(from o in Order, join: u in User, ...) # Don't query other schemas
Anti-patterns
| Wrong | Right |
|---|---|
Service objects (UserCreationService) |
Context functions (Accounts.create_user/2) |
| Repository pattern wrapping Repo | Repo IS the repository |
| Direct Repo calls in controllers | Delegate to context |
| Schema callbacks with side effects | Use Ecto.Multi |
Version Notes
- Phoenix 1.8+: Uses built-in
%Scope{}struct for authorization context - Phoenix 1.7: Requires manual authorization context (see
references/scopes-auth.md"Pre-Scopes Patterns")
References
For detailed patterns, see:
references/context-patterns.md- Full context module, PubSub, Multi, cross-boundaryreferences/scopes-auth.md- Scope struct, multi-tenant, authorization, plugsreferences/routing-patterns.md- Verified routes, pipelines, API authreferences/plug-patterns.md- Function/module plugs, placement, guardsreferences/json-api-patterns.md- JSON controllers, FallbackController, API auth
Files (claude-elixir-phoenix)
-
references
-
context-patterns.md 5.8 KB
# Context Patterns Reference ## Full Context Module Pattern ```elixir defmodule MyApp.Accounts do @moduledoc """ The Accounts context - manages users and authentication. """ import Ecto.Query alias MyApp.Repo alias MyApp.Accounts.{User, Token, Scope} @topic inspect(__MODULE__) # ============================================ # PubSub # ============================================ def subscribe do Phoenix.PubSub.subscribe(MyApp.PubSub, @topic) end defp broadcast({:ok, result}, scope, event) do Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {__MODULE__, event, result}) {:ok, result} end defp broadcast({:error, _} = error, _scope, _event), do: error # ============================================ # Users # ============================================ def list_users(%Scope{} = scope) do from(u in User, where: u.organization_id == ^scope.user.organization_id) |> Repo.all() end def get_user(%Scope{} = scope, id) do Repo.get_by(User, id: id, organization_id: scope.user.organization_id) end def get_user!(%Scope{} = scope, id) do Repo.get_by!(User, id: id, organization_id: scope.user.organization_id) end def create_user(%Scope{} = scope, attrs \\ %{}) do %User{organization_id: scope.user.organization_id} |> User.registration_changeset(attrs) |> Repo.insert() |> broadcast(scope, [:user, :created]) end def update_user(%Scope{} = scope, %User{} = user, attrs) do user |> User.changeset(attrs) |> Repo.update() |> broadcast(scope, [:user, :updated]) end def delete_user(%Scope{} = _scope, %User{} = user) do Repo.delete(user) end def change_user(%User{} = user, attrs \\ %{}) do User.changeset(user, attrs) end # ============================================ # Authentication # ============================================ def authenticate_user(email, password) do user = Repo.get_by(User, email: email) cond do user && Bcrypt.verify_pass(password, user.hashed_password) -> {:ok, user} user -> {:error, :invalid_password} true -> Bcrypt.no_user_verify() # Timing-safe {:error, :not_found} end end end ``` ## Side Effects with Ecto.Multi NO side effects in changesets. Use Ecto.Multi: ```elixir def register_user(%Scope{} = scope, attrs) do Ecto.Multi.new() |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs)) |> Ecto.Multi.run(:welcome_email, fn _repo, %{user: user} -> MyApp.Mailer.deliver_welcome(user) end) |> Ecto.Multi.run(:broadcast, fn _repo, %{user: user} -> broadcast({:ok, user}, scope, [:user, :registered]) end) |> Repo.transaction() end ``` ## FallbackController Pattern For APIs, use FallbackController for consistent error handling: ```elixir defmodule MyAppWeb.FallbackController do use MyAppWeb, :controller def call(conn, {:error, %Ecto.Changeset{} = changeset}) do conn |> put_status(:unprocessable_entity) |> put_view(json: MyAppWeb.ChangesetJSON) |> render(:error, changeset: changeset) end def call(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> put_view(json: MyAppWeb.ErrorJSON) |> render(:"404") end def call(conn, {:error, :unauthorized}) do conn |> put_status(:forbidden) |> put_view(json: MyAppWeb.ErrorJSON) |> render(:"403") end end # In controller defmodule MyAppWeb.PostController do use MyAppWeb, :controller action_fallback MyAppWeb.FallbackController def show(conn, %{"id" => id}) do with {:ok, post} <- Blog.fetch_post(conn.assigns.current_scope, id) do render(conn, :show, post: post) end end end ``` ## Cross-Context Boundary Patterns When contexts have data dependencies, two approaches: ### Option A: API-Driven (Preferred) Reference other contexts by ID, call their public API: ```elixir def create_order(%Scope{} = scope, user_id, product_ids) do with {:ok, user} <- Accounts.fetch_user(scope, user_id) do do_create_order(scope, user.id, product_ids) end end ``` ### Option B: DB Joins (When Performance Requires) Use `belongs_to` for cross-context schema references: ```elixir # In ShoppingCart.CartItem schema "cart_items" do field :price_when_carted, :decimal field :quantity, :integer belongs_to :cart, ShoppingCart.Cart belongs_to :product, Catalog.Product # Cross-context ref timestamps(type: :utc_datetime) end ``` ### Upsert for Cross-Context Operations ```elixir def add_item_to_cart(%Scope{} = scope, %Cart{} = cart, product_id) do true = cart.user_id == scope.user.id # Scope enforcement! product = Catalog.get_product!(product_id) %CartItem{quantity: 1, price_when_carted: product.price} |> CartItem.changeset(%{}) |> Ecto.Changeset.put_assoc(:cart, cart) |> Ecto.Changeset.put_assoc(:product, product) |> Repo.insert( on_conflict: [inc: [quantity: 1]], conflict_target: [:cart_id, :product_id] ) end ``` ### Cross-Context Preloading ```elixir def get_cart(%Scope{} = scope) do Repo.one( from(c in Cart, where: c.user_id == ^scope.user.id, left_join: i in assoc(c, :items), left_join: p in assoc(i, :product), order_by: [asc: i.inserted_at], preload: [items: {i, product: p}] ) ) end ``` ### Database Integrity at Boundaries Use cascade delete for cross-context FK constraints: ```elixir add :cart_id, references(:carts, on_delete: :delete_all) add :product_id, references(:products, on_delete: :delete_all) ``` Keep data integrity in the database, not application code. ## Subcontext Pattern (for large contexts) ```elixir # lib/accounts/subcontexts/users.ex (internal) defmodule MyApp.Accounts.Users do @moduledoc false def list_users(scope), do: Repo.all(scoped_query(User, scope)) end # lib/accounts.ex (public API) defmodule MyApp.Accounts do defdelegate list_users(scope), to: MyApp.Accounts.Users end ``` -
json-api-patterns.md 4.4 KB
# JSON and API Patterns Reference ## JSON Controller Pattern ```elixir defmodule MyAppWeb.PostController do use MyAppWeb, :controller action_fallback MyAppWeb.FallbackController def index(conn, _params) do posts = Blog.list_posts(conn.assigns.current_scope) render(conn, :index, posts: posts) end def create(conn, %{"post" => post_params}) do with {:ok, %Post{} = post} <- Blog.create_post(conn.assigns.current_scope, post_params) do conn |> put_status(:created) |> put_resp_header("location", ~p"/api/posts/#{post}") |> render(:show, post: post) end end def show(conn, %{"id" => id}) do post = Blog.get_post!(conn.assigns.current_scope, id) render(conn, :show, post: post) end def update(conn, %{"id" => id, "post" => post_params}) do post = Blog.get_post!(conn.assigns.current_scope, id) with {:ok, %Post{} = post} <- Blog.update_post(conn.assigns.current_scope, post, post_params) do render(conn, :show, post: post) end end def delete(conn, %{"id" => id}) do post = Blog.get_post!(conn.assigns.current_scope, id) with {:ok, %Post{}} <- Blog.delete_post(conn.assigns.current_scope, post) do send_resp(conn, :no_content, "") end end end ``` ## JSON View Pattern ```elixir defmodule MyAppWeb.PostJSON do alias MyApp.Blog.Post def index(%{posts: posts}) do %{data: for(post <- posts, do: data(post))} end def show(%{post: post}) do %{data: data(post)} end defp data(%Post{} = post) do %{ id: post.id, title: post.title, body: post.body, inserted_at: post.inserted_at } end end ``` ## FallbackController Centralize error handling for `with` chains: ```elixir defmodule MyAppWeb.FallbackController do use MyAppWeb, :controller def call(conn, {:error, %Ecto.Changeset{} = changeset}) do conn |> put_status(:unprocessable_entity) |> put_view(json: MyAppWeb.ChangesetJSON) |> render(:error, changeset: changeset) end def call(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> put_view(json: MyAppWeb.ErrorJSON) |> render(:"404") end def call(conn, {:error, :unauthorized}) do conn |> put_status(:forbidden) |> put_view(json: MyAppWeb.ErrorJSON) |> render(:"403") end end ``` ## ChangesetJSON ```elixir defmodule MyAppWeb.ChangesetJSON do def error(%{changeset: changeset}) do %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} end defp translate_error({msg, opts}) do Regex.replace(~r"%{(\w+)}", msg, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end end ``` ## API Authentication Pipeline ```elixir # In router.ex pipeline :api do plug :accepts, ["json"] plug :fetch_current_scope_for_api_user end scope "/api", MyAppWeb do pipe_through :api resources "/posts", PostController, except: [:new, :edit] end ``` ### Bearer Token Authentication ```elixir def fetch_current_scope_for_api_user(conn, _opts) do with ["Bearer " <> token] <- get_req_header(conn, "authorization"), {:ok, user} <- Accounts.fetch_user_by_api_token(token) do assign(conn, :current_scope, Scope.for_user(user)) else _ -> conn |> put_status(:unauthorized) |> put_view(json: MyAppWeb.ErrorJSON) |> render(:"401") |> halt() end end ``` ## Multi-Format Controllers Serve both HTML and JSON from the same controller: ```elixir defmodule MyAppWeb.PostController do use MyAppWeb, :controller plug :put_format, :json when action in [:api_index] def index(conn, _params) do posts = Blog.list_posts(conn.assigns.current_scope) render(conn, :index, posts: posts) end # Separate view modules: PostHTML and PostJSON end ``` ## API Versioning ```elixir scope "/api/v1", MyAppWeb.V1 do pipe_through :api resources "/posts", PostController end scope "/api/v2", MyAppWeb.V2 do pipe_through :api resources "/posts", PostController end ``` ## Anti-patterns | Wrong | Right | |-------|-------| | Render HTML errors for API | Use JSON FallbackController | | No `action_fallback` | Always set FallbackController | | Return `Repo.insert` directly | Use `with` chain in controller | | Include sensitive fields in JSON | Explicit `data/1` function | | No `Location` header on create | Set `put_resp_header("location", ...)` | -
plug-patterns.md 3.7 KB
# Plug Patterns Reference ## Plug Types ### Function Plug Accept `conn` + `opts`, return `conn`. Defined in the module where used: ```elixir plug :authenticate defp authenticate(conn, _opts) do if conn.assigns[:current_user] do conn else conn |> put_flash(:error, "Must log in") |> redirect(to: ~p"/login") |> halt() end end ``` Call `halt()` after redirect in auth plugs — without it, downstream plugs still execute. ### Module Plug Implement `init/1` (compile-time) and `call/2` (runtime): ```elixir defmodule MyAppWeb.Plugs.Locale do import Plug.Conn @default_locale "en" def init(opts), do: Keyword.get(opts, :default, @default_locale) def call(conn, default_locale) do locale = conn.params["locale"] || default_locale assign(conn, :locale, locale) end end # Usage in router pipeline plug MyAppWeb.Plugs.Locale, default: "en" ``` **Optimization**: `init/1` runs at compile time. Put expensive setup there, not in `call/2`. ## Plug Placement | Location | Scope | Example | |----------|-------|---------| | Endpoint | Every request | Static files, session, parsers | | Router pipeline | Route group | Auth, API token validation | | Controller | Action-specific | Resource loading, authorization | ### Controller-Level Plug Guards ```elixir defmodule MyAppWeb.PostController do use MyAppWeb, :controller # Only for specific actions plug :fetch_post when action in [:show, :edit, :update, :delete] plug :authorize when action in [:edit, :update, :delete] defp fetch_post(conn, _opts) do post = Blog.get_post!(conn.assigns.current_scope, conn.params["id"]) assign(conn, :post, post) end defp authorize(conn, _opts) do if conn.assigns.post.user_id == conn.assigns.current_scope.user.id do conn else conn |> put_flash(:error, "Unauthorized") |> redirect(to: ~p"/") |> halt() end end end ``` ## Endpoint Plug Order Default endpoint plugs run in this order: ```elixir # 1. Static assets (before anything else) plug Plug.Static, at: "/", from: :my_app # 2. Request metadata plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] # 3. Body parsing plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() # 4. HTTP method override (for PUT/PATCH/DELETE from forms) plug Plug.MethodOverride # 5. Content negotiation plug Plug.Head # 6. Session plug Plug.Session, @session_options # 7. Router (last) plug MyAppWeb.Router ``` ## Common Plug Patterns ### Rate Limiting Plug ```elixir defmodule MyAppWeb.Plugs.RateLimit do import Plug.Conn def init(opts), do: opts def call(conn, opts) do key = rate_limit_key(conn, opts) limit = Keyword.get(opts, :limit, 60) window = Keyword.get(opts, :window_ms, 60_000) case MyApp.RateLimit.check(key, limit, window) do :ok -> conn :rate_limited -> conn |> put_resp_header("retry-after", "60") |> send_resp(429, "Too Many Requests") |> halt() end end defp rate_limit_key(conn, opts) do case Keyword.get(opts, :by, :ip) do :ip -> "rate:#{:inet.ntoa(conn.remote_ip)}" :user -> "rate:user:#{conn.assigns[:current_user]&.id}" end end end ``` ### CORS Plug ```elixir # Use CORSPlug with explicit origins (never wildcard in prod) plug CORSPlug, origin: [ "https://app.example.com", "https://admin.example.com" ] ``` ## Anti-patterns | Wrong | Right | |-------|-------| | No `halt()` after redirect | Always `halt()` after redirect | | Expensive work in `init/1` DB calls | `init/1` for config only, DB in `call/2` | | Auth in endpoint (runs for static) | Auth in router pipeline | | All plugs in endpoint | Split by pipeline scope | -
routing-patterns.md 2.6 KB
# Routing Patterns Reference ## Verified Routes (~p sigil) - Phoenix 1.7+ ```elixir # ALWAYS use verified routes ~p"/posts/#{@post}" ~p"/search?#{%{q: user_input}}" # Auto URL-encoded # DON'T use path helpers (deprecated) Routes.post_path(conn, :show, post) ``` ## Pipeline Design ```elixir pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {MyAppWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers plug :fetch_current_user plug :fetch_current_scope end pipeline :api do plug :accepts, ["json"] plug :fetch_current_scope_for_api_user end scope "/", MyAppWeb do pipe_through [:browser, :require_authenticated_user] live "/dashboard", DashboardLive end ``` ## Anti-patterns (DON'T DO THESE) ### Rails/Ruby Patterns (Not Phoenix) ```elixir # WRONG: Service objects defmodule MyApp.Services.UserCreationService do def call(params), do: ... end # WRONG: Concerns defmodule MyApp.Concerns.Authenticatable do # Rails ActiveSupport::Concern pattern end # WRONG: Decorators/Presenters defmodule MyApp.Decorators.UserDecorator do def full_name(user), do: ... end # WRONG: Interactors/Commands defmodule MyApp.Interactors.CreateUser do def call(params), do: ... end # RIGHT: Context functions defmodule MyApp.Accounts do def create_user(scope, params), do: ... def authenticate_user(email, password), do: ... end # RIGHT: View functions for presentation defmodule MyAppWeb.UserHTML do def full_name(user), do: "#{user.first_name} #{user.last_name}" end ``` ### Repository Pattern (Don't) Repo IS the repository. Don't wrap it. ### God Context (Don't) Split when > 400 lines or when domains are distinct. ### Schema Callbacks with Side Effects (Don't) Ecto removed callbacks intentionally. Use Ecto.Multi. ### Reaching Across Contexts (Don't) ```elixir # WRONG def create_order(user_id, params) do user = Repo.get!(User, user_id) # Bypassing Accounts context! end # RIGHT def create_order(%Scope{} = scope, user_id, params) do with {:ok, user} <- Accounts.get_user(scope, user_id), {:ok, order} <- do_create_order(scope, user, params) do {:ok, order} end end ``` ### Direct Repo Calls in Controllers/LiveViews (Don't) ```elixir # WRONG: Business logic in controller def show(conn, %{"id" => id}) do user = Repo.get!(User, id) |> Repo.preload(:posts) render(conn, :show, user: user) end # RIGHT: Delegate to context def show(conn, %{"id" => id}) do user = Accounts.get_user_with_posts!(conn.assigns.current_scope, id) render(conn, :show, user: user) end ``` -
scopes-auth.md 7.7 KB
# Scopes and Authorization Reference ## Contents - [Version Compatibility](#version-compatibility) - [Phoenix 1.8+ Scopes](#phoenix-18-scopes) - [Plug Patterns](#plug-patterns) - [Pre-Scopes Patterns](#pre-scopes-patterns-phoenix-17-and-earlier) ## Version Compatibility | Phoenix Version | Pattern | |----------------|---------| | 1.8+ | Built-in `%Scope{}` struct (documented below) | | 1.7 and earlier | Manual authorization context (see "Pre-Scopes Patterns" section) | **Detection**: Check `{:phoenix, "~> 1.X"}` in mix.exs to determine version. --- ## Phoenix 1.8+ Scopes Scopes provide **secure data access by default**, addressing OWASP's "Broken access control". Generated by `mix phx.gen.auth`. ```elixir defmodule MyApp.Accounts.Scope do defstruct user: nil def for_user(%User{} = user), do: %__MODULE__{user: user} def for_user(nil), do: nil end ``` ### Scope Configuration Scopes are configured in `config/config.exs`: ```elixir config :my_app, :scopes, user: [ default: true, module: MyApp.Accounts.Scope, assign_key: :current_scope, access_path: [:user, :id], schema_key: :user_id, schema_type: :id, schema_table: :users, test_data_fixture: MyApp.AccountsFixtures, test_setup_helper: :register_and_log_in_user ] ``` | Option | Purpose | |--------|---------| | `default` | Boolean, only one scope can be default | | `module` | Module defining scope struct | | `assign_key` | Key for socket/conn assigns | | `access_path` | Path to identifying field (`[:user, :id]`) | | `schema_key` | Foreign key tying resources to scope | | `schema_type` | FK field type (`:id` or `:binary_id`) | | `route_prefix` | Nested route template (multi-tenant) | ### Generator Integration With default scope configured, generators automatically: - Pass scope as first arg to context functions - Filter DB queries by scope - Subscribe to scoped PubSub in LiveViews ### Multi-Tenant Scope Augmentation Extend scope for organizations: ```elixir defmodule MyApp.Accounts.Scope do defstruct user: nil, organization: nil def for_user(%User{} = user), do: %__MODULE__{user: user} def put_organization(%__MODULE__{} = scope, %Organization{} = org) do %{scope | organization: org} end end ``` Router plug for augmentation: ```elixir def assign_org_to_scope(conn, _opts) do scope = conn.assigns.current_scope if slug = conn.params["org"] do org = Accounts.get_organization_by_slug!(scope, slug) assign(conn, :current_scope, Scope.put_organization(scope, org)) else conn end end ``` LiveView hook for augmentation: ```elixir def on_mount(:assign_org, %{"org" => slug}, _session, socket) do case socket.assigns.current_scope do %{organization: nil} = scope -> org = Accounts.get_organization_by_slug!(scope, slug) {:cont, assign(socket, :current_scope, Scope.put_organization(scope, org))} _ -> {:cont, socket} end end def on_mount(:assign_org, _params, _session, socket) do {:cont, socket} end ``` Multi-scope config for orgs: ```elixir config :my_app, :scopes, user: [default: true, ...], organization: [ module: MyApp.Accounts.Scope, assign_key: :current_scope, access_path: [:organization, :id], route_prefix: "/organizations/:org", route_access_path: [:organization, :slug], schema_key: :org_id, schema_type: :id, schema_table: :organizations, test_data_fixture: MyApp.AccountsFixtures, test_setup_helper: :register_and_log_in_user_with_org ] ``` ### Scope Helpers for IEx ```elixir def for(opts) when is_list(opts) do cond do opts[:user] && opts[:org] -> opts[:user] |> user() |> for_user() |> put_organization(org(opts[:org])) opts[:user] -> opts[:user] |> user() |> for_user() end end # Usage: MyApp.Blog.list_posts(Scope.for(user: 1, org: "foo")) ``` ## Plug Patterns ### Authentication Plug ```elixir defp fetch_current_user(conn, _opts) do case get_session(conn, :user_token) do nil -> assign(conn, :current_user, nil) token -> assign(conn, :current_user, Accounts.get_user_by_session_token(token)) end end defp require_authenticated_user(conn, _opts) do if conn.assigns[:current_user] do conn else conn |> put_flash(:error, "You must log in to access this page.") |> redirect(to: ~p"/login") |> halt() end end ``` ### Authorization Plug (action-specific) ```elixir plug :authorize_resource when action in [:edit, :update, :delete] defp authorize_resource(conn, _opts) do if Authorizer.can_access?(conn.assigns.current_user, conn.assigns.resource) do conn else conn |> put_flash(:error, "Unauthorized") |> redirect(to: ~p"/") |> halt() end end ``` ### Fetch Resource Plug ```elixir plug :fetch_message when action in [:show, :edit, :update, :delete] defp fetch_message(conn, _opts) do message = Blog.get_message!(conn.assigns.current_scope, conn.params["id"]) assign(conn, :message, message) end ``` ### API Authentication ```elixir def fetch_current_scope_for_api_user(conn, _opts) do with ["Bearer " <> token] <- get_req_header(conn, "authorization"), {:ok, user} <- Accounts.fetch_user_by_api_token(token) do assign(conn, :current_scope, Scope.for_user(user)) else _ -> conn |> send_resp(:unauthorized, "Invalid or missing token") |> halt() end end ``` --- ## Pre-Scopes Patterns (Phoenix 1.7 and earlier) For Phoenix 1.7 projects, implement authorization context manually. ### Authority Struct Pattern ```elixir # lib/my_app/accounts/authority.ex defmodule MyApp.Accounts.Authority do @moduledoc """ Authorization context for scoping operations. Equivalent to Phoenix 1.8+ Scope but manually implemented. """ defstruct [:user, :tenant_id, :permissions] @type t :: %__MODULE__{ user: MyApp.Accounts.User.t() | nil, tenant_id: integer() | nil, permissions: [atom()] } def new(user, opts \\ []) do %__MODULE__{ user: user, tenant_id: Keyword.get(opts, :tenant_id) || user && user.tenant_id, permissions: Keyword.get(opts, :permissions, []) } end def guest, do: %__MODULE__{} end ``` ### Using Authority in Contexts ```elixir defmodule MyApp.Posts do alias MyApp.Accounts.Authority @doc """ Lists posts scoped to the authority's tenant. """ def list_posts(%Authority{tenant_id: tenant_id}) when not is_nil(tenant_id) do Post |> where(tenant_id: ^tenant_id) |> Repo.all() end def list_posts(%Authority{tenant_id: nil}) do {:error, :unauthorized} end @doc """ Creates a post with author from authority. """ def create_post(%Authority{user: user, tenant_id: tenant_id}, attrs) when not is_nil(user) do %Post{author_id: user.id, tenant_id: tenant_id} |> Post.changeset(attrs) |> Repo.insert() end end ``` ### Plug for Building Authority ```elixir defmodule MyAppWeb.Plugs.BuildAuthority do import Plug.Conn alias MyApp.Accounts.Authority def init(opts), do: opts def call(conn, _opts) do authority = case conn.assigns[:current_user] do nil -> Authority.guest() user -> Authority.new(user) end assign(conn, :authority, authority) end end ``` ### In Controllers ```elixir defmodule MyAppWeb.PostController do use MyAppWeb, :controller def index(conn, _params) do case Posts.list_posts(conn.assigns.authority) do {:error, :unauthorized} -> conn |> put_status(:forbidden) |> json(%{error: "Unauthorized"}) posts -> render(conn, :index, posts: posts) end end end ``` ### Migration Path to 1.8 When upgrading to Phoenix 1.8: 1. Replace `Authority` struct with `%Scope{}` 2. Update context function signatures to accept `%Scope{}` 3. Replace `BuildAuthority` plug with generated auth assigns 4. Phoenix 1.8 generators create Scope-aware code automatically
-
-
SKILL.md 3.3 KB
--- name: phoenix-contexts description: Phoenix context design — creating/splitting contexts, Scope (1.8+), Ecto.Multi, PubSub, routers, plugs, controllers. Use when editing contexts, routers, or designing boundaries. --- # Phoenix Contexts Reference > **Ash projects**: `Ash.Domain` replaces Phoenix contexts for data access — use the `ash-framework` skill. Context boundary and PubSub patterns still apply. Reference for designing and implementing Phoenix contexts (bounded contexts). ## Iron Laws — Never Violate These 1. **CONTEXTS OWN THEIR DATA** — Never query another context's schema directly via Repo 2. **SCOPES ARE MANDATORY (Phoenix 1.8+)** — Every context function MUST accept scope as first parameter 3. **THIN CONTROLLERS/LIVEVIEWS** — Controllers translate HTTP, business logic stays in contexts 4. **NO SIDE EFFECTS IN SCHEMAS** — Use `Ecto.Multi` for transactions with side effects ## Context Structure ``` lib/my_app/ ├── accounts/ # Context directory │ ├── user.ex # Schema │ ├── scope.ex # Scope struct (Phoenix 1.8+) ├── accounts.ex # Context module (public API) ``` ## Phoenix 1.8+ Scopes (CRITICAL) Context functions take the scope as their first parameter, so queries are filtered to the caller: ```elixir def list_posts(%Scope{} = scope) do from(p in Post, where: p.user_id == ^scope.user.id) |> Repo.all() end def create_post(%Scope{} = scope, attrs) do %Post{user_id: scope.user.id} |> Post.changeset(attrs) |> Repo.insert() |> broadcast(scope, :created) end ``` ## Quick Decisions ### When to SPLIT contexts? - Module exceeds ~400 lines - Functions don't share domain language - Could theoretically be a separate microservice - Team member could own it independently ### When to KEEP together? - Resources share vocabulary and domain concepts - Functions frequently operate on same data together - Splitting would create excessive cross-context calls ### Cross-Context References ```elixir # ✅ Reference by ID, convert at boundary def create_order(%Scope{} = scope, user_id, product_ids) do with {:ok, user} <- Accounts.fetch_user(scope, user_id) do do_create_order(scope, user.id, product_ids) end end # ❌ Reaching into other context's internals alias MyApp.Accounts.User # Don't do this Repo.all(from o in Order, join: u in User, ...) # Don't query other schemas ``` ## Anti-patterns | Wrong | Right | |-------|-------| | Service objects (`UserCreationService`) | Context functions (`Accounts.create_user/2`) | | Repository pattern wrapping Repo | Repo IS the repository | | Direct Repo calls in controllers | Delegate to context | | Schema callbacks with side effects | Use Ecto.Multi | ## Version Notes - **Phoenix 1.8+**: Uses built-in `%Scope{}` struct for authorization context - **Phoenix 1.7**: Requires manual authorization context (see `references/scopes-auth.md` "Pre-Scopes Patterns") ## References For detailed patterns, see: - `references/context-patterns.md` - Full context module, PubSub, Multi, cross-boundary - `references/scopes-auth.md` - Scope struct, multi-tenant, authorization, plugs - `references/routing-patterns.md` - Verified routes, pipelines, API auth - `references/plug-patterns.md` - Function/module plugs, placement, guards - `references/json-api-patterns.md` - JSON controllers, FallbackController, API auth
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.