REST API Design
How do you design production-grade REST APIs in Elixir? This guide teaches RESTful routing conventions, API versioning strategies, authentication patterns with JWT, error response design, and pagination/filtering approaches for building robust HTTP APIs.
Why It Matters
REST API design determines how clients interact with your system. Production APIs need:
- RESTful conventions - Standard resource routing (GET /donations, POST /donations/:id)
- API versioning - Backward compatibility for evolving interfaces (/api/v1/, /api/v2/)
- Authentication - Secure access control (JWT tokens, session management)
- Error consistency - Standard error responses with proper HTTP status codes
- Pagination/filtering - Handle large datasets efficiently (limit/offset, cursor-based)
Real-world scenarios requiring robust API design:
- Financial services - Donation APIs, transaction history, account management
- E-commerce platforms - Product catalogs, order processing, inventory queries
- Mobile backends - User authentication, data sync, push notifications
- Third-party integrations - Webhook endpoints, public APIs, partner integrations
- Internal microservices - Service-to-service communication, health checks
Production question: Should you use /api/v1 prefix, JWT authentication, or cursor-based pagination? The answer depends on your versioning strategy, security requirements, and data volume.
Phoenix Router - RESTful Routing Conventions
Phoenix provides router DSL for standard REST resource routing.
resources/4 - Standard Resource Routes
# Router with RESTful resource routes
defmodule DonationAPI.Router do
use Phoenix.Router # => Import router macros
# => Provides get, post, resources
pipeline :api do
plug :accepts, ["json"] # => Only accept JSON content type
# => Type: Plug.t()
end
scope "/api/v1", DonationAPI do
pipe_through :api # => Apply :api pipeline to all routes
# => Runs :accepts plug
resources "/donations", DonationController # => Generates 7 standard routes:
# => GET /donations (index)
# => GET /donations/:id (show)
# => POST /donations (create)
# => PATCH /donations/:id (update)
# => PUT /donations/:id (update)
# => DELETE /donations/:id (delete)
# => Type: macro expansion
end
endStandard RESTful routing with single resources declaration.
Nested Resources - Related Entities
# Nested donation comments route
scope "/api/v1", DonationAPI do
pipe_through :api
resources "/donations", DonationController do
resources "/comments", CommentController # => Nested resource routes:
# => GET /donations/:donation_id/comments
# => POST /donations/:donation_id/comments
# => Parameters include :donation_id
# => Type: nested route macro
end
endNested routes automatically include parent resource ID in parameters.
Custom Routes - Non-Standard Actions
# Custom action routes
scope "/api/v1", DonationAPI do
pipe_through :api
resources "/donations", DonationController do
post "/approve", DonationController, :approve
# => POST /donations/:id/approve
# => Custom action beyond REST
# => Calls approve/2 controller action
get "/pending", DonationController, :pending, as: :pending
# => GET /donations/:id/pending
# => Named route: donation_pending_path
# => Type: custom route definition
end
endCustom routes extend standard REST actions for domain-specific operations.
Complete Example - Financial Donation API
# Production donation API router
defmodule DonationAPI.Router do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
end
scope "/api/v1", DonationAPI do
pipe_through :api
resources "/donations", DonationController, except: [:new, :edit] do
# => Exclude HTML form routes
# => Only API routes: index, show, create, update, delete
# => Type: options keyword list
post "/approve", DonationController, :approve
post "/reject", DonationController, :reject
get "/receipt", DonationController, :receipt
end
resources "/campaigns", CampaignController, only: [:index, :show]
# => Read-only campaign access
# => No create/update/delete
end
endProduction router with selective route generation and custom actions.
API Versioning - Backward Compatibility
URL Prefix Versioning - /api/v1/
# Version-based routing with URL prefixes
defmodule DonationAPI.Router do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
end
# API Version 1
scope "/api/v1", DonationAPI.V1 do # => Version 1 routes
pipe_through :api # => Namespace: DonationAPI.V1
resources "/donations", DonationController # => V1.DonationController
# => Path: /api/v1/donations
end
# API Version 2 - New fields
scope "/api/v2", DonationAPI.V2 do # => Version 2 routes
pipe_through :api # => Namespace: DonationAPI.V2
resources "/donations", DonationController # => V2.DonationController
# => Path: /api/v2/donations
# => Different implementation than V1
end
endURL prefix versioning allows parallel version support with separate controllers.
Version-Specific Controllers
# V1 controller - Original response format
defmodule DonationAPI.V1.DonationController do
use Phoenix.Controller
def show(conn, %{"id" => id}) do
donation = Donations.get_donation!(id) # => Fetch donation by ID
# => Type: %Donation{}
json(conn, %{
id: donation.id,
amount: donation.amount, # => Integer amount in cents
donor: donation.donor_name # => String donor name
}) # => V1 response format
end
end
# V2 controller - Enhanced response format
defmodule DonationAPI.V2.DonationController do
use Phoenix.Controller
def show(conn, %{"id" => id}) do
donation = Donations.get_donation!(id)
json(conn, %{
id: donation.id,
amount: %{
cents: donation.amount, # => V2: Structured amount
currency: "USD" # => V2: Added currency field
},
donor: %{
name: donation.donor_name, # => V2: Structured donor
email: donation.donor_email # => V2: Added email field
},
metadata: donation.metadata # => V2: New metadata field
}) # => V2 enhanced format
end
endSeparate controllers per version support different response structures without breaking V1 clients.
Authentication - JWT with Guardian
Guardian library provides JWT token authentication for Elixir APIs.
Guardian Configuration
# Guardian JWT configuration
defmodule DonationAPI.Guardian do
use Guardian, otp_app: :donation_api # => Guardian behavior
# => Config from :donation_api
def subject_for_token(user, _claims) do
{:ok, to_string(user.id)} # => User ID as token subject
# => Type: {:ok, String.t()}
end
def resource_from_claims(%{"sub" => id}) do
user = Accounts.get_user!(id) # => Fetch user from token subject
{:ok, user} # => Return user resource
# => Type: {:ok, %User{}}
end
endGuardian configuration defines token generation and user lookup from claims.
Authentication Pipeline
# Protected API routes with JWT authentication
defmodule DonationAPI.Router do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
end
pipeline :authenticated do
plug Guardian.Plug.Pipeline,
module: DonationAPI.Guardian, # => Guardian module to use
error_handler: DonationAPI.AuthErrorHandler
# => Custom error handler for auth failures
plug Guardian.Plug.VerifyHeader # => Extract JWT from Authorization header
# => Format: "Authorization: Bearer <token>"
plug Guardian.Plug.EnsureAuthenticated # => Halt if no valid token
# => Returns 401 if authentication fails
plug Guardian.Plug.LoadResource # => Load user from token into conn
# => Available as Guardian.Plug.current_resource(conn)
end
scope "/api/v1", DonationAPI do
pipe_through [:api, :authenticated] # => Apply both pipelines
resources "/donations", DonationController # => Protected donation routes
get "/profile", UserController, :profile # => Protected profile endpoint
end
endAuthentication pipeline validates JWT tokens and loads authenticated user.
Token Generation - Login
# Login controller generating JWT tokens
defmodule DonationAPI.SessionController do
use Phoenix.Controller
alias DonationAPI.Guardian
def create(conn, %{"email" => email, "password" => password}) do
case Accounts.authenticate(email, password) do
# => Verify email/password credentials
# => Type: {:ok, user} | {:error, reason}
{:ok, user} ->
{:ok, token, _claims} = Guardian.encode_and_sign(user)
# => Generate JWT token for user
# => Type: {:ok, String.t(), map()}
json(conn, %{
token: token, # => JWT access token
user: %{
id: user.id,
email: user.email,
name: user.name
}
})
{:error, _reason} ->
conn
|> put_status(401) # => 401 Unauthorized
|> json(%{error: "Invalid credentials"})
end
end
endLogin endpoint validates credentials and returns JWT token for authenticated requests.
Protected Controller Actions
# Controller accessing authenticated user
defmodule DonationAPI.DonationController do
use Phoenix.Controller
alias DonationAPI.Guardian.Plug
def create(conn, params) do
user = Plug.current_resource(conn) # => Get authenticated user from conn
# => Type: %User{}
# => Loaded by Guardian pipeline
case Donations.create_donation(user, params) do
{:ok, donation} ->
conn
|> put_status(201) # => 201 Created
|> json(%{data: donation})
{:error, changeset} ->
conn
|> put_status(422) # => 422 Unprocessable Entity
|> json(%{errors: format_errors(changeset)})
end
end
endProtected actions access authenticated user from connection.
Error Responses - Consistent Error Handling
Standard Error Format
# Fallback controller for consistent errors
defmodule DonationAPI.FallbackController do
use Phoenix.Controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(404) # => 404 Not Found
|> json(%{
error: %{
code: "not_found", # => Machine-readable error code
message: "Resource not found", # => Human-readable message
details: nil # => Optional error details
}
})
end
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(422) # => 422 Unprocessable Entity
|> json(%{
error: %{
code: "validation_error",
message: "Validation failed",
details: format_changeset_errors(changeset)
# => Field-level validation errors
# => Type: %{field: [error_message]}
}
})
end
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(401) # => 401 Unauthorized
|> json(%{
error: %{
code: "unauthorized",
message: "Authentication required"
}
})
end
defp format_changeset_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end) # => Convert changeset errors to map
# => Type: %{atom() => [String.t()]}
end
endFallback controller provides consistent error response format across all endpoints.
Using Fallback Controller
# Controller with action_fallback
defmodule DonationAPI.DonationController do
use Phoenix.Controller
action_fallback DonationAPI.FallbackController
# => Catch non-conn returns
# => Delegate to fallback controller
def show(conn, %{"id" => id}) do
case Donations.get_donation(id) do
nil -> {:error, :not_found} # => Return error tuple
# => Fallback handles response
donation -> json(conn, %{data: donation}) # => Return conn (no fallback)
end
end
def create(conn, params) do
case Donations.create_donation(params) do
{:ok, donation} ->
conn
|> put_status(201)
|> json(%{data: donation})
{:error, changeset} ->
{:error, changeset} # => Fallback handles validation errors
end
end
endAction fallback automatically handles error tuples with consistent responses.
Pagination and Filtering
Basic Pagination - Offset-Based
# Donations context with pagination
defmodule DonationAPI.Donations do
import Ecto.Query
def list_donations(params \\ %{}) do
limit = Map.get(params, "limit", 20) # => Default 20 items per page
# => Type: integer()
offset = Map.get(params, "offset", 0) # => Default start from 0
# => Type: integer()
donations =
Donation
|> limit(^limit) # => Limit results
|> offset(^offset) # => Skip offset items
|> order_by([d], desc: d.inserted_at) # => Newest first
|> Repo.all() # => Execute query
# => Type: [%Donation{}]
total = Repo.aggregate(Donation, :count, :id)
# => Total count for pagination metadata
# => Type: integer()
%{
data: donations,
pagination: %{
limit: limit,
offset: offset,
total: total,
has_more: offset + limit < total # => Boolean: more pages available
}
}
end
endOffset-based pagination with metadata for page navigation.
Filtering - Query Parameters
# Donations with filtering support
def list_donations(params \\ %{}) do
limit = Map.get(params, "limit", 20)
offset = Map.get(params, "offset", 0)
query =
Donation
|> apply_status_filter(params) # => Filter by status if provided
|> apply_amount_filter(params) # => Filter by amount range
|> apply_date_filter(params) # => Filter by date range
donations =
query
|> limit(^limit)
|> offset(^offset)
|> order_by([d], desc: d.inserted_at)
|> Repo.all()
total = Repo.aggregate(query, :count, :id) # => Count filtered results
%{data: donations, pagination: %{limit: limit, offset: offset, total: total}}
end
defp apply_status_filter(query, %{"status" => status}) do
where(query, [d], d.status == ^status) # => Filter: WHERE status = ?
# => Type: Ecto.Query.t()
end
defp apply_status_filter(query, _params), do: query
defp apply_amount_filter(query, %{"min_amount" => min, "max_amount" => max}) do
query
|> where([d], d.amount >= ^min) # => Filter: amount >= min
|> where([d], d.amount <= ^max) # => Filter: amount <= max
end
defp apply_amount_filter(query, %{"min_amount" => min}) do
where(query, [d], d.amount >= ^min)
end
defp apply_amount_filter(query, _params), do: query
defp apply_date_filter(query, %{"from_date" => from_date, "to_date" => to_date}) do
query
|> where([d], d.inserted_at >= ^from_date) # => Date range filter
|> where([d], d.inserted_at <= ^to_date)
end
defp apply_date_filter(query, _params), do: queryComposable filters applied conditionally based on query parameters.
Cursor-Based Pagination - Efficient Large Datasets
# Cursor-based pagination using ID
def list_donations_cursor(params \\ %{}) do
limit = Map.get(params, "limit", 20)
after_id = Map.get(params, "after_id") # => Cursor: last seen ID
# => Type: integer() | nil
query =
case after_id do
nil ->
Donation # => First page: no cursor
|> order_by([d], desc: d.id)
cursor_id ->
Donation
|> where([d], d.id < ^cursor_id) # => Filter: ID less than cursor
# => Descending order: fetch older
|> order_by([d], desc: d.id)
end
donations =
query
|> limit(^limit + 1) # => Fetch limit + 1 to check has_more
|> Repo.all()
{results, has_more} =
case length(donations) > limit do
true ->
{Enum.take(donations, limit), true} # => More results available
# => Type: {[%Donation{}], true}
false ->
{donations, false} # => Last page
end
next_cursor =
case {has_more, List.last(results)} do
{true, %{id: id}} -> id # => Next cursor: last item ID
_ -> nil # => No next cursor (last page)
end
%{
data: results,
pagination: %{
limit: limit,
next_cursor: next_cursor,
has_more: has_more
}
}
endCursor-based pagination scales better for large datasets (no offset scan).
Controller with Pagination
# Controller exposing paginated donations
defmodule DonationAPI.DonationController do
use Phoenix.Controller
def index(conn, params) do
%{data: donations, pagination: meta} = Donations.list_donations(params)
# => Context handles pagination logic
# => Type: %{data: list(), pagination: map()}
json(conn, %{
data: donations,
meta: meta # => Include pagination metadata
# => Client uses for next page
})
end
end
# Example request: GET /api/v1/donations?limit=10&offset=20&status=approved&min_amount=1000
# => Returns 10 donations, skipping first 20, filtered by status and amount
# => Response includes pagination metadata for navigationController delegates pagination to context, returns data with metadata.
Production Patterns
Complete Donation API Example
# Production donation API with all patterns
defmodule DonationAPI.Router do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
end
pipeline :authenticated do
plug Guardian.Plug.Pipeline, module: DonationAPI.Guardian, error_handler: DonationAPI.AuthErrorHandler
plug Guardian.Plug.VerifyHeader
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource
end
# Public routes (no auth)
scope "/api/v1", DonationAPI.V1 do
pipe_through :api
post "/sessions", SessionController, :create
# => POST /api/v1/sessions (login)
# => Returns JWT token
resources "/campaigns", CampaignController, only: [:index, :show]
# => Public campaign listing
end
# Protected routes (auth required)
scope "/api/v1", DonationAPI.V1 do
pipe_through [:api, :authenticated]
resources "/donations", DonationController do
post "/approve", DonationController, :approve
get "/receipt", DonationController, :receipt
end
get "/profile", UserController, :profile
end
end
# Donation controller with all patterns
defmodule DonationAPI.V1.DonationController do
use Phoenix.Controller
alias DonationAPI.Guardian.Plug
action_fallback DonationAPI.FallbackController
def index(conn, params) do
%{data: donations, pagination: meta} = Donations.list_donations(params)
# => Pagination + filtering
json(conn, %{data: donations, meta: meta})
end
def show(conn, %{"id" => id}) do
case Donations.get_donation(id) do
nil -> {:error, :not_found} # => Fallback handles 404
donation -> json(conn, %{data: donation})
end
end
def create(conn, params) do
user = Plug.current_resource(conn) # => Get authenticated user
case Donations.create_donation(user, params) do
{:ok, donation} ->
conn
|> put_status(201)
|> json(%{data: donation})
{:error, changeset} ->
{:error, changeset} # => Fallback handles validation errors
end
end
def approve(conn, %{"donation_id" => id}) do
user = Plug.current_resource(conn)
with {:ok, donation} <- Donations.get_donation(id),
:ok <- authorize_approval(user, donation),
{:ok, approved} <- Donations.approve_donation(donation, user) do
# => with pipeline for multiple validations
# => Type: {:ok, term()} | {:error, term()}
json(conn, %{data: approved})
else
{:error, :not_found} -> {:error, :not_found}
{:error, :unauthorized} -> {:error, :unauthorized}
{:error, changeset} -> {:error, changeset}
end
end
defp authorize_approval(%{role: "admin"}, _donation), do: :ok
defp authorize_approval(_, _), do: {:error, :unauthorized}
endProduction API combining versioning, authentication, pagination, filtering, and consistent error handling.
When to Use Each Pattern
RESTful Routing:
- Standard CRUD operations (donations, users, campaigns)
- Clear resource hierarchy (donations have comments)
- Simple API clients (mobile apps, web frontends)
API Versioning:
- Public APIs with external clients (breaking changes need parallel versions)
- Long-term API contracts (v1 support during v2 migration)
- Different feature sets per version (free vs premium API tiers)
JWT Authentication:
- Stateless API servers (no session storage required)
- Mobile/SPA clients (token stored client-side)
- Microservices architecture (token includes claims for authorization)
Offset Pagination:
- Small to medium datasets (< 10k records)
- Random page access needed (jump to page 5)
- Simple UI requirements (traditional page numbers)
Cursor Pagination:
- Large datasets (millions of records)
- Infinite scroll UIs (load more pattern)
- Real-time feeds (new items don’t break pagination)
Production systems often combine patterns: JWT auth with cursor pagination for feeds, offset pagination for admin dashboards, versioned endpoints for public API.
Key Takeaways
- RESTful conventions - Use
resourcesfor standard CRUD, nested routes for relationships - URL prefix versioning -
/api/v1/with separate controllers per version - Guardian for JWT - Pipeline-based authentication with token generation
- Consistent errors - Fallback controller with standard error format
- Pagination choice - Offset for small datasets, cursor for large/real-time feeds
- Filtering composition - Composable query filters based on parameters
- Controller delegation - Controllers handle HTTP, contexts handle business logic
REST API design balances developer ergonomics (standard conventions) with production requirements (versioning, auth, performance). Phoenix and Guardian provide the primitives; your API design applies them to your domain (donations, campaigns, transactions).