Refactoring Demo - Counting posts

I found a question somewhere and decided to expand in a bit longer fashion on it. Note: The code is largely working pseudocode. I won't check / run this code and parts may be missing to just illustrate the example.

In the first refactoring demo I found this Reddit link and question (screenshot below):

The other answers are okay but they expand on the user model which is often the biggest God model in the application. It's also not a very extensible solution. What if I want comments in the last 30 days or total number of comments for a post. Typically I see methods and scopes pile up on the models for each permutation the app needs. But, I think there is a better way.

Make a class

The number of comments over a duration is a distinct thing. It is separate than a user, or comment and thus deserves it's own class. Let's begin by creating the rough class template.

class DailyCommentCount
  self.for(user)
    new(user).count
  end
  
  def initialize(user)
    @user = user
  end
  
  def count
    user.comments.where('created_at >= ?', 24.hours.ago)
  end
  
  private 
  
  attr_reader :user
end

This looks nice. Now we can use it anywhere.

<%= DailyCommentCount.for(current_user) %>

Why is this better?

You gain flexibility. By putting the code on the user model how does it work for other items? And we know how reality is. You are not only going to need a number of comments in some places. You are going to want to change the duration period, the display format etc. Let's expand on this a little bit.

class CommentCount
  self.for(object)
    new(object: object).count
  end
  
  def initialize(object:, timeframe: 24.hours.ago)
    raise StandardError.new("") if [Comment, User].exclude?(object.class)
    @timeframe = timeframe
    @object = object
  end
  
  def count
    object.comments.where('created_at >= ?', timeframe)
  end
  
  private 
  
  attr_reader :object, :timeframe
end

With a minute or two of fiddling around, I expanded the capabilities of this class without breaking old functionality. I did rename it to reflect the broader scope but that is not required. This class now:

  • Works for any object that has comments
  • Can specify the timeframe

But.. alas, now we have some funky requirements and we need to format the output better.

class CommentCount
  self.for(object)
    new(object: object).count
  end
  
  def initialize(object:, timeframe: 24.hours.ago)
    raise StandardError.new("") if [Comment, User].exclude?(object.class)
    @timeframe = timeframe
    @object = object
  end
  
  def count
    object.comments.where('created_at >= ?', timeframe)
  end
  
  def written_count_long
    I18n.t(
      "#{object.class.name.downcase}.written_count",
      number_in_words: number_in_words,
      count: count
    )
  end
  
  def written_count
    I18n.t(
      "#{object.class.name.downcase}.written_count", 
      count: count
    )
  end
  
  private 
  
  attr_reader :object, :timeframe
  
  def number_in_words
     count.to_words #pseudo ish... hopefully there is a class for this!
  end
end

Testing is a joy

This is now super easy to test. With the other way you need to render your views most likely to get a valid test of all the requirements. But now that it's in a tiny encapsulated class it becomes dead simple. I put the test outline below for rspec.

require "rails_helper";

describe CommentCount do 
  describe ".for" do
    it "should instantiate a new class and call count" do
      comment_count = CommentCount.new(object: mocked_user)
      expect(comment_count).to receive(:count)
      expect(CommentCount).to receive(:for).and_return(comment_count)
      
      CommentCount.for(mocked_user)
    end
  end
  
  describe ".new" do
    it "should raise an error if not a valid model" do
    end
  end
  
  describe "#count" do
    it "should default to the count in the last day" do
    end
    
    it "should be able to adjust the timeframe" do
    end
    
    it "should work with models that respond to comments" do
    end
  end
  
  describe "#written_count" do
  	it "should return the count as an integer and 'comments'" do
    end
  end
  
  describe "written_count_long" do
	it "should return the count as text and 'comments'" do
    end
  end
end

Getting nit-picky

I think this code has a bug after looking at what I just wrote. The code is fine. It works. The tests pass but still something is a bit off. When writing the test I wrote the requirement that "it should work with models that respond to comments". However, the code doesn't check that. It checks to ensure the model is a comment or a user.

This is a case where the described functionality of the class does not do what we say it does. In the future this could cause bugs. The team may operate under the assumption that it works with any object that responds to comments. The code however does not work that way.

Happy coding! Until next time.