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:

  1. Data Model Layer — Ecto.Changeset basic validation
  2. Context Layer — interface definitions and type constraints
  3. Controller Layer — dedicated validation module
  4. 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