Building Many-To-Many Associations with Embedded Schemas in Ecto and Phoenix
To recap, we implemented a registration form that saves User, Account, and Membership records in one transaction.
The Trouble with Nested Forms
Nested forms are useful in many situations, and Ecto’s design approach makes them easy to work with. Changesets give us contextual validations, and cast_assoc/3 allows us to cast and validate nested associations explicitly and with operation-specific changesets.
Despite these benefits, nested forms have inherent design problems. They couple our view layer with our database schema, lead to complex markup in our templates, and bloat our models with business logic and validations that are often contextual. The last point is especially true when the params structure from a form doesn’t map directly to the database schema. We saw this while adding the role requirement in my previous post.
Such problems are even more apparent in Rails, where much of this stuff happens implicitly and we are forced to litter our validations with conditional logic to handle different validation contexts.
Embedded Schemas to the Rescue
In Ecto, embedded schemas allow us to build a schema that is specific to an operation. An embedded schema is not backed by a database table and doesn’t have to be persisted. In Rails, its loose equivalent is a form object.
Let’s create an embedded schema in app/models to cast and validate our form parameters, then pass them on to their respective schemas for persistence, or return any errors.
There’s a lot of code here, and we could have lumped it all in the controller, but it’s straight forward. The
to_multi/1 function uses Ecto.Multi to group our database operations in a single queue. We call it from the controller and pass it the form parameters.
We extracted three private functions. The first two,
user_changeset/1, build and return Account and User changesets respectively. They receive form parameters from
to_multi and extract their respective fields.
The third function,
membership_changeset/1, is similar to the earlier two, only it uses the results of Ecto.Multi.insert to build its changeset.
Let’s look the registration controller.
We modified the new action to use the Registration changeset. We also modified our create action to handle the form submission, using the
to_multi function we wrote in the Registration schema.
Let’s recount the steps.
- First, we ensure the registration changeset is valid. If not, we render the new template.
- If the changeset is valid, we call
to_multiwith the form parameters and inside a transaction.
- If the transaction succeeds, we’re done. Otherwise, we copy errors from the failing changeset back to the registration changeset in order to display them in the UI.
If you are wondering why we would have errors in step three given that we validate the registration changeset in step one, the answer is simple: Errors that come from association and uniqueness constraints only appear after the application makes a trip to the database. Since the registration schema isn’t backed by a database table, it has no way of knowing that an email isn’t unique. When a transaction fails, we copy any errors that result from such constraints from the failing changeset to the registration changeset.
The template is also simpler since we removed the nested
inputs_for. The form based on the Registration schema is flat.
We transitioned from using cast_assoc/3 and nested forms to using an embedded schema and Ecto.Multi. We wrote more lines of code, but we centralised our registration logic in one location and reduced indirection.
As usual, each approach brings benefits and has tradeoffs. The best approach is always the one that best suits your needs. But it’s great to know that in Phoenix and Ecto we have great tools at our disposal.
A big thank you to José Valim, the creator of Elixir and Ecto, for his helpful guidance and suggestions.
Read more on embedded schemas.