Is there an acceptable way to split classes into smaller, composeable mixins (which know about the parent) in Ruby?

Viewed 206

I find that some classes in Ruby, especially the User class in Ruby on Rails, have a tendency to become absolutely enormous. That in itself is just about manageagable but the tests files for such large classes also become massive too.

To try and compartmentalise this complexity I would like to compose the user Class from several sub-classes.

class User < ApplicationRecord
  include User::AuthMixin
  include User::EmailsMixin
  include User::SettingsMixin
end

Technically this works however it means that the Mixins end up referencing a bunch of properties on the User object that they don't actually know anything about. Take this EmailsMixin which compartmentalises all of the logic around multiple-emails per user:

module User::EmailsMixin

  def email
    primary_email.try :value
  end

  def update_email value
    email = emails.build value: value
    self.primary_email = email
  end
  ...
end

This works but it doesn't smell real good...

While this all works it feels like bad smelling code. Why should the User::EmailsMixin be referencing a primary_email that it doesn't have any knowledge of? And this will be the nature of the mixins - they are all referencing properties on the parent object (which is the User in this case).

Not only this but now I'm using Sorbet Typechecking it doesn't pass typechecking. Sorbet points out precisely this error and states that Method primary_email does not exist on User::EmailsMixin. Which of course is true.

So I have three questions:

  1. Is this a reasonable paradigm for compartmentalising code? Please note that this is not code that can be moved into service objects, it relates directly to attributes on the user object (as per above)
  2. If it is a reasonable paradigm is there a way that reference the parent object in each of the mixins? Is there a way to say "this module is in fact part of the User object and will be included/merged later"
  3. If this isn't a reasonable way of breaking down vast classes into smaller ones then how else should I consider doing so (to re-iterate, service objects won't cut it here as the code needs to access the parent properties)
1 Answers

There's no way to give a concrete answer without looking at all of your code and refactoring accordingly to your use case.

In my view, mixins (or Rails concerns) should be used very sparingly. They do nothing to reduce the surface area of the code (public interface is still the same "logical size"), and they only serve to hide things away and make things harder to find. This is the same reason deep inheritance can become unwieldy -- the "logical size" of a class is itself plus all of its superclasses. Moving code to another file does nothing to change that.

Without knowing exactly what you code is doing, I'm going to make a blind suggestion that often applies: separate persistence from domain logic. For example, I think it's fine to have #email and #update_email remain in User, because they deal with persistence, and having them close to the Model is fine b/c interfacing /w the DB is the model's responsibility.

However, if you were looking for a place for a #validate_email method, neither User nor Email models would be a good place to put it.

There is always more than one way to divide up your application, and as you've noticed, dividing up purely by responsibility (Emails, Auth, Settings), means you're not dividing by data access/persistence, i.e. "everything needs access to User properties".

If you divide by data, you'd find that these modules don't need access to (all) the properties of User. Auth probably only needs login and hashed_pass. Settings probably only needs a single hash.

With the logic moved out of User, you'll need to pass data out for processing retrieve the results when done -- this is the data interface between components you'll want to maintain. If it starts getting complex, perhaps the data interface itself should be a class/datatype. If you start needing to pass too many parameters, think about if whether the boundaries between components are in the right place.

A warning -- many rails libraries don't make this distinction, often suggesting you include/extend your models to access their functionality, so you'll have to find a way to work with that if you want to use them.

Related