api-design
REST vs GraphQL, API versioning, rate limiting, and documentation for Elixir/BEAM APIs
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.
| name | api-design |
|---|---|
| description | REST 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:
- Ash Igniter: https://github.com/ash-project/igniter
- Ash Official Docs: https://hexdocs.pm/ash
- Nix Specialist:
roles/nix-specialist.md - Phoenix Storybook: https://github.com/phenixdigital/phoenix_storybook/fork