Advanced Domain Modeling Techniques for Ruby on Rails – Part 1
Aggregating models into value objects with `composed_of`.
At some point of any Ruby on Rails project’s life the codebase starts to get messy, and you sense that you have to address this before the tech debt pile reaches unmanageable proportions. There are a lot of places where this can occur, for example:
- organizing business logic between controllers and background jobs,
- managing database concurrency,
- orchestrating reactive and/or real-time user interfaces,
- etc.
The most likely place where this will bite you, though, is in the domain model layer. As it turns out, a poorly architected, tightly coupled domain model is a huge time and productivity sink. Large-scale refactorings to convert one-to-many to many-to-many associations - potentially even polymorphic ones - complete with complex backfill tasks that have to be rolled back because that one critical edge case hasn’t been covered. We’ve all been there.
In this series of articles, we are going to explore some of the more advanced methods to go about designing a robust yet flexible model layer in Ruby on Rails.
What Are Value Objects?
There are these instances of models that have a distinctively denormalized database structure with many columns describing one attribute. Or you find that one and the same concept reveals many representations for different use cases. Or you need to make complex comparisons between objects.
The concept I just described is called a value object. And it turns out Rails has built-in support for this by means of the composed_of ActiveRecord macro.
For starters, let’s assume that your application has support for frontend theming. There’s a Theme model with the following, grown database structure:
create_table "themes", force: :cascade do |t|
t.integer "primary_color_red", null: false
t.integer "primary_color_green", null: false
t.integer "primary_color_blue", null: false
t.decimal "primary_color_alpha", precision: 3, scale: 2, default: "1.0", null: false
t.integer "secondary_color_red", null: false
t.integer "secondary_color_green", null: false
t.integer "secondary_color_blue", null: false
t.decimal "secondary_color_alpha", precision: 3, scale: 2, default: "1.0", null: false
t.integer "background_color_red", default: 0, null: false
t.integer "background_color_green", default: 0, null: false
t.integer "background_color_blue", default: 0, null: false
t.decimal "background_color_alpha", precision: 3, scale: 2, default: "1.0", null: false
t.bigint "account_id"
t.string "name", null: false
t.index ["account_id"], name: "index_themes_on_account_id"
end
In your model file, nothing points at the existence of any of these colors:
class Theme < ApplicationRecord
belongs_to :account
end
Imagine that in your view code, you have to convert your color to and from different representations. e.g. hexadecimal. For example, you want to style individual UI components, that’s why you’ve defined the following helper function:
def color_to_hex(red, green, blue)
format("%02X%02X%02X", red, green, blue)
end
So now you can do:
<section style="background: #<%= color_to_hex(@theme.background_color_red, @theme.background_color_green, @theme.background_color_blue) %>;"> ... </section>
Likewise, maybe there are some conditions involved that depend on certain colors. For example, if the background is black, you might want to implement some overrides, so you need another helper:
def color_equal?(red_1, green_1, blue_1, alpha_1, red_2, green_2, blue_2, alpha_2)
red_1 == red_2 && green_1 == green_2 && blue_1 == blue_2 && alpha_1 == alpha_2
end
Using this, you can compare against the color black, for example:
<img style="<%= 'display: none;' unless color_equal?(@theme.background_color_red, @theme.background_color_green, @theme.background_color_blue, @theme.background_color_alpha, 0, 0, 0, 1)" />
Both these approaches read rather clunky. They constantly reach into @theme‘s internals, and border on illegibility. On top of that, when a theme is created, you have to juggle all these attributes separately:
theme = Theme.create!(
name: "Test Theme",
primary_color_red: 1,
primary_color_green: 0,
primary_color_blue: 0,
primary_color_alpha: 1,
secondary_color_red: 0,
# etc ...
)
That’s clearly not an enjoyable way to instantiate an object. We can drastically improve this situation, though. If you’ve paid attention, the “color” noun is appearing very frequently. Furthermore, its concept ticks all boxes of a value object:
- it’s small,
- its equality is not based on identity, but its attributes (i.e., it is fungible), and
- in the context we’re using it, it’s immutable. (In fact, the
ActiveRecord::Aggregationsdocs state that “Active Record won’t persist value objects that have been changed through means other than the writer method.”)
So why not extract a Color class of its own right? Let’s start simple and put this into app/models/color.rb:
class Color
include ActiveModel::Validations
attr_reader :red, :green, :blue, :alpha
# RGB values are integers 0-255 (8-bit color channels)
# Alpha is a float 0.0-1.0 (normalized opacity) following CSS rgba() convention
validates :red, :green, :blue, inclusion: { in: 0..255 }
validates :alpha, inclusion: { in: 0.0..1.0 }
def initialize(red_or_hex, green = nil, blue = nil, alpha = 1.0)
@red = red_or_hex.to_i
@green = green.to_i
@blue = blue.to_i
@alpha = alpha.to_f
end
end
Great, here we have a PORO that captures all 4 coordinates of an RGBA color. On top of that we are using ActiveModel::Validations to ensure a Color object is always created in a valid state (another formal requirement of a value object). Now let’s look at our use cases one by one:
Equality
Value objects should be comparable for equality. A simple test case would look like this:
test "equality compares all components" do
color1 = Color.new(255, 0, 128, 0.5)
color2 = Color.new(255, 0, 128, 0.5)
color3 = Color.new(255, 0, 128, 1.0)
assert_equal color1, color2
refute_equal color1, color3
end
Right now, unsurprisingly, this results in a failure:
F
Failure:
ColorTest#test_equality_compares_all_components [test/models/color_test.rb:94]:
No visible difference in the Color#inspect output.
You should look at the implementation of #== on Color or its members.
#<Color:0xXXXXXX @red=255, @green=0, @blue=128, @alpha=0.5, @context_for_validation=#<ActiveModel::ValidationContext:0xXXXXXX @context=nil>, @errors=#<ActiveModel::Errors []>>
But there’s an important clue in this failure message already: “look at the implementation of #==”. Aha! We didn’t actually provide a way of Color to check the equality of one instance against another. But adding this is trivial, since we already use it in the helper above:
class Color
# ...
def ==(other)
other.is_a?(Color) && @red == other.red && @green == other.green && @blue == other.blue && @alpha == other.alpha
end
end
Now the test passes:
# Running:
.
Finished in 0.209746s, 4.7677 runs/s, 9.5353 assertions/s.
1 runs, 2 assertions, 0 failures, 0 errors, 0 skips
However, the concatenation of logical operators in the == method is unpleasant to the eye. And it’s also prone to be forgotten should an instance variable be added or removed later.
A better, more idiomatic way to test simple objects for equality is using a hash method. A lot of standard library classes like Hash, Set, Float, etc. conform to this protocol. So why not use it here? In fact, we can make direct use of Array#hash to implement our own version:
class Color
# ...
def ==(other)
other.is_a?(Color) && hash == other.hash
end
def hash
[ red, green, blue, alpha ].hash
end
end
Much cleaner! As a side note, though, this comes at the cost of two array allocations per comparison. This does contribute to your application’s memory usage, though it will probably only amount to a few tenths of a percent.
With that, our view code from above can be transformed into the following (we assume a background_color attribute):
<img style="<%= 'display: none;' unless @theme.background_color == Color.new(0, 0, 0)" />
(We are assuming a Theme#background_color attribute here - we’ll get to that in a moment).
Conversions
The second use case we’ve visited above was to convert a color into various representations. Remember that color_to_hex helper from above? We can incorporate it right into our class:
class Color
# ...
def to_hex
format("%02X%02X%02X", red, green, blue)
end
end
Now the painful view code from above can be rewritten as:
<section style="background: #<%= @theme.background_color.to_hex %>;"> ... </section>
Of course, nothing is stopping us from creating other converters, for example to rgb or hsl CSS strings:
class Color
include ActiveSupport::NumberHelper
# ...
def to_rgb_s
"rgb(#{red} #{green} #{blue} / #{number_to_percentage(alpha * 100, precision: 0)})"
end
def to_hsl_s
"hsl(#{hue} #{saturation}% #{lightness}% / #{number_to_percentage(alpha * 100, precision: 0)})"
end
def hue
# ...
end
def saturation
# ...
end
def lightness
# ...
end
end
We can add another item to the wishlist: Being able to instantiate a Color object from more than one representation. You might want to pass in a hexadecimal string, or even just copy another Color instance. This can be quite easily be achieved using pattern matching in the initializer:
class Color
# ...
def initialize(red_or_hex_or_color, green = nil, blue = nil, alpha = 1.0)
case [ red_or_hex_or_color, green, blue ]
in [ Color => c, nil, nil ]
# Copy channels from an existing Color.
@red, @green, @blue, @alpha = c.red, c.green, c.blue, c.alpha
in [ /\A#?[0-9A-Fa-f]{6}\z/ => hex, nil, nil ]
# Parse a valid 6-digit hex string.
parse_hex(hex)
in [ String | Symbol, nil, nil ]
# Fail fast on invalid hex strings/symbols.
raise ArgumentError, "Invalid hex color format: #{red_or_hex_or_color}"
else
# Treat positional values as RGBA channels.
@red = red_or_hex_or_color.to_i
@green = green.to_i
@blue = blue.to_i
end
@alpha ||= alpha.to_f
end
# ...
private
def parse_hex(hex)
# Normalize hex string: remove #, strip whitespace, uppercase
normalized = hex.to_s.strip.upcase
normalized = normalized[1..-1] if normalized.start_with?("#")
# Parse hex to RGB using pack/unpack
@red, @green, @blue = [ normalized ].pack("H*").unpack("C*")
@alpha = 1.0
end
# ...
end
With this change, it’s now possible to do the following:
old_gray = Color.new(128, 128, 128, 0.5)
new_gray = Color.new(old_gray)
green = Color.new("#00FF00")
How about actually validating the correct hexadecimal color format instead of just raising? We have to adapt our class just a little to make that work:
class Color
# ...
validate :hex_format_valid?
def initialize(red_or_hex_or_color, green = nil, blue = nil, alpha = 1.0)
case [ red_or_hex_or_color, green, blue ]
# ...
in [ String | Symbol, nil, nil ]
@hex_format_error = "Invalid hex color format: #{red_or_hex_or_color}"
else
# ...
end
@alpha ||= alpha.to_f
end
# ...
private
# ...
def hex_format_valid?
errors.add(:base, @hex_format_error) if @hex_format_error
end
end
Now we’re storing a potential format error in an instance variable @hex_format_error that can be inspected by the :hex_format_valid? validator:
c = Color.new("#ZZZZZZ")
=> #<Color:0x0000000124c1ec40 @alpha=1.0, @hex_format_error="Invalid hex color format: #ZZZZZZ">
c.valid?
=> false
c.errors.messages
=> {red: ["is not included in the list"], green: ["is not included in the list"], blue: ["is not included in the list"], base: ["Invalid hex color format: #ZZZZZZ"]}
We’ve gained a flexible way to reason about the concept of a color. Now let’s take a step back and think about how a color fits into the architecture of a higher level construct.
Composition
You might be thinking: Great, but how do we integrate the Color value object with our Theme model? This is where the composed_of helper comes to the rescue. You can think of it as a kind of “inline” has_one association:
- The first argument is the name of the attribute you want to use for the composed object henceforth. Here, those are
primary_color,secondary_color,background_color. - Using the
class_nameoption, you define which class should encapsulate the value object. In our case, that’s"Color". mappingdefines how database columns of the entity correspond to the attributes of the value object. We map the respective primary, secondary and background color coordinates to the value objects’ in a simple hash here. But here’s an important caveat from the documentation: “The order in which mappings are defined determines the order in which attributes are sent to the value class constructor.”. So in this case the order of key/value pairs in the hash is not irrelevant!- The
converteroption allows us to specify a Proc (or method name) that’s used when a new value is assigned to the attribute. Without it, in our case we would have to explicitly call the constructor (theme.background_color = Color.new("#000000")), but if we specify it we can just use it for assignment:theme.background_color = "#000000".
There are two more options that we didn’t use here:
constructorallows to specify how to create new value objects. We built all of that into ourColorconstructor, but this option comes in handy when you use built-in classes likeIPAddrorURL, for example.allow_nilallows setting a value object tonil, resulting in all database columns being inserted asNULL.
Applied to our Theme model, it looks like this:
class Theme < ApplicationRecord
belongs_to :account
composed_of :primary_color,
class_name: "Color",
mapping: {
primary_color_red: :red,
primary_color_green: :green,
primary_color_blue: :blue,
primary_color_alpha: :alpha
},
converter: ->(value) { Color.new(value) }
composed_of :secondary_color,
class_name: "Color",
mapping: {
secondary_color_red: :red,
secondary_color_green: :green,
secondary_color_blue: :blue,
secondary_color_alpha: :alpha
},
converter: ->(value) { Color.new(value) }
composed_of :background_color,
class_name: "Color",
mapping: {
background_color_red: :red,
background_color_green: :green,
background_color_blue: :blue,
background_color_alpha: :alpha
},
converter: ->(value) { Color.new(value) }
end
This yields a couple of immediate benefits. The creation of new Themes is now dramatically easier:
theme = Theme.create!(
name: "Test Theme",
primary_color: "#FF0000",
secondary_color: "#00FF00",
background_color: "#000000"
)
You can now work with color objects directly:
theme.background_color.to_hex # => "000000"
theme.primary_color == Color.new(255, 0, 0) # => true
Updates are just as elegant:
theme.update!(background_color: "#FFFFFF")
Form parameters can be mapped directly:
theme.update!(params.require(:theme).permit(:name, :primary_color, :secondary_color, :background_color))
What about validations? That works too, if we use validates_associated. Observe:
class Theme
validates_associated :primary_color, :secondary_color, :background_color
# ...
end
Now let’s simulate a form submission containing a faulty primary_color value:
params = ActionController::Parameters.new(theme: {
name: "Test",
primary_color: "#ZZZZZZ",
secondary_color: "#000000",
background_color: "#ffffff"
})
=>
#<ActionController::Parameters {"theme" => {"name" => "Test", "primary_color" => "#ZZZZZZ", "secondary_color" => "#000000", "background_color" => "#ffffff"}} permitted: fals...
theme = Theme.new(params.require(:theme).permit(:name, :primary_color, :secondary_color, :background_color))
=>
#<Theme:0x00000001318d6b98
...
theme.valid?
=> false
theme.errors.messages
=> {primary_color: ["is invalid"]}
theme.primary_color.errors.messages
=> {red: ["is not included in the list"], green: ["is not included in the list"], blue: ["is not included in the list"], base: ["Invalid hex color format: #ZZZZZZ"]}
The icing on the cake is that you can use value objects for querying the database directly:
blue_themes = Theme.where(primary_color: Color.new("#0000FF"))
Bonus: Comparable Value Objects
A color is a nominally scaled concept, its instances follow no logical order like distances or temperatures. However, to stay in our example’s domain, let’s consider its lightness attribute, which inhabits the ratio scale - in other words, we can tell which of two colors is brighter. Note that in reality, lightness might not be an appropriate one-dimensional representation of a color (it worked for several decades on TV, though).
Thankfully, Ruby makes the implementation of this simple using the Comparable mixin. All we have to do is include it and define a <=> method, from which all the other comparisons (<, >, etc.), predicates (between?) and utilities (clamp) are derived:
class Color
include Comparable
# ...
def <=>(other)
lightness <=> other.lightness
end
Now it’s possible to compare them:
Color.new(0, 0, 0) < Color.new(128, 128, 128) # => true
Furthermore, implementing <=> opens up more fast search and sorting algorithms like bsearch, sort, etc.
Sadly, using this to query the database doesn’t work out of the box:
dark_themes = Theme.where("background_color < ?", Color.new(128, 128, 128)) # ⛔️ => #<TypeError: can't quote Color>
Which is kind of obvious, because lightness is a computed attribute, hence we’d have to reimplement the same functionality on the database layer. It’s convenient to have this capability in the Ruby domain, though: Here, it allows dynamic adjustments of the UI to the theme.
Conclusion
Value objects help you untangle a bloated model by bundling related attributes, keeping data valid, and making operations like comparison or conversion feel natural. Rails’ composed_of macro gives you a lightweight bridge between denormalized database tables and richer domain concepts, without forcing a full-blown refactor. In the next part of this series, we’ll look at the Strategy pattern to keep your models expressive, resilient, and easier to change as your application grows.