Validation is the worst kind of duplicated code. Once you have it in the controller and the context and the view and the test fixture, a single schema change means four places to update, three of which youβll miss. Ecto changesets solve this by giving you one boundary: input trusted if the changeset is valid, rejected otherwise, nowhere else allowed to check.
#The rule
Every write path goes through a changeset. Every changeset is the only place that checks the input.
Break that rule and validation drifts. Enforce it and the context becomes a thin layer over changeset/2.
#The shape
defmodule PrismaticDD.Entity do
use Ecto.Schema
import Ecto.Changeset
schema "entities" do
field :name, :string
field :ico, :string
field :country, :string
field :tier, Ecto.Enum, values: [:t1, :t2, :t3, :t4]
timestamps()
end
def changeset(entity, attrs) do
entity
|> cast(attrs, [:name, :ico, :country, :tier])
|> validate_required([:name, :country, :tier])
|> validate_length(:name, min: 1, max: 500)
|> validate_format(:ico, ~r/^\d{8}$/, message: "must be 8 digits")
|> validate_inclusion(:country, ~w(CZ SK DE AT PL))
|> unique_constraint([:ico, :country])
end
endThe invariant βevery entity has a valid ICO for its countryβ is encoded in the changeset, not in a helper function, not in a service layer, not in the view. A caller that wants a different rule writes a different changeset β strict_changeset/2 for imports, public_changeset/2 for web forms β but each of those is still the sole authority for its path.
#Cast at the boundary, not inside
# β Inside the context β too late
def create(attrs) do
attrs = Map.put(attrs, :country, String.upcase(attrs[:country]))
%Entity{} |> Entity.changeset(attrs) |> Repo.insert()
end
# β
At the changeset β the normalization is part of the contract
def changeset(entity, attrs) do
attrs = normalize_country(attrs)
entity
|> cast(attrs, [:name, :ico, :country, :tier])
|> ...
endNormalization that happens outside the changeset is normalization that other callers can skip. Put it inside and every caller gets it for free.
Input sanitization is not validation
Sanitization (stripping dangerous HTML, trimming whitespace, normalizing unicode) happens BEFORE validation β but still inside the changeset, via a prepare_changes hook. It is not a separate step some callers can forget.
#When to add multiple changesets
When two call sites have genuinely different rules. Public web form vs admin import vs API. Each gets a named function β public_changeset/2, import_changeset/2, api_changeset/2 β and the call site picks the right one. What you do not do is put an if web? inside one changeset.
#Where to go next
- Academy: Storage Patterns β changesets in context modules
- Glossary: Ecto, Changeset, Validation, Input Sanitization, Invariant
One boundary. One authority. Delete the duplicated checks and stop worrying about drift.