Organizing rails Code with ActiveRecord Associated Objects

We often hear how God objects are bad. Regardless, at some point, we’ve all either created one or had to interact with one. Lately, I’ve been using the active_record-associated_object gem by Kasper Timm Hansen more and more, and it has been a wonderfully focused tool to help avoid creating God objects.

Kasper’s words explain it far better than I ever could…

Rails applications can end up with models that get way too big, and so far, the Ruby community response has been Service Objects. But sometimes app/services can turn into another junk drawer that doesn’t help you build and make concepts for your Domain Model.

ActiveRecord::AssociatedObject takes that head on. Associated Objects are a new domain concept, a context object, that’s meant to help you tease out collaborator objects for your Active Record models.

As we started updating the pricing for Flipper Cloud, it was clear that our account model was going to expand significantly if we let it. We’re also working on creating a paid Pro version of the Flipper gem so that self-hosted installs can benefit from the relevant Flipper Cloud features.

All of that means designing and building an approach that can handle various billing structures and payment options. Much of that new logic that would really only ever be related to a specific account. It was arguably all account logic, but it clearly wasn’t cram-all-of-this-in-the-account-file logic.

Where should it go?

With logic that’s so closely related to a single model but doesn’t need to persist any of its own data, where should it go if we don’t want it in the primary model?

We have a handful of options…

  1. God objects. Put all of the logic in the primary model and embrace the fact that it will be inextricably tied to everything.
  2. More models. Create additional database models for each thing and connect them using references. This can work, but it inevitably leads to a lot of cognitive overheard and SQL joins for models that may have very few–if any–attributes of their own.
  3. Table-less models. Create table-less PORO or ActiveModel classes to represent each of the sub-concepts. This can work, but for objects that are only ever used in relation to the Account (or equivalent) model, sprinkling a bunch of extra classes in with the primary models creates a messy model directory quickly. Junk drawers like that are rarely the best option.
  4. Associated Objects. Create “Associated Objects” using the active_record-associated_object gem.

Until recently, I had mostly been using the third option and placing the new classes in sub-directories named after the primary model, but once Kasper shared his approach with ActiveRecord::AssociatedObject, it immediately felt like a more intentional approach.

Before ActiveRecord::AssociatedObject

My original approach meant adding quite a few methods that defined instances of related objects by passing self. It got the job done, but it never felt very Rails-like.

# app/models/account.rb
class Account < ApplicationRecord
  # ...
  def seats = Account::Seats.new(self)
  def trial = Account::Trial.new(self)
  # ...
end
# app/models/account/seats.rb
class Account::Seats
  attr_reader :account
  def initialize(account)
    @account = account
  end
  # ...
end
# app/models/account/trial.rb
class Account::Trial
  attr_reader :account
  def initialize(account)
    @account = account
  end
  # ...
end

While it’s not the worst Ruby code, my approach felt repetitive and never felt great even if it did do the job. It also didn’t expose the same conveniences that we get from Associated Objects.

There’s a lot of extra duplication and overhead to add these. I had gotten in the habit of using a custom generator to streamline the process and save time, but it still left me with code I didn’t love.

After ActiveRecord::AssociatedObject

While my approach worked, Kasper’s cleans it up nicely, and it feels much more Rails-like. There’s less boilerplate code, but the additional objects work just the same.

class Account < ApplicationRecord
  # ...
  has_object :seats, :trial
  # ...
end
# app/models/account/seats.rb
class Account::Seats < ActiveRecord::AssociatedObject
  # ...
end
# app/models/account/trial.rb
class Account::Trial < ActiveRecord::AssociatedObject
  # ...
end

While it’s not the worst Ruby code, my approach felt repetitive and never felt great even if it did do the job. It also didn’t expose the same conveniences that we get from Associated Objects.

Now that we’ve seen the basics of how it works, let’s expand and look at the collective benefits of using it.

Benefits of Associated Objects

At the simplest level, associated objects come in handy mostly by helping organize and compartmentalize related logic so we can better avoid the junk drawer or God object “patterns.” But that’s an over-simplification because the benefits can be almost invisible–it just disappears into the background and does its job.

