High-level data types with "composed_of"

Posted . Visible to the public.

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 write f.input :address and 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 Address model and use a has_one association with accepts_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.

Klaus Weidinger
Last edit
Klaus Weidinger
License
Source code in this card is licensed under the MIT License.
Posted by Klaus Weidinger to makandra dev (2024-10-10 11:41)