Your Ecto changeset is allowed to be boring
I've written changesets that were trying very hard to be impressive.
You know the type. A schema has a changeset/2, then a registration_changeset/2, then an admin_changeset/2. After that comes a little module that builds errors for the frontend, another module that knows which fields are visible on which screen, and one more helper because the embedded form is "a bit special."
At some point the code stops feeling like validation and starts feeling like a tiny form framework that nobody remembers designing.
Annoyingly, the boring version was usually enough.
Ecto changesets already have a clear job. They take data you do not trust yet, cast the fields you are willing to accept, validate the result, and keep the errors close to the data. That is enough for most forms.
The temptation to build a form system
The slippery slope rarely starts with a grand architecture plan. It starts with a reasonable request.
"This field is required on signup, but not when an admin edits the user."
Fine. Add a second changeset.
Then another one appears.
"This API endpoint needs a slightly different error shape."
Still reasonable. Add a helper.
Then a third.
"This embedded form needs to validate three fields before we create the actual record."
Before long, the changeset is no longer the source of truth. It is one stop in a pipeline of mapping, translating, grouping, decorating, and massaging.
The code still works, which is part of the problem. Every new field has to be added in four places. Every error message has a little journey before anyone sees it.
That is usually when I try to ask the dull question again: what does Ecto already give me?
More than I usually remember when I'm halfway through inventing a tiny framework.
Start with the fields you accept
The most important line in many changesets is the least exciting one:
elixirdef changeset(user, attrs) douser|> cast(attrs, [:name, :email, :timezone])|> validate_required([:name, :email])|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)end
cast/4 is the boundary. It says: these are the fields this operation accepts from the outside world.
That line is worth keeping boring and explicit.
If a form posts role: "admin" and :role is not in the cast list, it does not become a change. You do not need a custom sanitizer for that. The changeset already has the list of fields you meant to allow.
There is a nice side effect too: the code reads like the form.
Name, email, timezone. Name and email required. Email shaped like an email.
Nothing clever is hiding behind a private DSL with vocabulary you have to relearn six months later.
Different operation, different changeset
Not every operation has the same rules, and that is fine. The mistake is pretending one changeset has to cover every path.
User registration is not the same as an account settings update:
elixirdef registration_changeset(user, attrs) douser|> cast(attrs, [:name, :email, :password, :timezone])|> validate_required([:name, :email, :password])|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)|> validate_length(:password, min: 12)|> unique_constraint(:email)enddef profile_changeset(user, attrs) douser|> cast(attrs, [:name, :timezone])|> validate_required([:name])end
Two changesets. Two names. Two clear reasons to exist.
That beats one generic changeset/3 with a context option that slowly grows a small command language:
elixir# This starts neat, then somehow becomes the new framework.User.changeset(user, attrs, mode: :registration, validate_email: true, require_password: true)
Options are not bad. But if the option changes what the operation is, a separate function is usually easier to read, test, and delete later.
Put internal changes where they happen
Some data should not come from the user at all.
Maybe you normalize an email address. Maybe you store who invited the user. Maybe you stamp a derived value the form should never be allowed to submit.
That is where put_change/3 is useful:
elixirdef registration_changeset(user, attrs, inviter) douser|> cast(attrs, [:name, :email, :password])|> validate_required([:name, :email, :password])|> update_change(:email, &String.downcase/1)|> put_change(:invited_by_id, inviter.id)end
The split matters:
cast/4handles data from outside.put_change/3handles data your application decided.
Keeping that difference visible makes the changeset easier to trust. The cast list tells you what the user controlled. The later steps tell you what the system added.
Constraints belong next to validations
Some rules cannot be checked honestly until the database gets involved.
Email uniqueness is the classic example. You can query first, but another request can create the same email between your query and your insert. The database constraint is the thing that actually protects you.
Ecto lets the changeset know about that:
elixirdef registration_changeset(user, attrs) douser|> cast(attrs, [:name, :email, :password])|> validate_required([:name, :email, :password])|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)|> unique_constraint(:email)end
The important part is that unique_constraint/3 does not replace a database index. It turns a database error into a changeset error.
That is a good trade: the database keeps the data safe, and the changeset keeps the feedback useful.
Error messages do not need a separate universe
Frontend code often wants errors as a map. Fair enough.
That does not mean the application needs a whole error formatting layer before it has earned one.
Ecto gives you traverse_errors/2:
elixirdef translate_errors(changeset) dotraverse_errors(changeset, fn {message, opts} ->Enum.reduce(opts, message, fn {key, value}, acc ->String.replace(acc, "%{#{key}}", to_string(value))end)end)end
Given a changeset with a missing email and a short password, you get a boring map back:
elixir%{email: ["can't be blank"],password: ["should be at least 12 character(s)"]}
That is not the final answer for every product. You may need translations, error codes, nested structures, or field-specific copy later.
But start here.
The plain map is enough for most forms, tests, and JSON responses. More importantly, it keeps the validation language close to Ecto instead of inventing another error system beside it on day one.
Keep the boring parts boring
The changesets I like working with have a few things in common:
The cast list is explicit. You can see what outside data is allowed to change.
The function name describes the operation. registration_changeset/2 tells you more than changeset/3 with a bag of options.
Application-owned values are added after casting. User input and system decisions do not get mixed together.
Database constraints stay in the database. The changeset names them so errors come back in the right shape.
Errors are formatted late. Keep the changeset useful for as long as possible before turning it into whatever the controller or API needs.
None of this is advanced. That is the point.
When a changeset is boring, you can change the form without spelunking through a private abstraction. You can add a field and see where it belongs. You can test one operation without setting up the entire product in miniature.
And when the rules really do get complicated, the boring version gives you a better place to start. You will know which part became painful because there was not already a framework-shaped blanket over the whole thing.
So yes, extract when the duplication becomes real. Add helpers when they remove noise. Build a richer error layer when the product asks for it.
But let the changeset be boring first.