Avoiding Junk-Drawer Classes in Ruby

Because Ruby is an object-oriented language, we tend to model the world as a set of objects. We say that two integers (x and y) are a Point, and a Line has two of them.

While this approach is often useful, it has one big problem. It privileges one interpretation of the data over all others. It assumes that x, and y will always be a Point and that you'll never need them to act as a Cell or Vector.

What happens when you do need a Cell? Well, Point owns the data. So you add a your cell methods to Point. Over time you end up with a Point class that is a junk drawer of loosely-related methods.

Junk-drawer classes are so common that we often accept them as inevitable and tack on a "refactoring" step to development. But what if we could avoid the problem in the first place?

The first rule of web development is that we don't talk about the User class

At the heart of any battle-hardened production Rails app is a gigantic monstrosity of a class called User.

It always starts innocently enough. You want to let people log in. You need to store their username and password, so you create a class:

class User
  attr_accessor :username, :password, :email, :address

  def authenticate!(password)
    ...
  end
end

That works so well that eventually people want to give you money. "Ok," we say "a User is basically the same thing as a Subscriber so we'll just add a few attributes and a few methods."

class User
    ...

    attr_accessor :payment_processor_token, :subscription_plan_id, ...etc

    def charge_cc
      ...
    end
end

Great! Now we're making some real money! The CEO has decided that they want to be able to export VCards with users contact information so the sales team can easily import them into SalesForce. Time to fire up vim:

class User
    ...

    def export_vcard
      ...
    end
end

What have we done?

We started with a User class, whose only purpose was to handle authentication. By adding additional methods and attributes to it, we turned it into a User/Subscriber/Contact Frankenstein hybrid.

Why would we do this? Do we not respect ourselves as developers? Did our parents not love us enough? Or did we set ourselves up for failure by believing that this data really is a thing, an object?

Why do we say that the combo of username, password and email address is a User? Why not a Subscriber? Or a Contact? Or a SessionHolder?

The truth about data is that it's data

Data is just data. Today you may need to treat it like a boyfriend. Tomorrow it might be an ex-boyfriend.

This is the approach that functional languages like Elixir take. Data is stored in simple structures much like Ruby's hashes, arrays, strings, etc. When you want to do something with the data you pass it in to a function. Any results are returned from that function.

It sounds simple, but this approach makes it very easy to separate concerns into different modules.

Here's a cartoon of how we might construct our User system it in Elixir:

my_user = %{username: "foo", password: ..., phone: ..., payment_token: ...}
my_user = Authentication.authenticate(my_user)
my_user = Subscription.charge(my_user)
my_user = Contact.export_vcard(my_user)

Because data is separate from code, no module has a privileged interpretation of that data.

Bringing it back to Ruby

Since this approach works so well in Elixir, why not adopt it in Ruby? There's nothing stopping us from making User into a simple wrapper for its data, and pulling all business logic into modules.

class User
  attr_accessor :username, :password, :email, :address
end

module Authentication
  def self.authenticate!(user)
    ..
  end
end

module Subscription
  def self.charge(user)
    ..
  end
end

module Contact
  def self.export_vcard(user)
    ..
  end
end

You could even make the User class into a struct, or add code to make it (kind-of) immutable.

So what?

Does this seem like a trivial change? What's the point?

As we've seen, the typical OO approach has problems as it ages. By saying that a certain class owns data, we run into problems when we need to use that data in some other way.

In the modular approach, however, adding behavior presents no problems. We simply create a new module that interprets the data in whatever way it needs to. It's easy to do so because data and functionality are completely separate.

Other approaches

There are other approaches to preventing the junk-drawer problem. Traditionally, in object-oriented programming you would attempt to do so via inheritance and careful refactoring. In 2012 Sandi Metz published Practical Object Oriented Design in Ruby which convinced many people to start composing objects using dependency injection. Even more recently, the popularity of functional programming has caused Rubyists to experiment with immutable "data objects".

All of these approaches can be used to create clean, beautiful code. However the fact that classes typically own data means that there will always be tension between what the class is and what the class does with the data.

I suspect that whatever success these approaches have in avoiding the junk-drawer problem results from their decoupling data from code.