So, in no particular order…

  1. It’s Rails-like. The has_object syntax compliments the other referential syntaxes like has_one and has_many making it immediately familiar and Rails-like.
  2. It reduces boilerplate code. Not only does it connect the associated object and expose the primary object within the associated object, but it supports forwarding callbacks to the associated object.
  3. It supports extending the primary object. So code written in the associated object can work just as if it was written directly in the primary object.
  4. It groups related logic. It’s easier to build and test code that all relates to a single topic. By grouping that logic (and its tests), it’s easier to reason about the purpose of the code.
  5. Provides a well-defined structure and approach. Because it provides a predictable Rails-like pattern with has_object, it doesn’t create a steep learning curve. Everyone on a team can learn and apply the pattern with minimal effort.
  6. Better organizes related code concepts. By pulling code out of a top-level object and placing it into a directory named after that top-level object, the connection between the two is still clear, but the structure is much less overwhelming.
  7. It provides intention-revealing syntax. While defining a plain method in the primary class that instantiates a matching object isn’t wildly complex, there’s something about has_object that feels more natural and obvious in practice.
  8. It helps identify and decompose features. With a predictable pattern for the concept of an “associated object” and no boilerplate, it helps more easily recognize the opportunities while providing a roadmap for how to organize the code.
  9. It integrates with ActiveJob. All associated objects automatically receive ActiveJob integration using GlobalID. We haven’t made use of this yet, but I’m sure we will.
  10. It includes automatic kredis integration. We haven’t used this yet either, but it’s great to have available.
  11. It has its own generator. I may be biased, but that’s the icing on the cake. Not only does the code save time, but the generator ensures that it’s a convenient tool to reach for regularly.

Admittedly, many of these benefits are inter-related and/or subjective. As I’ve been using it more day-to-day, I’ve found that it’s been a great tool for organizing and thinking about code in a more modular manner while remaining confident that it’s properly integrated with Rails.

Let’s look at some examples of how it affects the API of some common things I’ve done and seen done over the years by providing a more well-defined approach with much less boilerplate.

While we’ve only created a subset of these concepts in Flipper Cloud, let’s explore some various sub-concepts of multi-user SaaS accounts. Depending on the application, many of these could make a case to be mediator objects, form objects, or other specialized objects, but they certainly have or interact with data that makes a case for them to be associated objects as well.

Trials

Depending on the application, trials may or may not be related to subscriptions. For example, if all accounts have free trials without requiring a credit card, an Account may have a fair amount of logic related to the trial period, but outside of a trial, that logic is never used again.

# Before:
#   "trial_*" provides the hint we have another object...
account.trial_expired?
account.trial_active?
account.trial_days_remaining?
# After:
#   A "trial" associated object provides a more object-oriented
#   API that feels like it reads much better
account.trial.expired?
account.trial.active?
account.trial.days_remaining?

Figure 2

Whenever I start seeing multiple methods prefixed with the same word, that immediately makes me think of creating an associated object.

Seats

In the case of Flipper, we have the concept of “billable” seats and “non-billable” seats. For example, people with billing access like bookkeepers or accountants can’t access project-related information. Similarly, for seat-based plans, the number of billable seats represents the quantity for the subscription. With fixed-price plans, the quantity is irrelevant for billing, but we still have upper limits on seats and need a count. Plus pending invitations should count towards seat limits, but since they haven’t been accepted, they aren’t considered billable seats for seat-based plans.

# The variations of "seat" in the method names hint at a another object...
account.seats_available?
account.billable_seats_count
account.invitation_count
account.seat_limit?
account.unlimited_seats?
# A "seats" associated object provides a great home for this logic...
account.seats.available?
account.seats.billable_count
account.seats.invited_count
account.seats.limit
account.seats.unlimited?

While ‘seats’ isn’t the prefix for every method, the word ‘seat’ shows up enough to feel like there was an associated object lurking in there.

Pricing

Because we don’t want to force any customers to change plans if we release new pricing, we keep track of pricing versions so customers can continue on unhindered with legacy pricing if they’re aren’t thrilled with the new options. As a result, we end up with a fair amount of specialized logic related to the version of pricing the customer uses.

account.free_plan
account.plan_options
account.pricing_version
account.current_pricing?
account.legacy_pricing?
# A "pricing" associated object provides a great home for this# logic...
account.pricing.free_plan
account.pricing.plans
account.pricing.version
account.pricing.current?
account.pricing.legacy?

The associated object for pricing was less obvious, but having a system-level Pricing class made it feel natural to create an account-related Account::Pricing associated object to know the state of a given instance’s pricing availability.

Entitlements, Limits, and Features

While entitlements are loosely related to pricing, any Flipper Cloud customers on our free plan don’t have a subscription because we’re not charging them anything. As a result, like pricing, there’s a non-trivial amount of logic that determines which capabilities a given Account has.

account.seat_limitaccount.advanced_permissions?
# An "entitlements" associated object provides a great home for this logic...
account.entitlements.seat_limit
# ...or...
account.limits.seatsaccount.entitlements.advanced_permissions?
# ...or...
account.features.advanced_permissions?

The Entitlements class really did a great job of encapsulating the logic about whether a given account had access to a specific feature. We used ‘Entitlements’, but a ‘Limits’ or ‘Features’ associated object could work well while reducing typing.

A Great Way to Organize Rails Model Logic

We’ve only skimmed the surface in our own usage, but being able to create Associated Objects has definitely improved the domain modeling and organized the code in a way that has been much easier to reason about and test. With Rails, it feels like one of the biggest challenges is having the perfect place to put certain elements of code, and Associated Objects provide a great way to do that.