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…
- God objects. Put all of the logic in the primary model and embrace the fact that it will be inextricably tied to everything.
- 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.
- 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 theAccount
(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. - 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.
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.
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…
- It’s Rails-like. The
has_object
syntax compliments the other referential syntaxes likehas_one
andhas_many
making it immediately familiar and Rails-like. - 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.
- 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.
- 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.
- 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. - 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.
- 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. - 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.
- 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.
- It includes automatic kredis integration. We haven’t used this yet either, but it’s great to have available.
- 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.
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.
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.
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.
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.