api-design

layeddie's avatarfrom layeddie

REST vs GraphQL, API versioning, rate limiting, and documentation for Elixir/BEAM APIs

⭐0stars🔀0forks📁View on GitHub🕐Updated Jan 3, 2026

When & Why to Use This Skill

This Claude skill provides expert guidance on designing, implementing, and documenting high-performance APIs specifically for the Elixir and BEAM ecosystem. It offers comprehensive patterns for REST and GraphQL architectures, robust versioning strategies, rate limiting, and efficient data handling to ensure scalable, maintainable, and developer-friendly web services.

Use Cases

  • Architectural Decision Making: Evaluate and choose between REST and GraphQL based on specific project requirements such as type safety, real-time subscriptions, and caching needs.
  • API Lifecycle Management: Implement URL, Header, or Semantic versioning and deprecation strategies to manage evolving APIs without breaking existing client integrations.
  • Traffic Control and Security: Set up rate-limiting mechanisms using the Token Bucket algorithm and Elixir Plugs to protect endpoints from abuse and ensure service stability.
  • Automated Documentation: Generate standardized API documentation using OpenAPI (Swagger), Absinthe, or ExDoc to improve developer experience and integration speed.
  • Data Efficiency Optimization: Apply best practices for cursor-based pagination, parameterized filtering, and structured error handling to optimize performance and reduce LLM token consumption.
nameapi-design
descriptionREST vs GraphQL, API versioning, rate limiting, and documentation for Elixir/BEAM APIs

API Design Skill

Use this skill when:

  • Designing API endpoints
  • Choosing between REST and GraphQL
  • Implementing API versioning strategies
  • Setting up rate limiting
  • Creating API documentation
  • Defining pagination and filtering

API Architecture Patterns

REST vs GraphQL

REST API

# ✅ Good: Resource-based routing
defmodule MyAppWeb.API.UsersController do
  use MyAppWeb, :controller

  action :index, %{"page" => page} do
    users = MyApp.Users.list_users(page: page, per_page: 20)
    render(conn, "index.json", %{users: users, page: page})
  end

  action :show, %{"id" => id} do
    user = MyApp.Users.get(id)
    render(conn, "show.json", %{user: user})
  end
  end
end

# ❌ Bad: Over-fetching N+1
defmodule MyAppWeb.API.BadUsersController do
  use MyAppWeb, :controller

  action :show, %{"id" => id} do
    user = MyApp.Users.get(id)  # Single query
    comments = MyApp.Comments.by_user(id)  # N+1 query!
    render(conn, "show.json", %{user: user, comments: comments})
  end
  end
end

GraphQL API

# ✅ Good: Type-safe schema with Absinthe
defmodule MyAppWeb.Schema do
  use Absinthe.Schema

  @desc "A user object"
  object :user do
    field :id, non_null(:id), :id
    field :name, non_null(:name), :string
    field :email, non_null(:email), :string
    field :comments, list_of(:comment)
  end

  @desc "All users query"
  query :users, type: :user do
    arg :limit, :integer
    resolve fn %{limit: limit}, _, _ do
      MyApp.Users.list_users(limit: limit)
    end
  end
end

Decision Criteria

Requirement REST GraphQL
Simple CRUD ✅ Better ⚠️ Overkill
Complex queries ⚠️ Multiple requests ✅ Single request
Type safety ⚠️ Runtime ✅ Compile-time
Over-fetching ✅ Explicit queries ⚠️ Requires careful design
Caching ✅ Easy (HTTP) ⚠️ Requires field-level
Introspection ✅ Easy (OpenAPI) ⚠️ Requires tools
Mobile ✅ Mature ⚠️ Growing
Real-time ⚠️ Polling ✅ Subscriptions

Recommendation

Use REST when:

  • Simple CRUD operations
  • File uploads
  • Mobile clients
  • Need type safety not critical
  • Caching at HTTP level

Use GraphQL when:

  • Complex nested queries
  • Multiple data sources in single request
  • Type safety critical
  • Real-time subscriptions needed
  • Field-level caching beneficial

API Versioning

URL Versioning

# Version in URL path
defmodule MyAppWeb.Router do
  scope "/api/v1", MyAppWeb do
    pipe_through :api

    resources "/users", UserController
  end

  scope "/api/v2", MyAppWeb do
    pipe_through :api

    resources "/users", UserControllerV2
  end
end

Header Versioning

defmodule MyAppWeb.APIVersionMiddleware do
  @behaviour Plug

  def init(opts), do: opts

  def call(conn, opts) do
    version = Application.get_env(:my_app, :api_version, "1.0")
    conn
    |> put_resp_header("api-version", version)
    |> put_resp_header("content-type", "application/json")
    |> assign(:api_version, version)
  end
end

Semantic Versioning

