Why Ruby Class Methods Resist Refactoring

on

One of the most common questions I received following my 7 Patterns to Refactor Fat ActiveRecord Models post was “Why are you using instances where class methods will do?”. I’d like to address that. Simply put:

I prefer object instances to class methods because class methods resist refactoring.

To illustrate, let’s look at an example background job for syncing data to a third-party analytics service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class SyncToAnalyticsService
  ConnectionFailure = Class.new(StandardError)

  def self.perform(data)
    data              = data.symbolize_keys
    account           = Account.find(data[:account_id])
    analytics_client  = Analytics::Client.new(CC.config[:analytics_api_key])

    account_attributes = {
      account_id:         account.id,
      account_name:       account.name,
      account_user_count: account.users.count
    }

    account.users.each do |user|
      analytics_client.create_or_update({
        id:             user.id,
        email:          user.email,
        account_admin:  account.administered_by?(user)
      }.merge(account_attributes))
    end
  rescue SocketError => ex
    raise ConnectionFailure.new(ex.message)
  end
end

The job iterates over each user sending a hash of attributes as an HTTP POST. If a SocketError is raised, it gets wrapped as a SyncToAnalyticsService::ConnectionFailure, which ensures it gets categorized properly in our error tracking system.

The SyncToAnalyticsService.perform method is pretty complex. It certainly has multiple responsibilities. The Single Responsibility Principle (SRP) can be thought of as fractal, applying at a finer grained level of detail to all of applications, modules, classes and methods. SyncToAnalyticsService.perform is not a Composed Method because not all of the operations of the method are at the same level of abstraction.

One solution would be to apply Extract Method a few times. You’d end up with something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class SyncToAnalyticsService
  ConnectionFailure = Class.new(StandardError)

  def self.perform(data)
    data                = data.symbolize_keys
    account             = Account.find(data[:account_id])
    analytics_client    = Analytics::Client.new(CC.config[:analytics_api_key])

    sync_users(analytics_client, account)
  end

  def self.sync_users(analytics_client, account)
    account_attributes  = account_attributes(account)

    account.users.each do |user|
      sync_user(analytics_client, account_attributes, user)
    end
  end

  def self.sync_user(analytics_client, account_attributes, user)
    create_or_update_user(analytics_client, account_attributes, user)
  rescue SocketError => ex
    raise ConnectionFailure.new(ex.message)
  end

  def self.create_or_update_user(analytics_client, account_attributes, user)
    attributes = user_attributes(user, account).merge(account_attributes)
    analytics_client.create_or_update(attributes)
  end

  def self.user_attributes(user, account)
    {
      id:             user.id,
      email:          user.email,
      account_admin:  account.administered_by?(user)
    }
  end

  def self.account_attributes(account)
    {
      account_id:         account.id,
      account_name:       account.name,
      account_user_count: account.users.count
    }
  end
end

This is a little better the original, but doesn’t feel right. It’s certainly not object oriented. It feels like a weird hybrid of procedural and functional programming, stuck in an object-based world. Additionally, you can’t easily declare the extracted methods private because they are at the class level. (You’d have to switch to an ugly class << self form.)

If I came across the original SyncToAnalyticsService implementation, I wouldn’t be eager to refactor only to end up with the above result. Instead, suppose we started with this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class SyncToAnalyticsService
  ConnectionFailure = Class.new(StandardError)

  def self.perform(data)
    new(data).perform
  end

  def initialize(data)
    @data = data.symbolize_keys
  end

  def perform
    account           = Account.find(@data[:account_id])
    analytics_client  = Analytics::Client.new(CC.config[:analytics_api_key])

    account_attributes = {
      account_id:         account.id,
      account_name:       account.name,
      account_user_count: account.users.count
    }

    account.users.each do |user|
      analytics_client.create_or_update({
        id:             user.id,
        email:          user.email,
        account_admin:  account.administered_by?(user)
      }.merge(account_attributes))
    end
  rescue SocketError => ex
    raise ConnectionFailure.new(ex.message)
  end
end

Almost identical, but this time the functionality is in an instance method rather than a class method. Again, applying Extract Method we might end up with something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class SyncToAnalyticsService
  ConnectionFailure = Class.new(StandardError)

  def self.perform(data)
    new(data).perform
  end

  def initialize(data)
    @data = data.symbolize_keys
  end

  def perform
    account.users.each do |user|
      create_or_update_user(user)
    end
  rescue SocketError => ex
    raise ConnectionFailure.new(ex.message)
  end

