Hi everyone,
I’ve been implementing RFC 9457 (Problem Details for HTTP APIs) in a Phoenix API project and wanted to share the pattern and discuss whether it makes sense to have first-class support in Phoenix/Plug. I searched the forum, Hex, and GitHub — there are zero existing packages or discussions about this in the Elixir ecosystem, while other major frameworks have already adopted it.
What is RFC 9457?
RFC 9457 (formerly RFC 7807) defines a standard JSON format for HTTP API error responses. Instead of every API inventing its own error shape, you return application/problem+json with a well-known structure:
{
"type": "https://example.com/problems/user-not-found",
"title": "Not Found",
"status": 404,
"detail": "User with ID 42 was not found.",
"instance": "/api/users/42"
}
The five standard fields are:
| Field | Required | Purpose |
|---|---|---|
type |
Yes* | URI identifying the problem type |
title |
No | Short human-readable summary |
status |
Yes | HTTP status code |
detail |
No | Human-readable explanation for this instance |
instance |
No | URI identifying this specific occurrence |
* Defaults to "about:blank" when omitted.
The spec also allows extension members — any additional fields you need (validation errors, trace IDs, etc.) are flattened as top-level JSON keys.
Who already uses it?
- Spring Boot —
ProblemDetailclass, opt-in viaspring.mvc.problemdetails=true(docs) - ASP.NET —
ProblemDetailsclass, built intoMicrosoft.AspNetCore.Mvc - FastAPI — via community packages
- Go —
rfc7807package - Rust —
problem_detailscrate - Kotlin —
kotlin-rfc9457-problem-details
Elixir has zero. No Hex package, no framework support, no forum discussion.
The current state in Phoenix
Phoenix generates ErrorJSON with ad-hoc error shapes:
# Default generated ErrorJSON
def render("404.json", _assigns) do
%{errors: %{detail: "Not Found"}}
end
Every Phoenix API project reinvents its own error format. Some use %{errors: ...}, others %{error: ...}, others %{message: ...}. Clients consuming multiple Phoenix APIs need to handle each one differently.
Proposal: opt-in Plug.ProblemDetail struct
The idea is simple — a struct that lives in Plug, available to anyone who wants to use it. No breaking changes, no migration, fully opt-in. Like Spring Boot: it’s there, you use it if you want.
The struct (~50 lines)
defmodule Plug.ProblemDetail do
@type t :: %__MODULE__{
status: pos_integer(),
type: String.t() | nil,
title: String.t() | nil,
detail: String.t() | nil,
instance: String.t() | nil,
properties: map()
}
@enforce_keys [:status]
defstruct [:type, :title, :status, :detail, :instance, properties: %{}]
@spec new(pos_integer()) :: t()
def new(status) when is_integer(status) and status > 0 do
%__MODULE__{status: status, title: Plug.Conn.Status.reason_phrase(status)}
end
@spec new(pos_integer(), keyword()) :: t()
def new(status, opts) when is_integer(status) and status > 0 and is_list(opts) do
%__MODULE__{
status: status,
type: Keyword.get(opts, :type),
title: Keyword.get(opts, :title, Plug.Conn.Status.reason_phrase(status)),
detail: Keyword.get(opts, :detail),
instance: Keyword.get(opts, :instance)
}
end
def put_type(%__MODULE__{} = pd, type) when is_binary(type), do: %{pd | type: type}
def put_title(%__MODULE__{} = pd, title) when is_binary(title), do: %{pd | title: title}
def put_detail(%__MODULE__{} = pd, detail) when is_binary(detail), do: %{pd | detail: detail}
def put_instance(%__MODULE__{} = pd, inst) when is_binary(inst), do: %{pd | instance: inst}
def put_extension(%__MODULE__{} = pd, key, value) do
%{pd | properties: Map.put(pd.properties, to_string(key), value)}
end
end
Notice it reuses Plug.Conn.Status.reason_phrase/1 — no duplicate maps needed.
JSON encoding
Implements JSON.Encoder (OTP 27+) to produce spec-compliant output:
defimpl JSON.Encoder, for: Plug.ProblemDetail do
def encode(%Plug.ProblemDetail{} = pd, encoder) do
standard =
%{"type" => pd.type || "about:blank", "status" => pd.status}
|> put_non_nil("title", pd.title)
|> put_non_nil("detail", pd.detail)
|> put_non_nil("instance", pd.instance)
# Extensions are flattened as top-level keys per RFC 9457 §4.2
# Standard fields win on name collision
pd.properties
|> Map.drop(~w(type title status detail instance))
|> Map.merge(standard)
|> encoder.(encoder)
end
defp put_non_nil(map, _key, nil), do: map
defp put_non_nil(map, key, value), do: Map.put(map, key, value)
end
Pipe-friendly usage
The API is designed for Elixir’s pipe operator:
ProblemDetail.new(422)
|> ProblemDetail.put_type("https://api.example.com/validation-error")
|> ProblemDetail.put_detail("One or more fields are invalid.")
|> ProblemDetail.put_instance(conn.request_path)
|> ProblemDetail.put_extension("errors", %{email: ["can't be blank"]})
Produces:
{
"type": "https://api.example.com/validation-error",
"title": "Unprocessable Content",
"status": 422,
"detail": "One or more fields are invalid.",
"instance": "/api/users",
"errors": {"email": ["can't be blank"]}
}
Usage in a FallbackController
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
def call(conn, {:error, :not_found}) do
problem =
Plug.ProblemDetail.new(404)
|> Plug.ProblemDetail.put_detail("Resource not found.")
|> Plug.ProblemDetail.put_instance(conn.request_path)
conn
|> put_resp_content_type("application/problem+json")
|> put_status(404)
|> json(problem)
end
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
problem =
Plug.ProblemDetail.new(422)
|> Plug.ProblemDetail.put_detail("Validation failed.")
|> Plug.ProblemDetail.put_instance(conn.request_path)
|> Plug.ProblemDetail.put_extension("errors", translate_errors(changeset))
conn
|> put_resp_content_type("application/problem+json")
|> put_status(422)
|> json(problem)
end
end
What this is NOT
- Not a breaking change — existing
%{errors: ...}responses continue working - Not a framework opinion — it’s a value type in Plug, like
Plug.Conn.Status - Not a new dependency — zero external deps, ~50 lines of code
- Not mandatory — you use it if you want, ignore it otherwise
Open questions
1. Where should it live?
- Plug (
Plug.ProblemDetail) — since it’s an HTTP-level concern and Plug already ownsPlug.Conn.Status - Phoenix — as part of the generated
ErrorJSONpattern - Both — struct in Plug, usage pattern in Phoenix generators
2. Should phx.new generate with it?
Options:
- Yes, by default — new projects get RFC 9457 out of the box
- Yes, with a flag —
mix phx.new --problem-details - No — just document the pattern, let people adopt manually
3. Content-type helper
Should Plug include a small helper for application/problem+json, similar to how it has helpers for other content types? Something like:
def put_problem_content_type(conn) do
put_resp_content_type(conn, "application/problem+json")
end
4. Jason vs JSON module
The JSON.Encoder protocol is OTP 27+ (Elixir 1.18+). For broader compatibility, should it also implement Jason.Encoder? Or is Elixir 1.18+ a reasonable minimum for new Plug features?
Reference implementation
I built a working example in a Phoenix API project to validate the pattern end-to-end:
drink_water on GitHub — includes:
ProblemDetailstruct with full test suite (47 tests)ErrorCatalogmodule for project-specific error mapping with Gettext i18nFallbackControllerthat returnsapplication/problem+jsonfor all error pathsErrorJSONthat handles both business errors and unhandled exceptions- Rate limiter that returns RFC 9457 responses with
retry-afterheader
Next steps
Depending on the feedback here, I’d be happy to:
- Submit a PR to Plug with the
Plug.ProblemDetailstruct - Submit a PR to Phoenix with documentation and/or generator changes
- Just share the implementation as a reference for anyone interested
Looking forward to hearing your thoughts — especially from anyone who has dealt with API error standardization in Phoenix. Is this something the community would find useful, or do people prefer keeping error formats project-specific?






















