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.

Gem Version CI Ask DeepWiki

Table of Contents

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 <<  << 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()
  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.full_messages
# => [
#   "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?
    .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):

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.