{"id":15504076,"url":"https://github.com/hamajyotan/active_record_compose","last_synced_at":"2025-09-05T13:49:01.612Z","repository":{"id":215774727,"uuid":"738135024","full_name":"hamajyotan/active_record_compose","owner":"hamajyotan","description":"activermodel (activerecord) form object pattern.","archived":false,"fork":false,"pushed_at":"2024-10-09T14:11:33.000Z","size":80,"stargazers_count":10,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2024-10-09T15:52:25.523Z","etag":null,"topics":["activerecord","activerecord-models","rails","ruby"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hamajyotan.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-01-02T14:05:01.000Z","updated_at":"2024-10-09T14:07:23.000Z","dependencies_parsed_at":"2024-01-23T19:01:20.918Z","dependency_job_id":"16f747ee-78bb-48af-b964-47435c939a7f","html_url":"https://github.com/hamajyotan/active_record_compose","commit_stats":null,"previous_names":["hamajyotan/active_record_compose"],"tags_count":19,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hamajyotan%2Factive_record_compose","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hamajyotan%2Factive_record_compose/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hamajyotan%2Factive_record_compose/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hamajyotan%2Factive_record_compose/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hamajyotan","download_url":"https://codeload.github.com/hamajyotan/active_record_compose/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":250487643,"owners_count":21438629,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","activerecord-models","rails","ruby"],"created_at":"2024-10-02T09:15:43.839Z","updated_at":"2025-09-05T13:49:01.591Z","avatar_url":"https://github.com/hamajyotan.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# ActiveRecordCompose\n\nActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface.\nIt makes complex updates - such as user registration forms spanning multiple tables - easier to write, validate, and maintain.\n\n[![Gem Version](https://badge.fury.io/rb/active_record_compose.svg)](https://badge.fury.io/rb/active_record_compose)\n![CI](https://github.com/hamajyotan/active_record_compose/workflows/CI/badge.svg)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hamajyotan/active_record_compose)\n\n## Table of Contents\n\n- [Motivation](#motivation)\n- [Installation](#installation)\n- [Quick Start](#quick-start)\n  - [Basic Example](#basic-example)\n  - [Attribute Delegation](#attribute-delegation)\n  - [Unified Error Handling](#unified-error-handling)\n  - [I18n Support](#i18n-support)\n- [Advanced Usage](#advanced-usage)\n  - [Destroy Option](#destroy-option)\n  - [Callback ordering with `#persisted?`](#callback-ordering-with-persisted)\n  - [Notes on adding models dynamically](#notes-on-adding-models-dynamically)\n- [Sample Application](#sample-application)\n- [Links](#links)\n- [Development](#development)\n- [Contributing](#contributing)\n- [License](#license)\n- [Code of Conduct](#code-of-conduct)\n\n## Motivation\n\nIn Rails, `ActiveRecord::Base` is responsible for persisting data to the database.\nBy defining validations and callbacks, you can model use cases effectively.\n\nHowever, 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)`.\nThis mixes unrelated concerns into one model, leading to unnecessary complexity.\n\n`ActiveModel::Model` helps here — it provides the familiar API (`attribute`, `errors`, validations, callbacks) without persistence, so you can isolate logic per use case.\n\n**ActiveRecordCompose** builds on `ActiveModel::Model` and acts as a first-class model within Rails:\n- Transparently accesses attributes across multiple models\n- Saves all associated models atomically in a transaction\n- Collects and exposes error information consistently\n\nThis leads to cleaner domain models, better separation of concerns, and fewer surprises in validations and callbacks.\n\n## Installation\n\nTo install `active_record_compose`, just put this line in your Gemfile:\n\n```ruby\ngem 'active_record_compose'\n```\n\nThen bundle\n\n```sh\n$ bundle\n```\n\n## Quick Start\n\n### Basic Example\n\nSuppose you have two models:\n\n```ruby\nclass Account \u003c ApplicationRecord\n  has_one :profile\n  validates :name, :email, presence: true\nend\n\nclass Profile \u003c ApplicationRecord\n  belongs_to :account\n  validates :firstname, :lastname, :age, presence: true\nend\n```\n\nYou can compose them into one form object:\n\n```ruby\nclass UserRegistration \u003c ActiveRecordCompose::Model\n  def initialize\n    @account = Account.new\n    @profile = @account.build_profile\n    super()\n    models \u003c\u003c account \u003c\u003c profile\n  end\n\n  attribute :terms_of_service, :boolean\n  validates :terms_of_service, presence: true\n  validates :email, confirmation: true\n\n  after_commit :send_email_message\n\n  delegate_attribute :name, :email, to: :account\n  delegate_attribute :firstname, :lastname, :age, to: :profile\n\n  private\n\n  attr_reader :account, :profile\n\n  def send_email_message\n    SendEmailConfirmationJob.perform_later(account)\n  end\nend\n```\n\nUsage:\n\n```ruby\nregistration = UserRegistration.new\nregistration.update!(\n  name: \"foo\",\n  email: \"bar@example.com\",\n  firstname: \"taro\",\n  lastname: \"yamada\",\n  age: 18,\n  email_confirmation: \"bar@example.com\",\n  terms_of_service: true,\n)\n```\n\nBoth `Account` and `Profile` will be updated **atomically in one transaction**.\n\n### Attribute Delegation\n\n`delegate_attribute` allows transparent access to attributes of inner models:\n\n```ruby\ndelegate_attribute :name, :email, to: :account\ndelegate_attribute :firstname, :lastname, :age, to: :profile\n```\n\nThey are also included in `#attributes`:\n\n```ruby\nregistration.attributes\n# =\u003e {\n#   \"terms_of_service\" =\u003e true,\n#   \"email\" =\u003e nil,\n#   \"name\" =\u003e \"foo\",\n#   \"age\" =\u003e nil,\n#   \"firstname\" =\u003e nil,\n#   \"lastname\" =\u003e nil\n# }\n```\n\n### Unified Error Handling\n\nValidation errors from inner models are collected into the composed model:\n\n```ruby\nuser_registration = UserRegistration.new(\n  email: \"foo@example.com\",\n  email_confirmation: \"BAZ@example.com\",\n  age: 18,\n  terms_of_service: true,\n)\n\nuser_registration.save # =\u003e false\n\nuser_registration.errors.full_messages\n# =\u003e [\n#   \"Name can't be blank\",\n#   \"Firstname can't be blank\",\n#   \"Lastname can't be blank\",\n#   \"Email confirmation doesn't match Email\"\n# ]\n```\n\n### I18n Support\n\nWhen `#save!` raises `ActiveRecord::RecordInvalid`,\nmake sure you have locale entries such as:\n\n```yaml\nen:\n  activemodel:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n```\n\n## Advanced Usage\n\n### Destroy Option\n\n```ruby\nmodels.push(profile, destroy: true)\n```\n\nThis deletes the model on `#save` instead of persisting it.\nConditional deletion is also supported:\n\n```ruby\nmodels.push(profile, destroy: -\u003e { profile_field_is_blank? })\n```\n\n### Callback ordering with `#persisted?`\n\nThe result of `#persisted?` determines **which callbacks are fired**:\n\n- `persisted? == false` -\u003e create callbacks (`before_create`, `after_create`, ...)\n- `persisted? == true` -\u003e update callbacks (`before_update`, `after_update`, ...)\n\nThis matches the behavior of normal ActiveRecord models.\n\n```ruby\nclass ComposedModel \u003c ActiveRecordCompose::Model\n  before_save     { puts \"before_save\" }\n  before_create   { puts \"before_create\" }\n  before_update   { puts \"before_update\" }\n  after_create    { puts \"after_create\" }\n  after_update    { puts \"after_update\" }\n  after_save      { puts \"after_save\" }\n\n  def persisted?\n    account.persisted?\n  end\nend\n```\n\nExample:\n\n```ruby\n# When persisted? == false\nmodel = ComposedModel.new\n\nmodel.save\n# =\u003e before_save\n# =\u003e before_create\n# =\u003e after_create\n# =\u003e after_save\n\n# When persisted? == true\nmodel = ComposedModel.new\ndef model.persisted?; true; end\n\nmodel.save\n# =\u003e before_save\n# =\u003e before_update\n# =\u003e after_update\n# =\u003e after_save\n```\n\n### Notes on adding models dynamically\n\nAvoid adding `models` to the models array **after validation has already run**\n(for example, inside `after_validation` or `before_save` callbacks).\n\n```ruby\nclass Example \u003c ActiveRecordCompose::Model\n  before_save { models \u003c\u003c AnotherModel.new }\nend\n```\n\nIn this case, the newly added model will **not** run validations for the current save cycle.\nThis may look like a bug, but it is the expected behavior: validations are only applied\nto models that were registered before validation started.\n\nWe intentionally do not restrict this at the framework level, since there may be valid\nadvanced use cases where models are manipulated dynamically.\nInstead, this behavior is documented here so that developers can make an informed decision.\n\n## Sample Application\n\nTry it out in your browser with GitHub Codespaces (or locally):\n\n- https://github.com/hamajyotan/active_record_compose-example\n\n## Links\n\n- [API Documentation (YARD)](https://hamajyotan.github.io/active_record_compose/)\n- [Blog article introducing the concept](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)\n- [Sample Application](https://github.com/hamajyotan/active_record_compose-example)\n\n## Development\n\nAfter 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.\n\nTo 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](https://rubygems.org).\n\n## Contributing\n\nBug 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](https://github.com/hamajyotan/active_record_compose/blob/main/CODE_OF_CONDUCT.md).\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n\n## Code of Conduct\n\nEveryone interacting in the ActiveRecord::Compose project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/hamajyotan/active_record_compose/blob/main/CODE_OF_CONDUCT.md).\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhamajyotan%2Factive_record_compose","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhamajyotan%2Factive_record_compose","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhamajyotan%2Factive_record_compose/lists"}