How to handle two-way dependencies in modularized codebase using `belongs_to` / `has_many` and ActiveSupport hooks

This message was imported from the Ruby/Rails Modularity Slack server. Find more info in the import thread.

Message originally sent by slack user U723PSM8PD2

Hi.

We’ve been working on modularising our monolith, and we’ve found a big stumbling block is the two-way dependency caused by belongs_to / has_many (or has_one) So if we have one pack in engines/companies and one in engines/employees, and we have…

# engines/companies/app/models/company.rb
class Company < ApplicationRecord
  has_many :employees, inverse_of: :company
end

# engines/employees/app/models/employee.rb
class Employee < ApplicationRecord
  belongs_to :company, inverse_of: :employees
end

…then whichever way round we define the dependencies, Packwerk will complain about a cyclic dependency.

Reading back a bit here, it seems like the preferred solution is to have engines/employees depend on engines/companies and then replace the has_many with a class method:

# engines/companies/app/models/company.rb
class Company < ApplicationRecord
end

# engines/employees/app/models/employee.rb
class Employee < ApplicationRecord
  belongs_to :company

  def self.for_company(company)
    where(company: company)
  end
end

…which does break the dependency, but has the unpleasant side-effect that we can’t define the inverse relationship any more, and so we open ourselves up to N+1 errors. So we’ve been using ActiveSupport hooks to mix the has_many into the parent class from the child pack:

# engines/companies/app/models/company.rb
class Company < ApplicationRecord
end
ActiveSupport.run_load_hooks(:myorg_company)

# engines/employees/app/models/employee.rb
class Employee < ApplicationRecord
  belongs_to :company, inverse_of: :employees
end

# engines/employees/app/models/concerns/company_extensions.rb
module Employees::CompanyExtensions
  extend ActiveSupport::Concern

  included do
    has_many :employees, inverse_of: :company
  end
end

# engines/employees/config/initializers/engines/companies.rb
ActiveSupport.on_load(:myorg_company) do
  include Employees::CompanyExtensions
end

I’m about to propose that we use this approach throughout our codebase in order to tame the N+1s that have been sneaking in since we started modularising. What drawbacks do you see with this approach? I’d like to anticipate some of those concerns in my proposal (or be convinced out of it if they’re too severe).

Message excluded from import.

Message originally sent by slack user U723PSM8PD2

Thanks. I think some of that might be due to my poor explanation, so let me clear it up a bit:

• Splitting code up based on noun, rather than business process
That’s definitely just an artifact of me choosing a simple example. Our real code is separated along more sensible lines.

• Responding to an error in a tool by changing the code to make it more convoluted, but effectively the same
• Using class methods, rather than query objects
These feel like responses to the second state, which I’m rejecting, rather than the third, which I’m proposing. Or have I misunderstood?

• Addressing an N+1 potential problem with tooling, rather than query objects, testing, and diligence

To be clear, the N+1 problems didn’t exist before we moved to the second state, and do exist in the second state, and they go away in the third state.

Message excluded from import.

Message originally sent by slack user U723PSM8PD2

Right you are, thanks.

Message excluded from import.

Message originally sent by slack user U723PSM8PD2

Yeah, the problem is more around situations like (again, contrived example)

c = Company.find(whatever)
e = c.employees.first
e2 = e.company.employees.second

That’s two queries: one to fetch the company, one to fetch its users. Whereas in the second state, without inverses, the equivalent would be…

c = Company.find(whatever)
e = Employee.for_company(c).first
e2 = Employee.for_company(e.company).second

…which is four queries: one to fetch the company, one to fetch its users, one to fetch the company again, and one to fetch the users again. So old monolith code that worked fine gets N+1 errors if we use the recommended class-method approach, but not if we use the mixin approach.

Message excluded from import.

Message excluded from import.

Message excluded from import.

Message originally sent by slack user U723PSM8PD2

That’s fair. My proposal is about how we deal with our existing codebase, which does do stuff like that.

We’ve been through several rounds of how to reorganise our codebase, and quite often it has gone like this:
• Our codebase is disorganised and hard to refactor
• It ought to look like [thing], then it would be easy to refactor
• We can’t refactor it to look like [thing] because it’s disorganised and hard to refactor
So it’s a cyclic dependancy problem. As above, so below, I guess. So the nut we have to crack is how do we make it easier to refactor without requiring a big refactor up-front. So when you say….

changing the code to make it more convoluted, but effectively the same

…convoluted isn’t what we’re going for, but “effectively the same” is absolutely the goal here, and a bit of convoluted is worth it to move us to a future where we can start to refactor things at a higher level.

Message originally sent by slack user U723PSM8PD2

Maybe that means I’m in the wrong channel? :slightly_smiling_face:

Message excluded from import.

Message excluded from import.

Message originally sent by slack user U723PSM8PD2

Thanks.

Splitting up an entangled codebase is impossible

I hope it’s OK to say I hope we prove you wrong eventually? Only time will tell, and either way, it’ll be a fair amount of it.

Message excluded from import.

Message excluded from import.

Message excluded from import.

Message excluded from import.

Message excluded from import.