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 :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 ahas_one
association 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.