ActiveRecordCompose
ActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface. It makes complex updates - such as user registration forms spanning multiple tables - easier to write, validate, and maintain.
Table of Contents
- Motivation
- Installation
- Quick Start
- Advanced Usage
- Sample Application
- Links
- Development
- Contributing
- License
- Code of Conduct
Motivation
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 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.
Installation
To install active_record_compose
, just put this line in your Gemfile:
gem 'active_record_compose'
Then bundle
$ bundle
Quick Start
Basic Example
Suppose 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
end
You can compose them into one form object:
class UserRegistration < ActiveRecordCompose::Model
def initialize
@account = Account.new
@profile = @account.build_profile
super()
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
SendEmailConfirmationJob.perform_later(account)
end
end
Usage:
registration = UserRegistration.new
registration.update!(
name: "foo",
email: "bar@example.com",
firstname: "taro",
lastname: "yamada",
age: 18,
email_confirmation: "bar@example.com",
terms_of_service: true,
)
Both Account
and Profile
will be updated atomically in one transaction.
Attribute Delegation
delegate_attribute
allows transparent access to attributes of inner models:
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profile
They are also included in #attributes
:
registration.attributes
# => {
# "terms_of_service" => true,
# "email" => nil,
# "name" => "foo",
# "age" => nil,
# "firstname" => nil,
# "lastname" => nil
# }
Unified Error Handling
Validation errors from inner models are collected into the composed model:
user_registration = UserRegistration.new(
email: "foo@example.com",
email_confirmation: "BAZ@example.com",
age: 18,
terms_of_service: true,
)
user_registration.save # => false
user_registration.errors.
# => [
# "Name can't be blank",
# "Firstname can't be blank",
# "Lastname can't be blank",
# "Email confirmation doesn't match Email"
# ]
I18n Support
When #save!
raises ActiveRecord::RecordInvalid
,
make sure you have locale entries such as:
en:
activemodel:
errors:
messages:
record_invalid: 'Validation failed: %{errors}'
Advanced Usage
Destroy Option
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? })
Callback ordering with #persisted?
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
end
Example:
# 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_save
Notes on adding models dynamically
Avoid 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 }
end
In 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.
Sample Application
Try it out in your browser with GitHub Codespaces (or locally):
Links
Development
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.
Contributing
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.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the ActiveRecord::Compose project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.