Welding

Max LaRochelle

Be Suspicious of Join Tables

We often have to represent many-to-many relationships between models in our applications. Rails provides a method in its migrations to generate a table in your database to support this. You can see the documentation in the Rails guide for ActiveRecord migrations.

However, these basic join tables often obscure a useful concept in your application that might be better represented as a named model.

Instead of…

…using a join table:

Migration

create_join_table :user, :organisation

app/models/user.rb

class User < ApplicationRecord
  has_and_belongs_to_many :organisations
end

app/models/organisation.rb

class Organisation < ApplicationRecord
  has_and_belongs_to_many :users
end

Use

…a real model and name the concept that the join table is hiding.

Migration

create_table :memberships do |t|
  t.references :user
  t.references :organisation
end

app/models/membership.rb

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :organisation
end

app/models/user.rb

class User < ApplicationRecord
  has_many :memberships
  has_many :organisations, through: :memberships
end

app/models/organisation.rb

class Organisation < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
end

Why?

Almost without fail, whenever a join model sits for any length of time in an application it begins to acquire behaviour. It’s nearly always worth having a first pass at naming the concept that the join model represents.

With a “basic” join table there is no way to add functionality to this unnamed concept. The lack of a place to put this extension means you might have to attach functionality to one of the joined models.

In the “join table” example above you might be forced to put a role attribute on the User, where a User’s role is likely different for each organisation of which they’re a member. This need—for role information that belongs on the join table—demonstrates the requirement for a real Membership model.

Delaying the creation of the Membership concept will make for more refactoring later.

Why not?

There’s extra manual work, if you aren’t using the built-in functionality for “has and belongs to”-style joins, so you have to create the joining model yourself.

# has_and_belongs_to_many
user = User.create!(email: "andy@goodscary.com")
user.organisations.create!(name: "One Ruby Thing")

# real model
user = User.create!(email: "andy@goodscary.com")
organisation = Organisation.create!(name: "One Ruby Thing")
Membership.create!(user: user, organisation: organisation)

This extra work during creation is partially because you don’t get the same convenience methods from has_and_belongs_to_many. You do get a similar selection of association methods by using the has_many: xx through: yy syntax. You can review the different generated methods in the documentation for Active Record associations.

You can build a quick join model to get going and explore the domain of your application. But be ready to change the table into a ‘proper model’ when you start to discover attributes or logic that really belong to the join.

Sign up to get a nugget of Ruby knowledge every couple of weeks or so.

Last updated on January 20th, 2020