Do we actually need ‘required has_one’ associations in Ruby on Rails?

Lift off

When we talk about required has_one associations in Ruby on Rails, we refer to associations like this:


class User < ApplicationRecord
  has_one :profile, required: true
end
 

At first glance, you might think, “Well, of course we need them. I’ve used plenty of these associations before, and they work just fine. Are you suggesting we should do away with them altogether?”

The answer is no, you don’t have to eliminate existing required has_one associations.

However, while required has_one associations can contribute to a healthier model layer in your project, the difficulty of integrating such associations makes developers avoid refactoring at all, which is already not that great.

I dare say that the next time you consider refactoring a model in your project, but find that integrating required has_one associations would be too costly, you might not actually need them at all.

This might not be entirely clear at this point, so let’s start from the beginning.

“I used to be an adventurer like you. Then I took an arrow in the knee.”

Let’s say we have a requirement to store a user with their email, encrypted password, and first name.

Sounds like a simple task, doesn’t it? We want to create a new table and a new model.

In the model, we also want to add some validations (email format validation; email uniqueness validation; email, encrypted password, and first name presence validation). The model seems concise and clear:


# app/models/user.rb

class User < ApplicationRecord
  EMAIL_FORMAT = /\S+@\S+\.\S+/

  validates :email, presence: true, uniqueness: true, format: { with: EMAIL_FORMAT }
  validates :encrypted_password, presence: true
  validates :first_name, presence: true
end
 

And the schema is equally straightforward:


# db/schema.rb

create_table "users", force: :cascade do |t|
  t.string "email", null: false
  t.string "encrypted_password", null: false
  t.string "first_name", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["email"], name: "index_users_on_email", unique: true
end
 

We made it. Mission complete!

But then, after a two-week vacation, you return to find a new task: add a middle name field to the users table. Seems simple enough, right? The team estimates the task at 2 points, and you take it on.

You open the User model and are greeted by a sight you didn’t expect:


# app/models/user.rb

class User < ApplicationRecord
  EMAIL_FORMAT = /\S+@\S+\.\S+/

  validates :email, presence: true, uniqueness: true, format: { with: EMAIL_FORMAT }
  validates :encrypted_password, presence: true
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :username, presence: true
  validates :country_code, presence: true
  validates :state_code, presence: true
  validates :city, presence: true
  validates :postal_code, presence: true
  validates :street, presence: true
  validates :admin, presence: true

  has_many :auth_sessions, dependent: :destroy
  has_many :organization_memberships, dependent: :destroy
  has_many :organization_invitations, dependent: :destroy

  def avatar
    avatar_url.presence || "https://boulderbugle.com/LH7qdhXU" 
  end

  def full_name
    [first_name, last_name].compact.join(' ')
  end

  def contact_data_complete?
    address_line_2.present? && phone_number.present?
  end

  def confirmed_admin?
    admin? && confirmed?
  end

  def full_address
    [address_line_2, street, city, state_code, country_code, postal_code].compact.join(', ')
  end

  def confirmed?
    confirmed_at.present?
  end
end
 

# db/schema.rb

create_table "users", force: :cascade do |t|
  t.string "email", null: false
  t.string "encrypted_password", null: false
  t.string "first_name", null: false
  t.string "last_name", null: false
  t.string "username", null: false
  t.string "avatar_url"
  t.string "country_code", null: false
  t.string "state_code", null: false
  t.string "city", null: false
  t.string "postal_code", null: false
  t.string "street", null: false
  t.string "address_line_2"
  t.string "phone_number"
  t.boolean "admin", default: false, null: false
  t.datetime "confirmed_at"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["email"], name: "index_users_on_email", unique: true
  t.index ["username"], name: "index_users_on_username", unique: true
end
 

Yikes, the model is now a mishmash of logic for everything: addresses, names, roles, avatars, and whatnot. This is the kind of model that will balloon to over 500 lines of code in six months.

So, let’s heed the Boy Scout rule and try to improve things. But what can we do?

Good old required has_one associations

Implementation

The most obvious solution that comes to mind is to use a required has_one association. What if we move some fields to a new table, let’s call it user_profiles?

Here’s how it might look:


# app/models/user.rb

class User < ApplicationRecord
  EMAIL_FORMAT = /\S+@\S+\.\S+/

  validates :email, presence: true, uniqueness: true, format: { with: EMAIL_FORMAT }
  validates :encrypted_password, presence: true
  validates :country_code, presence: true
  validates :state_code, presence: true
  validates :city, presence: true
  validates :postal_code, presence: true
  validates :street, presence: true
  validates :admin, presence: true

  has_one :profile, required: true, inverse_of: :user, dependent: :destroy

  has_many :auth_sessions, dependent: :destroy
  has_many :organization_memberships, dependent: :destroy
  has_many :organization_invitations, dependent: :destroy

  def contact_data_complete?
    address_line_2.present? && phone_number.present?
  end

  def confirmed_admin?
    admin? && confirmed?
  end

  def full_address
    [address_line_2, street, city, state_code, country_code, postal_code].compact.join(', ')
  end

  def confirmed?
    confirmed_at.present?
  end