# Major version for breaking changes
defmodule MyAppWeb.Schemas.UserV1 do
  use Ecto.Schema

  embedded_schema, primary_key: false do
    field :name, :string
    field :email, :string
  end

  embedded_schema, primary_key: true do
    field :id, :id
    field :name, :string
    field :email, :string
    field :preferences, :map
  end
end

defmodule MyAppWeb.Schemas.UserV2 do
  use Ecto.Schema

  embedded_schema, primary_key: true do
    field :id, :id
    field :name, :string
    field :email, :string
    field :preferences, :map
    field :avatar_url, :string
  end
end

defmodule MyAppWeb.API.UsersController do
  def index(conn, params) do
    version = get_req_header(conn, "api-version")
    users = case version do
      "1.0" -> MyApp.Users.list_users_v1()
      "2.0" -> MyApp.Users.list_users_v2()
      _ -> MyApp.Users.list_users_v1()
    end
    render(conn, "index.json", %{users: users, version: version})
  end
end

Deprecation Strategy

defmodule MyAppWeb.DeprecationMiddleware do
  @behaviour Plug

  def init(opts), do: opts

  def call(conn, opts) do
    path = request_path(conn)
    version = get_req_header(conn, "api-version")
    
    if path in ["/api/v1/old_endpoint"] do
      conn
      |> put_resp_header("x-api-deprecation-date", "2024-01-01")
      |> put_resp_header("x-api-deprecated", "true")
      |> put_resp_header("x-api-replaced-by", "api/v2")
      |> put_resp_header("x-api-replacement-date", "2024-06-01")
    end
    
    assign(conn, :deprecation_warning, version && path in deprecation_paths())
  end

  defp deprecation_paths do
    Application.get_env(:my_app, :deprecation_paths, [])
  end
end

Rate Limiting

Token Bucket Algorithm

defmodule MyApp.RateLimiter do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{})
  end

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call({:check_rate_limit, user_id}, _from, state) do
    key = "user:#{user_id}"
    case :ets.lookup_element(:rate_limiter_table, key) do
      {:miss, count} -> {:reply, {:ok, true, count + 1}, state}
      {:hit, count} when count < 10 -> {:reply, {:ok, false, count + 1}, state}
      {:hit, count} -> {:reply, {:ok, false, count + 1}, state}  # Rate limited
    end
  end

  @impl true
  def handle_cast({:reset, user_id}, _from, state) do
    key = "user:#{user_id}"
    :ets.delete_element(:rate_limiter_table, key)
    {:noreply, state}
  end

  defp schedule_reset(user_id, delay_ms) do
    Process.send_after(self(), {:reset, user_id}, delay_ms)
  end
end

Plug Integration

defmodule MyAppWeb.RateLimiterPlug do
  use Plug

  def init(opts) do
    opts
  end

  def call(conn, opts) do
    user_id = get_user_id(conn)
    
    case MyApp.RateLimiter.check_rate_limit(user_id) do
      {:ok, :allowed, remaining} ->
        conn
        |> put_resp_header("x-ratelimit-remaining", to_string(remaining))
        |> put_resp_header("x-ratelimit-reset", to_string(60))
        assign(conn, :rate_limited, false)
      
      {:ok, :limited, _remaining} ->
        conn
        |> put_resp_header("retry-after", to_string(60))
        |> assign(:rate_limited, true)
        |> halt()
    end
  end
end

API Documentation

OpenAPI Integration

# Use Absinthe.Plug for auto-documentation
defmodule MyAppWeb.APIRouter do
  use Absinthe.Plug,
      spec: "priv/api_spec.json",
      for: MyAppWeb

  pipeline :api do
    plug :accepts, ["application/json"]
    plug :rate_limiter
  end

  scope "/api", MyAppWeb do
    resources "/users", UsersController
  end
end

API Spec Example (priv/api_spec.json)

