class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
attribute :password, :string
attribute :plan, :string, default: "free"
validates :account_name, presence: true, length: { maximum: 60 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :plan, inclusion: { in: %w[free pro enterprise] }
validate :password_complexity
attr_reader :account, :user
def save
return false unless valid?
ActiveRecord::Base.transaction do
@account = Account.create!(name: account_name, plan: plan)
@user = @account.users.create!(email: email, password: password, role: :owner)
end
true
rescue ActiveRecord::RecordInvalid => e
promote_errors(e.record)
false
end
private
def password_complexity
return if password.blank?
return if password.match?(/\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}\z/)
errors.add(:password, "must be 10+ chars with upper, lower, and a digit")
end
def promote_errors(record)
record.errors.each do |error|
errors.add(error.attribute, error.message)
end
end
end
class SignupsController < ApplicationController
def new
@signup = SignupForm.new
end
def create
@signup = SignupForm.new(signup_params)
if @signup.save
sign_in(@signup.user)
redirect_to dashboard_path, notice: "Welcome to #{@signup.account.name}!"
else
flash.now[:alert] = "Please fix the errors below."
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.require(:signup_form)
.permit(:account_name, :email, :password, :plan)
end
end
<%= form_with model: @signup, url: signups_path, scope: :signup_form do |f| %>
<% if f.object.errors.any? %>
<div class="form-errors">
<% f.object.errors.full_messages.each do |msg| %>
<p><%= msg %></p>
<% end %>
</div>
<% end %>
<div class="field">
<%= f.label :account_name %>
<%= f.text_field :account_name, autofocus: true %>
</div>
<div class="field">
<%= f.label :email %>
<%= f.email_field :email %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password %>
</div>
<div class="field">
<%= f.label :plan %>
<%= f.select :plan, %w[free pro enterprise] %>
</div>
<%= f.submit "Create account" %>
<% end %>
This snippet demonstrates the form object pattern, a way to move multi-model creation logic out of a fat Rails controller and into a dedicated object that speaks ActiveModel. The controller stays shallow — it parses params and dispatches — while a plain Ruby object owns the validation rules, the deep parameter shape, and the persistence that spans several tables.
The SignupForm tab defines the form. It includes ActiveModel::Model and ActiveModel::Attributes so the object behaves like an ActiveRecord model to Rails view helpers and controllers: it responds to valid?, exposes errors, and can be handed to form_with. The attributes here — account_name, email, password, plan — deliberately do not match one table; they span an Account and its owning User. Validations that would otherwise be smeared across two models and a controller live in one place, including a custom password_complexity check. The save method wraps both record writes in a single ActiveRecord::Base.transaction so a half-created account can never leak; if either model fails, promote_errors copies the record-level messages back onto the form so the same view can render them.
The key trade-off is that the form object duplicates a little validation that also exists on the models, but in exchange the controller and the models each stay focused. A form object is the right reach when one HTTP request must create or update several records atomically, or when the accepted params do not cleanly map to a single model.
The SignupsController tab shows the payoff: create builds the form from signup_params, calls save, and branches on the boolean. There is no Account.new, no nested build, no transaction, and no cross-model orchestration in the controller. The signup_params method still uses strong parameters, but permits the flat shape the form expects rather than a deep nested hash.
The new signup form view tab renders the form with form_with model:, and because the object quacks like a model, f.text_field and form.object.errors work unchanged. The password_complexity regex and the promote_errors mapping are the two spots most worth studying, since they are where the pattern earns its keep.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.