Claude Skill

phoenix-contexts

Phoenix context design — creating/splitting contexts, Scope (1.8+), Ecto.Multi, PubSub, routers, plugs, controllers. Use when editing contexts, routers, or designing boundaries.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download oliver-kriska-claude-elixir-phoenix-plugins_elixir-phoenix_skills_phoenix-contexts-9767a82.zip · 11 KB
Part of oliver-kriska/claude-elixir-phoenix — 93 skills

Install

skills CLI npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/plugins/elixir-phoenix/skills/phoenix-contexts
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
Git 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.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:

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 ${CLAUDE_SKILL_DIR}/references/scopes-auth.md "Pre-Scopes Patterns")

References

For detailed patterns, see:

  • ${CLAUDE_SKILL_DIR}/references/context-patterns.md - Full context module, PubSub, Multi, cross-boundary
  • ${CLAUDE_SKILL_DIR}/references/scopes-auth.md - Scope struct, multi-tenant, authorization, plugs
  • ${CLAUDE_SKILL_DIR}/references/routing-patterns.md - Verified routes, pipelines, API auth
  • ${CLAUDE_SKILL_DIR}/references/plug-patterns.md - Function/module plugs, placement, guards
  • ${CLAUDE_SKILL_DIR}/references/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.5 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."
    effort: medium
    user-invocable: false
    ---
    
    # 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 `${CLAUDE_SKILL_DIR}/references/scopes-auth.md` "Pre-Scopes Patterns")
    
    ## References
    
    For detailed patterns, see:
    
    - `${CLAUDE_SKILL_DIR}/references/context-patterns.md` - Full context module, PubSub, Multi, cross-boundary
    - `${CLAUDE_SKILL_DIR}/references/scopes-auth.md` - Scope struct, multi-tenant, authorization, plugs
    - `${CLAUDE_SKILL_DIR}/references/routing-patterns.md` - Verified routes, pipelines, API auth
    - `${CLAUDE_SKILL_DIR}/references/plug-patterns.md` - Function/module plugs, placement, guards
    - `${CLAUDE_SKILL_DIR}/references/json-api-patterns.md` - JSON controllers, FallbackController, API auth
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related