Rails Relationships Rethought: Introducing The Missing Model
08 May 2024I enjoyed The Bike Shed podcast’s latest episode, “Modeling Associations in Rails,” in which the hosts, Joël and Stephanie, discussed a data modeling challenge Stephanie recently encountered. I love the topic of data modeling and wanted to share my solution to the presented problem.
Stephanie’s project has these three classes: Company
, Employee
, and
Device
, with associations shown in the code sample below.
class Company
has_many :employees
end
class Employee
belongs_to :company
has_many :devices
end
class Device
# This is the code they're talking about maybe adding.
# has_one :company, through: :employee
#
# def company
# employee.company
# end
end
The challenge is that Stephanie is trying to figure out how to get a
Device
’s Company
in a semantically satisfying way. There’s a simple
way to do that, basically delegating the call to Employee, but there are
many reasons why there might be better solutions. (That’s a super
abridged summary. Listen to the episode for all of the nuances.)
One of the things I usually explore when folks express discomfort about
issues around the semantics of associations, especially when has_many
is involved, is that a model is often missing. (I love finding these
models—that’s why I’m writing this post!)
I think a model is missing. Here’s the approach I’d take:
class Company
has_many :employees
has_many :devices
has_many :device_assignments
def assign_device(employee, device)
device_assignments.create(employee: employee, device: device)
end
# I don't love this name
def unassign_device(device)
device_assignments.find_by(device: device).destroy
end
end
class Employee
belongs_to :company
has_one :device_assignment
has_one :device, through: :device_assignments
end
class Device
belongs_to :company
has_one :device_assignment
has_one :employee, through: :device_assignments
end
class DeviceAssignment
belongs_to :company
belongs_to :device
belongs_to :employee
end
The significant change in this code is the introduction of a new model,
DeviceAssignment
, representing a Device
’s assignment to an Employee
.
With this change, I’d argue, the ActiveRecord associations all “point”
in a semantically sensible way.
For example, the Device
doesn’t “have” a Company
–it “belongs” to it
(which is part of the big semantic stumbling block.)
I’d further argue that this is generally more correct. The Device
is
truly the property of the Company
and not the Employee
.
And, this model is just richer. I don’t know if this is a requirement of the original design, but you could also see which devices are currently unassigned. You could also soft-delete an assignment, which would enable a historical record of assignments. Also, it’s a pretty easy change to allow an employee to have multiple devices.
Lastly, you can rely on the database to help maintain data integrity.
For example, you can add uniqueness constraints to the table backing
DeviceAssignment
to ensure that each Employee
has at most one
Device
.
So, this is more code, and if I were knocking out a quick-and-dirty MVP, I might choose the more naive implementation. Relatedly, this is a lot more work than a one-line change, and in Stephanie’s case, it would require data migrations, which are a whole different level of effort (and she made clear that was out of her budget.)
Anyway, I enjoyed the episode and hope this doesn’t come across as critical–it’s just me playing along at home.