Nested validation in Rails 8 without manual loops
- Ruby on Rails
- Rails 8
- Architecture
When I worked at Efalia Engage (formerly Fluicity), one of the core features I built was a form builder that let admins construct complex surveys. The UI allowed building a deeply nested object tree: a Survey containing multiple Question items, each with several Option choices.
The frontend packages that entire structure into a single JSON payload. Saving the tree in one request sounds simple, but managing validations across three levels gets messy quickly.
The manual loop approach
The most obvious path is writing nested loops inside a database transaction.
# app/controllers/surveys_controller.rb
def create
payload = params.require(:survey).permit(
:title,
questions: [:text, options: [:text, :correct]]
)
ActiveRecord::Base.transaction do
survey = Survey.create!(title: payload[:title])
payload[:questions].each do |q_params|
question = survey.questions.create!(text: q_params[:text])
q_params[:options].each do |o_params|
question.options.create!(text: o_params[:text], correct: o_params[:correct])
end
end
end
redirect_to survey_path(survey)
rescue ActiveRecord::RecordInvalid => e
render :new, status: :unprocessable_entity, flash: { error: e.message }
end
It works for valid input. The problems show up on edge cases.
The friction with manual persistence
Three immediate issues show up:
- Opaque error mapping. When an option fails validation,
create!raises an exception. Extracting which specific option failed and mapping that back to a form field takes extra parsing logic. - Param synchronization. Every schema change forces updates across strong parameters, loop logic, and response mappers.
- Query bloat. Each question and option issues its own
INSERTquery.
It’s code I’d rather not maintain as the hierarchy grows.
The Rails-native solution
Rails added accepts_nested_attributes_for in version 2.3 back in 2009. It handles mass assignment, nested instantiation, and error bubbling automatically.
Here is the model setup:
# app/models/survey.rb
class Survey < ApplicationRecord
has_many :questions, inverse_of: :survey, dependent: :destroy, index_errors: true
accepts_nested_attributes_for :questions,
reject_if: ->(attrs) { attrs[:text].blank? },
allow_destroy: true
validates :title, presence: true
end
# app/models/question.rb
class Question < ApplicationRecord
belongs_to :survey, inverse_of: :questions
has_many :options, inverse_of: :question, dependent: :destroy, index_errors: true
accepts_nested_attributes_for :options,
reject_if: ->(attrs) { attrs[:text].blank? },
allow_destroy: true
validates :text, presence: true
end
# app/models/option.rb
class Option < ApplicationRecord
belongs_to :question, inverse_of: :options
validates :text, presence: true
validates :correct, inclusion: { in: [true, false] }
end
Strong parameters use the _attributes suffix:
# app/controllers/surveys_controller.rb
def survey_params
params.require(:survey).permit(
:title,
questions_attributes: [
:id, :_destroy, :text,
options_attributes: [:id, :_destroy, :text, :correct]
]
)
end
The controller shrinks to standard REST boilerplate:
def create
@survey = Survey.new(survey_params)
if @survey.save
redirect_to @survey, notice: "Survey created."
else
render :new, status: :unprocessable_entity
end
end
Preserving error positions for the frontend
By default, ActiveRecord attaches validation errors to collection associations using flat keys like "questions.text". That doesn’t tell the frontend which question or option in an array broke the rule.
Setting index_errors: true on the has_many associations fixes that. It prefixes error keys with zero-based indexes:
{
"errors": {
"questions[1].options[0].text": ["can't be blank"]
}
}
The error key maps directly to question index 1, option index 0. The UI highlights the exact field without guessing.
What this replaces
The incoming payload shifts to using questions_attributes and options_attributes:
{
"survey": {
"title": "Community Feedback",
"questions_attributes": [
{
"text": "What is your primary goal?",
"options_attributes": [
{ "text": "Improve urban mobility", "correct": false },
{ "text": "Enhance green spaces", "correct": true }
]
}
]
}
}
Calling @survey.save runs validations top-to-bottom across the tree. If any option is invalid, @survey.save returns false and populates @survey.errors with the indexed paths.
What I gain from this setup:
- Zero iteration code in the controller.
- Positional error keys out of the box.
- Atomic persistence managed in a single transaction.
- Adding a fourth nesting layer requires one model line, not a rewritten loop.