RoRvsWild

Advanced Domain Modeling Techniques for Ruby on Rails – Part 1

Aggregating models into value objects with `composed_of`.

Advanced Domain Modeling Part 1

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::Aggregations docs 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_name option, you define which class should encapsulate the value object. In our case, that’s "Color".
  • mapping defines 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 converter option 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:

  • constructor allows to specify how to create new value objects. We built all of that into our Color constructor, but this option comes in handy when you use built-in classes like IPAddr or URL, for example.
  • allow_nil allows setting a value object to nil, resulting in all database columns being inserted as NULL.

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.

RoRvsWild monitors your Ruby on Rails applications.

Try for free
RoRvsWild Ruby Request group
View gem
Try for free