F6.03 · part 2 of 3
Changesets & validation
A changeset is the gate every write passes through, and it is a pure pipeline —
data in, a changeset struct out, no side effects. You cast an untrusted map to permit and
coerce only the fields you name; you validate the rules — required, length, format,
number; and you declare constraints that the database enforces, like uniqueness. The result is a
%Ecto.Changeset{} carrying valid? and a list of
errors, computed before anything touches the database. Because it is pure, you can test a
changeset without a repo and reuse it for both inserts and updates. And the engine’s boundary stays clean:
a failed changeset becomes the closed %Portal.Error{} from F5.08, so the web layer sees
one error shape, never raw Ecto internals.
See the three stages of the pipeline, follow a map through to a valid-or-invalid changeset, then read a real changeset and how the engine wraps its errors.
The changeset pipeline
Three stages, each a function that takes a changeset and returns one. cast decides
which fields are even allowed; validate_* functions check values in Elixir; a
constraint defers a check the database is best at, like uniqueness, and turns its error into a changeset error.
Select a stage.
The pipeline · select a stage
stage: cast
does: permit & coerce fields
cast is your security boundary: a field not in its allow-list is dropped, so
a client cannot set :published or an internal flag by smuggling it into the params.
Valid or invalid
The pipeline always returns a changeset; what differs is its valid? flag. A valid
changeset is what you hand to Repo.insert; an invalid one carries
errors and never reaches the database, so the same value drives both the write and the
error message a form shows.
params → cast → validate → %Changeset{valid?}
One value, two destinations. The valid changeset is the write; the invalid one is the error report
— and the engine maps it to the closed error set so the controller branches on
%Portal.Error{}, not on Ecto.
A real changeset
A changeset function takes a struct and the attributes and pipes through the stages. At the engine boundary,
Repo.insert returns {:ok, struct} or
{:error, changeset}, and the engine translates the error case into the closed
contract — the same Portal.Error the F5.08 facade already speaks.
import Ecto.Changeset def changeset(course, attrs) do course |> cast(attrs, [:title, :published]) # permit only these fields |> validate_required([:title]) |> validate_length(:title, min: 3, max: 120) |> unique_constraint(:title) # enforced by the database on insert end # at the engine boundary: a failed changeset becomes the closed contract (F5.08) case Repo.insert(changeset(%Course{}, attrs)) do {:ok, course} -> {:ok, course} {:error, changeset} -> {:error, Portal.Error.from_changeset(changeset)} end
cast & validate
A pure pipeline produces a changeset with valid? and errors.
one error shape
The engine maps a failed changeset to %Portal.Error{} — never raw Ecto.
Next: queries & the repo — the
Repo.insert above is one repo call, and the repo lives behind the engine’s port.