ActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface. More than just a simple form object, it is designed as a business-oriented composed model that encapsulates complex operations-such as user registration spanning multiple tables-making them easier to write, validate, and maintain.
- Motivation
- Installation
- Quick Start
- Advanced Usage
- Sample Application
- Links
- Development
- Contributing
- License
- Code of Conduct
In Rails, ActiveRecord::Base is responsible for persisting data to the database.
By defining validations and callbacks, you can model use cases effectively.
However, when a single model must serve multiple different use cases, you often end up with conditional validations (on: :context) or workarounds like save(validate: false).
This mixes unrelated concerns into one model, leading to unnecessary complexity.
ActiveModel::Model helps here — it provides the familiar API (attribute, errors, validations, callbacks) without persistence, so you can isolate logic per use case.
ActiveRecordCompose builds on ActiveModel::Model and is a powerful business object that acts as a first-class model within Rails.
- Transparently accesses attributes across multiple models
- Saves all associated models atomically in a transaction
- Collects and exposes error information consistently
This leads to cleaner domain models, better separation of concerns, and fewer surprises in validations and callbacks.
To install active_record_compose, just put this line in your Gemfile:
gem 'active_record_compose'Then bundle
$ bundleSuppose you have two models:
class Account < ApplicationRecord
has_one :profile
validates :name, :email, presence: true
end
class Profile < ApplicationRecord
belongs_to :account
validates :firstname, :lastname, :age, presence: true
endYou can compose them into one form object:
class UserRegistration < ActiveRecordCompose::Model
def initialize(attributes = {})
@account = Account.new
@profile = @account.build_profile
super(attributes)
models << account << profile
end
attribute :terms_of_service, :boolean
validates :terms_of_service, presence: true
validates :email, confirmation: true
after_commit :send_email_message
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profile
private
attr_reader :account, :profile
def send_email_message
SendEmailConfirmationJob.perform_later(account)
end
endUsage:
# === Standalone script ===
registration = UserRegistration.new
registration.update!(
name: "foo",
email: "[email protected]",
firstname: "taro",
lastname: "yamada",
age: 18,
email_confirmation: "[email protected]",
terms_of_service: true,
)
# `#update!` SQL log
# BEGIN immediate TRANSACTION
# INSERT INTO "accounts" ("created_at", "email", "name", "updated_at") VALUES (...
# INSERT INTO "profiles" ("account_id", "age", "created_at", "firstname", "lastname", ...
# COMMIT TRANSACTION
# === Or, in a Rails controller with strong parameters ===
class UserRegistrationsController < ApplicationController
def create
@registration = UserRegistration.new(user_registration_params)
if @registration.save
redirect_to root_path, notice: "Registered!"
else
render :new
end
end
private
def user_registration_params
params.require(:user_registration).permit(
:name, :email, :firstname, :lastname, :age, :email_confirmation, :terms_of_service
)
end
endBoth Account and Profile will be updated atomically in one transaction.
delegate_attribute allows transparent access to attributes of inner models:
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profileThey are also included in #attributes:
registration.attributes
# => {
# "terms_of_service" => true,
# "email" => nil,
# "name" => "foo",
# "age" => nil,
# "firstname" => nil,
# "lastname" => nil
# }Validation errors from inner models are collected into the composed model:
user_registration = UserRegistration.new(
email: "[email protected]",
email_confirmation: "[email protected]",
age: 18,
terms_of_service: true,
)
user_registration.save # => false
user_registration.errors.full_messages
# => [
# "Name can't be blank",
# "Firstname can't be blank",
# "Lastname can't be blank",
# "Email confirmation doesn't match Email"
# ]When #save! raises ActiveRecord::RecordInvalid,
make sure you have locale entries such as:
en:
activemodel:
errors:
messages:
record_invalid: 'Validation failed: %{errors}'For more complete usage patterns, see the Sample Application below.
models.push(profile, destroy: true)This deletes the model on #save instead of persisting it.
Conditional deletion is also supported:
models.push(profile, destroy: -> { profile_field_is_blank? })The result of #persisted? determines which callbacks are fired:
persisted? == false-> create callbacks (before_create,after_create, ...)persisted? == true-> update callbacks (before_update,after_update, ...)
This matches the behavior of normal ActiveRecord models.
class ComposedModel < ActiveRecordCompose::Model
before_save { puts "before_save" }
before_create { puts "before_create" }
before_update { puts "before_update" }
after_create { puts "after_create" }
after_update { puts "after_update" }
after_save { puts "after_save" }
def persisted?
account.persisted?
end
endExample:
# When persisted? == false
model = ComposedModel.new
model.save
# => before_save
# => before_create
# => after_create
# => after_save
# When persisted? == true
model = ComposedModel.new
def model.persisted?; true; end
model.save
# => before_save
# => before_update
# => after_update
# => after_saveAvoid adding models to the models array after validation has already run
(for example, inside after_validation or before_save callbacks).
class Example < ActiveRecordCompose::Model
before_save { models << AnotherModel.new }
endIn this case, the newly added model will not run validations for the current save cycle. This may look like a bug, but it is the expected behavior: validations are only applied to models that were registered before validation started.
We intentionally do not restrict this at the framework level, since there may be valid advanced use cases where models are manipulated dynamically. Instead, this behavior is documented here so that developers can make an informed decision.
The sample app demonstrates a more complete usage of ActiveRecordCompose (e.g., user registration flows involving multiple models). It is not meant to cover every possible pattern, but can serve as a reference for putting the library into practice.
Try it out in your browser with GitHub Codespaces (or locally):
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/hamajyotan/active_record_compose. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the ActiveRecord::Compose project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.