Parameter Validation Practices in Elixir
Programs can only handle expected data, but user input often exceeds expectations. Parameter validation is the first line of defense for system security. This article introduces a multi-layer parameter validation approach in Elixir/Phoenix projects.
Requirements
- Erlang 28+
- Elixir 1.19+
- Phoenix 1.18+
- Cookiecutter 2.6+
Initialize Project
cookiecutter git@github.com:seangong0/cookiecutter-phoenix.git \
--no-input \
app_name=validator_demo \
use_sqlite=y
cd validator_demo
mix setup
This article covers parameter validation across the following layers:
- Data Model Layer — Ecto.Changeset basic validation
- Context Layer — interface definitions and type constraints
- Controller Layer — dedicated validation module
- Response Format — unified API response structure
Data Model Layer Validation
First, create the User data model with Ecto.Changeset for basic validation:
@primary_key {:id, :binary_id, autogenerate: true}
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :password_hash, :string
field :nickname, :string
timestamps()
end
def create_changeset(user, attrs) do
user
|> cast(attrs, [:email, :password, :nickname])
|> unique_constraint(:email)
|> hash_password_if_present()
end
This layer leverages Ecto.Changeset to handle database-level constraints.
Context Layer Interface Definitions
The Context module defines the following functions for use by the Controller:
def get_user!(id :: Ecto.UUID.t()) :: User.t()
def get_user(id :: Ecto.UUID.t()) :: User.t() | nil
def list_users() :: [User.t()]
def create_user(attrs :: map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def authenticate_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :invalid_password | :user_not_found}
Controller Layer Validation
Writing validation logic directly in actions leads to verbose, hard-to-maintain code:
def create(conn, %{"nickname" => nickname}) do
if String.length(nickname) >= 2 and String.length(nickname) <= 50 do
ok(conn, _)
else
bad_request(conn, %{error: "name length must be between 2 and 50"})
end
end
Dedicated Validation Module
Create a dedicated validation module lib/validator_demo_web/validators/user_validator.ex:
defmodule ValidatorDemoWeb.Validators.UserValidator do
use Ecto.Schema
import Ecto.Changeset
@email_regex ~r/^[^\s]+@[^\s]+$/
embedded_schema do
field :email, :string
field :password, :string
field :nickname, :string
end
def validate_create(params) do
%__MODULE__{}
|> cast(params, [:email, :password, :nickname])
|> validate_required([:email, :password, :nickname])
|> validate_format(:email, @email_regex, message: "must be a valid email")
|> validate_length(:password, min: 8)
|> validate_length(:nickname, min: 2, max: 50)
|> handle_changeset(:insert)
end
def validate_update(params) do
%__MODULE__{}
|> cast(params, [:password, :nickname])
|> validate_length(:password, min: 6)
|> validate_length(:nickname, min: 2, max: 50)
|> handle_changeset(:update)
end
def validate_uuid(id) when is_binary(id) do
case Ecto.Type.cast(Ecto.UUID, id) do
{:ok, _} -> :ok
:error -> {:error, "invalid UUID format"}
end
end
def validate_uuid(_id), do: {:error, "id must be a UUID"}
defp handle_changeset(changeset, action) do
case apply_action(changeset, action) do
{:ok, result} ->
{:ok, Map.from_struct(result)}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, changeset, :validation}
error ->
error
end
end
end
Using embedded_schema to define the validation structure and the validate_ family of functions for validation rules. Returns {:ok, data} on success, and {:error, changeset, :validation} to flag parameter errors.
Usage in Controller
def create(conn, unsafe_params) do
with {:ok, params} <- UserValidator.validate_create(unsafe_params),
{:ok, user} <- Accounts.create_user(params) do
data = UserJSON.render("user.json", user: user)
created(conn, data, ~p"/api/users/#{user.id}/")
else
{:error, %Ecto.Changeset{} = changeset, :validation} ->
bad_request(conn, changeset)
{:error, %Ecto.Changeset{} = changeset} ->
unprocessable_entity(conn, changeset)
{:error, reason} when is_atom(reason) ->
unprocessable_entity(conn, reason)
end
end
The :validation tag distinguishes parameter errors from database errors, making it easier for front-end debugging.
Unified Response Format
Define a common response structure for consistent front-end handling:
{
"status_code": 200,
"success": true,
"errors": [],
"data": "your data"
}
Summary
This article covered a multi-layer parameter validation approach:
| Layer | Responsibility |
|---|---|
| Data Model Layer | Database constraints (required, unique, length) |
| Context Layer | Interface type constraints |
| Controller Layer | Business parameter validation |
| Response Layer | Unified error format |
Each layer has its own duty, intercepting invalid data step by step. If you prefer not to implement validation logic yourself, check out the community library goal.
Complete example code: validator_demo