High-level data types with "composed_of"
I recently stumbled upon the Rails feature composed_of Show archive.org snapshot . One of our applications dealt with a lot of addresses and they were implemented as 7 separate columns in the DB and Rails models. This seemed like a perfect use case to try out this feature.
TLDR
The feature is still a VERY leaky abstraction. I ran into a lot of ugly edge cases.
It also doesn't solve the question of UI. We like to use
simple_form. It's currently not possible to simply writef.input :addressand get 7 form fields from that.I would recommend to either use a much simpler Rails concern instead (that doesn't fully encapsulate this data type) or to extract the fields into a separate
Addressmodel and use ahas_oneassociation withaccepts_nested_attributes_for
Usage
The basic idea is that you write a "value object". The simplest way with modern Ruby would probably be:
# only 4 attributes here to make examples shorter
Address = Data.define(:street_and_number, :address_supplement, :zip_code, :city)
However, I wanted to also have access to Rails validations and reached for this:
class Address
  include ActiveModel::API
  include ActiveModel::Attributes
  include ActiveModel::Validations
  REQUIRED_ATTRIBUTES = %i[street_and_number zip_code city].freeze
  ALL_ATTRIBUTES = (REQUIRED_ATTRIBUTES + %i[address_supplement]).freeze
  ALL_ATTRIBUTES.each do |attr|
    attribute attr
  end
  validates *REQUIRED_ATTRIBUTES, presence: true
  validates *ALL_ATTRIBUTES, length: { maximum: 1000 }
  
  # From this you already get methods like `#valid?` and `#==`.
  
  # (optional) Here is the perfect place to implement further methods for your new data type.
  # General methods:
  def inspect
    attributes
  end
  def blank?
    attributes.values.all?(&:blank?)
  end
  def present?
    !blank?
  end
  def to_s
    # ... (custom)
  end
  
  # Or more specific methods like dealing with country codes in our case
end
This was then hooked into the other model like this:
class Invoice < ApplicationRecord
  composed_of :address, class_name: 'Address'
end
So far so easy. With this, you get the following API:
invoice = Invoice.first
old_address = invoice.address # -> instance of `Address`
new_address = Address.new(...)
invoice.address = new_address
Encountered problems
Data objects should be immutable
It's not a good idea to leave data objects mutable. They should behave like integers. Ruby's Data would have already enforced that. Because I used a regular class, I had to build it myself. The Rails-native 
  readonly
  
    Show archive.org snapshot
  
 is sadly only available with ActiveRecord, not with ActiveModel. It would have most likely also not worked, because we need to actually set the attributes once in the initializer.
# instead of
  ALL_ATTRIBUTES.each do |attr|
    attribute attr
  end
  
# we need
  def initialize
    super
    @readonly = true
  end
  ALL_ATTRIBUTES.each do |attr|
    attribute attr
    define_method :"#{attr}=" do |value|
      if @readonly
        raise 'Readonly'
      else
        super(value)
      end
    end
  end
Weird behavior changes from composed_of
The method composed_of accepts the option :mapping, which is required, when your model is supposed to contain multiple addresses, like e.g. a sender and a receiver. With this option, you can provide a mapping between the attribute names on Address and the including model. However, using that option suddenly switches the way how Rails constructs your Address instance:
# without :mapping Rails uses keyword arguments
Address.new(street_and_number: 'Foo St. 1', zip_code: '12345', city: 'Bar Town')
# with a :mapping (even if that is just the "identity mapping" that keeps all names the same) Rails uses positional arguments
Address.new('Foo St. 1', nil, '12345', 'Bar Town')
Did you notice the nil in there? That's for the optional :address_suplement. Feels pretty brittle and looks like legacy code. To preserve the ability to use keyword arguments when initializing an address manually, I had to build a small hack:
  def initialize(*args, **kwargs, &block)
    converted_args = ALL_ATTRIBUTES.zip(args).to_h
    super(**converted_args.merge(kwargs), &block)
    @readonly = true
  end
Also, this :mapping feature feels overly granular. Why not just provide a :prefix option? Then one could write:
  composed_of :sender_address, class_name: 'Address', prefix: 'sender_'
  composed_of :recipient_address, class_name: 'Address', prefix: 'recipient_'
The main model actually needs to do work to receive validation errors
Without any further code, the validations on Address won't be run. And if they were run, then Invoice wouldn't magically see the errors. We need:
class Invoice < ApplicationRecord
  validate :address_valid?
  after_validation :copy_address_errors
  
  def address_valid?
    address.valid?
  end
  def copy_address_errors
    errors.merge!(address.errors)
  end
end
This code is not tricky, but I would have preferred not having to write it myself. Also, this code doesn't yet respect the :mapping option when copying the errors.
You better not have conditionals in your validations
If in some cases your address doesn't need to be present, you need to somehow turn of the presence validations from the outside. Simply not calling address.valid? would turn off all validations and stop validating the length. I used 
  validation contexts
  
    Show archive.org snapshot
  
 to achieve this, but this already got ugly with a single address and would have had buggy side effects with multiple independent addresses.
With a has_one association, the association could have simply been nil. However, with composed_of, we actually have the 7 columns hanging around in our main model and we need to deal with them correctly.
Caching is hard
When you load an association from the DB, Rails does some caching for you. There, it's perfectly fine as this is actually an SQL query (that you wouldn't want to do multiple times) and the association is actually stateful. However, Rails also caches composed_of "Aggregations" for you. And sadly there is no cache: false option. The problem with this:
# On initialization, the raw attributes and the aggregated address contain the same value. Everything fine so far.
invoice = Invoice.first
invoice.city # -> 'Bar Town'
invoice.address.city # -> 'Bar Town'
# When you set the aggregated `address`, changes also propagate to the individual attributes. Still looking good.
invoice.address = Address.new(city: 'Foo City')
invoice.city # -> 'Foo City''
invoice.address.city # -> 'Foo City''
# But if anyone ever dares to instead modify an individual attribute, you will most likely run into ugly bugs:
invoice.city = 'Bugtropolis'
invoice.city # -> 'Bugtropolis'
invoice.address.city # -> 'Foo City'
I actually managed to solve this issue by overriding the attribute setters so they clear the aggregation cache:
class Invoice < ApplicationRecord
  # clear `composed_of` aggregation cache when directly setting an attribute
  Address::ALL_ATTRIBUTES.each do |attr|
    define_method :"#{attr}=" do |value|
      super(value)
      @aggregation_cache['address'] = nil
    end
  end
end
However, that was the point where I seriously started to doubt, whether this feature of Rails is actually production-ready.
UI isn't solved
As stated already in the TLDR, you also at some point need to render the form fields. You need to write the 7 inputs by hands and permit the 7 params one by one. No extra convenience here. You can at least extract this to a partial, but even here the full flexibility of :mapping is detrimental and a common :prefix option (same prefix for all attributes) would have been nicer.
Verdict
I like the basic idea of allowing to combine multiple DB columns into a single attribute, but there are just too many footguns there currently. It would probably be the more lightweight solution compared to a separate addresses DB table, but it's also less future-proof if you ever want to switch from a has_one to a has_many. I still wouldn't discard the idea completely, but I wouldn't try to use it again before all the mentioned non-UI-issues are fixed.