end
 

# app/models/user/profile.rb

class User::Profile < ApplicationRecord
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :username, presence: true

  belongs_to :user, inverse_of: :profile

  def avatar
    avatar_url.presence || "https://boulderbugle.com/LH7qdhXU" 
  end

  def full_name
    [first_name, last_name].compact.join(' ')
  end
end
 

# db/schema.rb

create_table "user_profiles", force: :cascade do |t|
  t.string "first_name", null: false
  t.string "last_name", null: false
  t.string "username", null: false
  t.string "avatar_url"
  t.bigint "user_id", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["user_id"], name: "index_user_profiles_on_user_id"
  t.index ["username"], name: "index_users_on_username", unique: true
end

create_table "users", force: :cascade do |t|
  t.string "email", null: false
  t.string "encrypted_password", null: false
  t.string "country_code", null: false
  t.string "state_code", null: false
  t.string "city", null: false
  t.string "postal_code", null: false
  t.string "street", null: false
  t.string "address_line_2"
  t.string "phone_number"
  t.boolean "admin", default: false, null: false
  t.datetime "confirmed_at"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["email"], name: "index_users_on_email", unique: true
end
 

Now, it looks much better. Of course, the User model still contains a lot of different logic, but at least we’re moving part of this logic to a new class. It’s much easier to work with two smaller classes than one big class. But what’s the cost? Let’s calculate.

Expenses

To make the code above work we need to:

  1. Create a new table with first_name, last_name, username, avatar_url, and a reference to the users table, which costs from 1 to 2 points.
  2. Backfill the new table with the existing data, which costs around 2 points.
  3. Update all references to first_name, last_name, username, avatar_url, avatar, and full_name in the code to the new table, which costs from 2 to ∞ points.
  4. Drop the columns in the users table, which costs around 1 point.
  5. Update users creation logic — we now have to create two records in the database instead of one, which costs from 2 to ∞ points.

It already costs us from 8 to ∞ points to make it work. And don’t forget that points 3 and 5 may explode in their scope.

Regarding point 3, on big projects, it’s quite possible that the whole application is riddled with the use of first_name, last_name, username, etc. It may turn out that it will not be possible to update all references at all. And you may end up adding something like delegate :first_name, to: :profile to make it work, which isn’t too bad if you think about it, but it’s still a hack in that case.

Regarding point 5, it’s very unlikely that you know all the places where you create users in the application. Ideally, it should be a single service, but in reality, it won’t. It’s also difficult to find all those places because, thanks to Rails, any record may be created in 5+ different syntaxes. So, depending on the application complexity, this task may take from 2 to ∞ points.

Also, it’s worth noting that you may introduce some N + 1 queries within this refactoring. For instance, accessing first_name and last_name in a loop is a pretty common operation, and it will cause N + 1 if you don’t add includes(:profile) for all these loops. How many such places are there in the application? You’ll probably have to figure this out.

And I have to admit, this kind of refactoring is quite risky. We have to migrate data, which is already a dangerous operation because you may lose some production data. But we are also updating vital logic like user creation, and not every developer wants to do this, let’s be honest. I hope you have good test coverage and reliable database dumps if you decide to do this refactoring.

And this is all just to move the user profile into a separate class, but we will have to do something similar with user roles, address, etc.

I’m not trying to discourage this type of refactoring. It makes the code better and, therefore, has a right to life. The problem is that you probably won’t do this refactoring because:

  1. It is expensive. Of course, we can’t afford to do this within our 2-point ticket.
  2. It is tedious. How many files will need to be checked to complete this entire plan? Dozens, if not hundreds.
  3. It is risky. No pain, no gain, but not many people want to take responsibility out of the blue.

I think this is the reason why models usually continue to grow endlessly and no one undertakes to refactor them.

So what, should we give up now?

No. There is a way to get 80% profit with 20% effort.

ActiveModel API and composed_of

Implementation

A useful feature of Rails that often flies under the radar is the ability to create models without creating new tables. In Rails, the composed_of method provides a means to define objects on top of your model’s attributes.

Let’s create a simple User::Profile class:


# app/models/user/profile.rb