{
  "openapi": "3.0.0",
  "info": {
    "title": "My App API",
    "version": "1.0.0"
  },
  "paths": {
    "/users": {
      "get": {
        "summary": "List all users",
        "operationId": "listUsers",
        "responses": {
          "200": {
            "description": "Users list response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Create new user",
        "operationId": "createUser",
        "requestBody": {
          "required": ["name", "email"],
          "schema": {
            "type": "object",
            "properties": {
              "name": {"type": "string"},
              "email": {"type": "string"}
            }
          }
        }
      }
    }
  }
}

ExDoc Documentation

# Document public modules
defmodule MyAppWeb.API do
  @moduledoc """
  API module for user management.
  
  ## Endpoints
  
  ### GET /api/users
  Lists all users with pagination.
  
  ### GET /api/users/:id
  Get a specific user by ID.
  
  ### POST /api/users
  Creates a new user.
  
  ### PUT /api/users/:id
  Updates an existing user.
  
  ### DELETE /api/users/:id
  Deletes a user.
  
  ## Authentication
  
  All endpoints require a valid JWT token.
  Use the Authorization header with format: `Bearer <token>`.
  
  ## Rate Limiting
  
  Default rate limits:
  - 10 requests per minute per user
  - 100 requests per hour per user
  
  ## Errors
  
  Standard error responses:
  - 400: Bad Request - Invalid input
  - 401: Unauthorized - Missing or invalid token
  - 403: Forbidden - Insufficient permissions
  - 404: Not Found - Resource not found
  - 429: Too Many Requests - Rate limit exceeded
  - 500: Internal Server Error
  """
end

Best Practices

1. Consistent Response Format

# ✅ Good: Consistent structure
def render_success(conn, data) do
  conn
  |> put_status(200)
  |> json(%{success: true, data: data})
end

# ❌ Bad: Inconsistent responses
def render_success(conn, data) do
  case data do
    %{users: users} -> json(conn, %{status: "success", users: users})
    %{error: error} -> json(conn, %{error: error})
  end
end

2. Proper HTTP Status Codes

Code Usage
200 ✅ Success
201 ✅ Created
204 ✅ No Content
400 ✅ Bad Request
401 ✅ Unauthorized
403 ✅ Forbidden
404 ✅ Not Found
409 ✅ Conflict
429 ✅ Too Many Requests
500 ✅ Internal Error

3. Pagination

# ✅ Good: Cursor-based pagination
defmodule MyApp.Users do
  alias MyApp.Repo

  def list_users(page: page, per_page: 20) do
    offset = (page - 1) * per_page
    query = from(u in User, order_by: [asc: u.inserted_at], limit: ^per_page, offset: ^offset)
    Repo.all(query)
  end
end

# ❌ Bad: Offset-based pagination (easier for unlimited scrolling)
defmodule MyApp.Users do
  alias MyApp.Repo

  def list_users(offset: 0, limit: 20) do
    query = from(u in User, order_by: [asc: u.inserted_at], limit: ^limit, offset: ^offset)
    Repo.all(query)
  end
end

4. Filtering and Sorting

# ✅ Good: Parameterized filters
defmodule MyApp.Users do
  alias MyApp.Repo

  def list_users(filters: %{"search" => search, "role" => role}) do
  query = from(u in User,
      where: [ilike: ^filters.search, u.role == ^filters.role],
      order_by: [asc: u.name])
    Repo.all(query)
  end
end

# ❌ Bad: String interpolation (SQL injection risk)
defmodule MyApp.Users do
  alias MyApp.Repo

  def list_users(search: search, role: role) do
    query = "SELECT * FROM users WHERE name LIKE '%#{search}%' AND role = '#{role}'"
    Repo.query(query)
  end
end

5. Error Handling

# ✅ Good: Structured errors with typespecs
defmodule MyApp.API.Error do
  @moduledoc """
  Structured API error responses.
  """
  
  @type t :: :error
  @type reason :: :string

  @enforce_keys [type: :atom]
  
  @spec new(t, reason, changeset) :: t()
  def new(type, reason, changeset) do
    %__MODULE__{
      type: type,
      reason: reason,
      changeset: changeset
    }
  end
end

# ❌ Bad: Generic error responses
defmodule MyAppWeb.API do
  def render_error(message) do
    json(conn, %{error: message})
  end
end

Token Efficiency

Use API design patterns for:

  • Single request optimization (~30% token savings vs multiple requests)
  • Explicit data selection (~40% token savings vs returning all fields)
  • Cursor-based pagination (~50% token savings vs offset-based)
  • Efficient filtering (~35% token savings vs returning all results)

Tools to Use

  • Absinthe: Automatic OpenAPI spec generation
  • ExDoc: Comprehensive documentation generation
  • Phoenix.Swagger: Alternative API documentation
  • Plug: Request/response processing
  • Plug.CSRFProtection: CSRF token validation
  • Joken: JWT authentication and validation
  • Ecto: Database queries with parameterization

Ash Igniter Integration

Critical: Nix environment must match what Igniter expects

When using Igniter with Ash:

  • Architect Phase: Consult Nix specialist for environment setup
  • Orchestrator Phase: Set up Nix devshell with correct Elixir version
  • Implementation Phase: Ensure Ash packages work with Nix-provided Elixir
  • Review Phase: Verify Ash package works with selected version

Nix specialist provides:

  • Guidance on version selection for Igniter compatibility
  • Multiple environment configurations (stable vs testing)
  • Version testing strategies before full implementation

Workflow:

# 1. Nix Specialist (Planning)
nix-specialist:
  "We need Ash 3.4+ for this project"
  "Nix 1.17+ supports this"

# 2. Architect (Design with Igniter guidance)
architect:
  "Use Igniter to explore Ash 3.4+"
  "Nix specialist confirmed 1.17+ compatibility"

# 3. Orchestrator (Implementation)
orchestrator:
  "Setting up Nix devshell"
  "Use Ash 3.4+ from Nix"

# 4. Reviewer (Verify)
reviewer:
  "Check Ash package compatibility"
  "Verify Nix environment"

Resources: