Skip to content
yf_
← Writing

Nested validation in Rails 8 without manual loops

3 min read
  • 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 INSERT query.

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.