class User::Profile
  def initialize(first_name:, last_name:, username:, avatar_url:)
    @first_name = first_name
    @last_name = last_name
    @username = username
    @avatar_url = avatar_url
  end

  def avatar
    avatar_url.presence || "https://boulderbugle.com/LH7qdhXU" 
  end

  def full_name
    [first_name, last_name].compact.join(' ')
  end

  private

  attr_reader :first_name, :last_name, :username, :avatar_url
end
 

It’s a Ruby class with some arguments and public methods. Note that we don’t inherit User::Profile from ApplicationRecord.

Now, in our User model, we can do this:


# app/models/user.rb

class User < ApplicationRecord
  ...

  composed_of :profile,
    class_name: "User::Profile",
    mapping: [%w(first_name), %w(last_name), %w(username), %w(avatar_url)],
    constructor: proc { |first_name, last_name, username, avatar_url|
      User::Profile.new(first_name:, last_name:, username:, avatar_url:)
    }

  ...
end
 

Take a peek at the documentation to understand what we’re doing here. As a result, here’s what we can already achieve:


$ user = User.create(first_name: 'Nikita', last_name: 'Sakau', username: 'nikitasakau', avatar_url: 'https://boulderbugle.com/LH7qdhXU', .....)

$ user.profile.full_name
=> "Nikita Sakau"
 

Looks sleek, doesn’t it? However, it might seem like it’s not a completely fair deal. Although we’ve moved a lot of User::Profile’s logic to a separate class, all User::Profile’s validations still reside in the User model:


# app/models/user.rb

class User < ApplicationRecord
  EMAIL_FORMAT = /\S+@\S+\.\S+/

  validates :email, presence: true, uniqueness: true, format: { with: EMAIL_FORMAT }
  validates :encrypted_password, presence: true
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :username, presence: true
  validates :country_code, presence: true
  validates :state_code, presence: true
  validates :city, presence: true
  validates :postal_code, presence: true
  validates :street, presence: true
  validates :admin, presence: true

  composed_of :profile,
    class_name: "User::Profile",
    mapping: [%w(first_name), %w(last_name), %w(username), %w(avatar_url)],
    constructor: proc { |first_name, last_name, username, avatar_url|
      User::Profile.new(first_name:, last_name:, username:, avatar_url:)
    }

  has_many :auth_sessions, dependent: :destroy
  has_many :organization_memberships, dependent: :destroy
  has_many :organization_invitations, dependent: :destroy

  def contact_data_complete?
    address_line_2.present? && phone_number.present?
  end

  def confirmed_admin?
    admin? && confirmed?
  end

  def full_address
    [address_line_2, street, city, state_code, country_code, postal_code].compact.join(', ')
  end

  def confirmed?
    confirmed_at.present?
  end
end
 

It’s an improvement from the initial code, but it’s still not as elegant as our good old required has_one association. Fair enough, let’s turn our User::Profile class into a real model!

The trick is to include some ActiveModel modules: ActiveModel::API and ActiveModel::Attributes to enable working with this class just like with a Rails model:


# app/models/user/profile.rb

class User::Profile
  include ActiveModel::API
  include ActiveModel::Attributes

  attribute :first_name
  attribute :last_name
  attribute :username
  attribute :avatar_url

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :username, presence: true

  def avatar
    avatar_url.presence || "https://boulderbugle.com/LH7qdhXU" 
  end

  def full_name
    [first_name, last_name].compact.join(' ')
  end
end
 

Now it looks like a real model!

To ensure our User model respects User::Profile’s validations, we need to implement something like this:


# app/models/user.rb

class User < ApplicationRecord
  ...

  validate do |record|
    next if profile.valid?
    record.errors.add(:profile, 'is invalid')
  end

  ...
end
 

So, what do we have now?


$ user = User.create(first_name: 'Nikita', last_name: 'Sakau', username: 'nikitasakau', avatar_url: 'https://boulderbugle.com/LH7qdhXU', .....)

$ user.profile.full_name
=> "Nikita Sakau"

$ user.update!(last_name: nil)
=> Validation failed: Profile is invalid (ActiveRecord::RecordInvalid)
 

Certainly, you may wish to implement some neat helpers to avoid repeating the same structure for all future models. But for now, the final code will look like this:


# app/models/user.rb

