Rails Relationships Rethought: Introducing The Missing Model

I 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.