private

  def create_or_update_user(user)
    attributes = user_attributes(user).merge(account_attributes)
    analytics_client.create_or_update(attributes)
  end

  def user_attributes(user)
    {
      id:             user.id,
      email:          user.email,
      account_admin:  account.administered_by?(user)
    }
  end

  def account_attributes
    @account_attributes ||= {
      account_id:         account.id,
      account_name:       account.name,
      account_user_count: account.users.count
    }
  end

  def analytics_client
    @analytics_client ||= Analytics::Client.new(CC.config[:analytics_api_key])
  end

  def account
    @account ||= Account.find(@data[:account_id])
  end
end

Instead of adding class methods that have to pass around intermediate variables to get work done, we have methods like #account_attributes which memoize their results. I love this technique. When breaking down a method, extracting intermediate variables as memoized accessors is one of my favorite refactorings. The class didn’t start with any state, but as it was decomposed it was helpful to add some.

The result feels much cleaner to me. Refactoring to this feels like a clear win. There is now state and logic encapsulated together in an object. It’s easier to test because you can separate the creation of the object (Given) from the invokation of the action (When). And we’re not passing variables like the Account and Analytics::Client around everywhere.

Also, every piece of code that uses this logic won’t be coupled to the (global) class name. You can’t easily swap in new a class, but you can easily swap in a new instance. This encourages building up additional behavior with composition, rather than needing to re-open and expand classes for every change.

Refactoring note: I’d probably leave this class implemented as the last source listing shows. However, if the logic becomes more complex, this job is begging for a class to sync a single user to be split out.

So how does this relate to the premise of this article? I’m unlikely to see the opportunities for refactoring a class method because decomposing them produces ugly code. Starting with the instance form makes your refactoring options clear, and reduces friction to taking action on them. I’ve observed this effect many times in my own coding and also anecdotally across many Ruby teams over the years.

Objections

YAGNI (You Aren’t Going To Need It)

YANGI is an important principle, but misapplied in this case. If you open these classes in your editor, neither form is more or less complicated than the other. Saying “YAGNI an object here” is sort of like saying “YAGNI to indent with two spaces instead of a single tab.” The variants are only different stylistically. Applying YAGNI to object oriented design only make sense if there’s a difference in understandability (e.g. using one class versus two).

It Uses an Extra Object

Some people object on the basis that the instance form creates an additional object. That’s true, but in practice it doesn’t matter. Rails requests and background jobs create thousands upon thousands of Ruby objects. Optimizing object creation to lessen the stress on the Ruby garbage collector is a legitimate technique, but do it where it counts. You can only find out where it counts by measuring. It’s inconceivable that the single additional object that the instance variant creates will have a measurable impact on the performance of your system. (If you have a counter example with data, I’d love to hear about it.)

It’s Cumbersome to Call

Finally, some object that it is just easier to type:

1
2
3
Job.perform(args)
#  vs.
Job.new(args).perform

That is true. In that case I simply define a convenience class method that builds the object and delegates down. In fact, that is one of the few cases I think class methods are okay. You can have your cake and eat it too, in this regard.

Wrapping Up

Begin with an object instance, even if it doesn’t have state or multiple methods right away. If you come back to change it later, you (and your team) will be more likely to refactor. If it never changes, the difference between the class method approach and the instance is negligible, and you certainly won’t be any worse off.

There are very few cases where I am comfortable with class methods in my code. They are either convenience methods, wrapping the act of initializing an instance and invoking a method on it, or extremely simple factory methods (no more than two lines), to allow collaborators to more easily construct objects.

What do you think? Which form do you prefer and why? Are there any pros/cons that I missed? Post a comment and let me know what you think!

P.S. If this sort of thing interests you, and you want to read more articles like it, you might like the Code Climate newsletter (see below). It’s just one email a month, and includes content about refactoring and object design with a Ruby focus.

Further Reading

Thanks to Doug Cole, Don Morrison and Josh Susser for reviewing this post.

Looking for more about Ruby, code quality, OOP and Rails security? Subscribe to our newsletter.

Comments