class User < ApplicationRecord
  EMAIL_FORMAT = /\S+@\S+\.\S+/

  validates :email, presence: true, uniqueness: true, format: { with: EMAIL_FORMAT }
  validates :encrypted_password, presence: true
  validates :country_code, presence: true
  validates :state_code, presence: true
  validates :city, presence: true
  validates :postal_code, presence: true
  validates :street, presence: true
  validates :admin, presence: true

  validate do |record|
    next if profile.valid?
    record.errors.add(:profile, 'is invalid')
  end

  composed_of :profile,
    class_name: "User::Profile",
    mapping: [%w(first_name), %w(last_name), %w(username), %w(avatar_url)],
    constructor: proc { |first_name, last_name, username, avatar_url|
      User::Profile.new(first_name:, last_name:, username:, avatar_url:)
    }

  has_many :auth_sessions, dependent: :destroy
  has_many :organization_memberships, dependent: :destroy
  has_many :organization_invitations, dependent: :destroy

  def contact_data_complete?
    address_line_2.present? && phone_number.present?
  end

  def confirmed_admin?
    admin? && confirmed?
  end

  def full_address
    [address_line_2, street, city, state_code, country_code, postal_code].compact.join(', ')
  end

  def confirmed?
    confirmed_at.present?
  end
end
 

# app/models/user/profile.rb

class User::Profile
  include ActiveModel::API
  include ActiveModel::Attributes

  attribute :first_name
  attribute :last_name
  attribute :username
  attribute :avatar_url

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :username, presence: true

  def avatar
    avatar_url.presence || "https://boulderbugle.com/LH7qdhXU" 
  end

  def full_name
    [first_name, last_name].compact.join(' ')
  end
end
 

# db/schema.rb

create_table "users", force: :cascade do |t|
  t.string "email", null: false
  t.string "encrypted_password", null: false
  t.string "first_name", null: false
  t.string "last_name", null: false
  t.string "username", null: false
  t.string "avatar_url"
  t.string "country_code", null: false
  t.string "state_code", null: false
  t.string "city", null: false
  t.string "postal_code", null: false
  t.string "street", null: false
  t.string "address_line_2"
  t.string "phone_number"
  t.boolean "admin", default: false, null: false
  t.datetime "confirmed_at"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["email"], name: "index_users_on_email", unique: true
  t.index ["username"], name: "index_users_on_username", unique: true
end
 

Comparing this code with the version utilizing a required has_one association, you’ll observe their conceptual similarity. Both models appear and function almost identically.

The only difference here is in how we manage tables. With a required has_one association, we split the users table into two: users and user_profiles. That’s neat and all, but not that vital because in Rails we mainly deal with models, not tables directly.

Expenses

Looking at the checklist for implementing the code with a required has_one association, let’s see what’s left for the code with ActiveModel API and composed_of:

  1. Create a new table with first_name, last_name, username, avatar_url, and a reference to the users table, which costs from 1 to 2 points.

    There’s no need to create any new tables at this point.

  2. Backfill the new table with the existing data, which costs around 2 points.

    We don’t have to backfill any data.

  3. Update all references to first_name, last_name, username, avatar_url, avatar, and full_name in the code to the new table, which costs from 2 to ∞ points.

    We don’t need to update references to attributes since they are still accessible for the User model. However, we do need to update references to model methods like full_name and avatar, but this task is relatively simple and costs around 2 points for our case.

  4. Drop the columns in the users table, which costs around 1 point.

    No need to drop any columns.

  5. Update users creation logic – we now have to create 2 records in the database instead of 1, which costs from 2 to ∞ points.

    There’s no need to update users creation logic.

As a result, the cost of refactoring drops significantly from “8 to ∞ points” to “around 2 points”, which is quite affordable.

Moreover, we don’t risk introducing N + 1 queries because the attributes still reside in the same table.

This kind of refactoring is generally safer. We can complete this refactoring within our 2-point task, merge everything at once, and move forward. Over time, we can gradually move all parts of the User model into separate models, one step at a time.

That’s why I see this kind of refactoring as 80% profit for 20% effort.

However, in some cases, a required has_one association may still be preferable. For instance, given we have numerous address fields in the User model like country_code, state_code, etc, and we want to consolidate this logic into an Address model, a required has_one association might be more suitable. While ActiveModel API and composed_of would still work well for the User model, adding an address to another model would necessitate duplicating all database constraints in the new table, which can be cumbersome. If a model is intended for reuse across multiple other models, a required has_one association might still be a better option.

Conclusion

To answer the question in the title, I would say, “Yes, we do need required has_one associations in Ruby on Rails, but not as often as you might think.”

Required has_one associations is a powerful tool for refactoring big models. However, this tool is often too expensive, which may discourage refactoring at all.

At the same time, ActiveModel API and composed_of offer us almost the same thing but much cheaper.

Of course, as always, you will have to decide what you need in your particular case yourself. But now you have one more handy tool that can be a great fit.

References

Nikita Sakau

Nikita Sakau

Software Engineer at datarockets

From our blog

Stay up to date

Check out our newsletter

© 2024 Red Panda Technology (dba datarockets). All Rights Reserved.