image by 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.
…using a join table:
create_join_table :user, :organisation
class User < ApplicationRecord has_and_belongs_to_many :organisations end
class Organisation < ApplicationRecord has_and_belongs_to_many :users end
…a real model and name the concept that the join table is hiding.
create_table :memberships do |t| t.references :user t.references :organisation end
class Membership < ApplicationRecord belongs_to :user belongs_to :organisation end
class User < ApplicationRecord has_many :memberships has_many :organisations, through: :memberships end
class Organisation < ApplicationRecord has_many :memberships has_many :users, through: :memberships end
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
Delaying the creation of the
Membership concept will make for more refactoring later.
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: "email@example.com") user.organisations.create!(name: "One Ruby Thing") # real model user = User.create!(email: "firstname.lastname@example.org") 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.
Last updated on January 20th, 2020 by @andycroll
An email newsletter, with one Ruby/Rails technique delivered with a ‘why?’ and a ‘how?’ every two weeks. It’s deliberately brief, focussed & opinionated.