ActiveRecord Refactoring: Concerns

ActiveRecord provides a lot of power – if not too much. To that power we add our own business logic to create rather large domain models. Here at Made I've started looking at various ways to tone down the responsibilities of my ActiveRecord models using various programming patterns.

In this short series I will be looking at three ways of reducing the responsibilities of your models:

Part One: Concerns

The first design pattern for reducing the responsibility of your ActiveRecord models is the concern pattern. Concerns can help reduce repeated logic across models. They also help group certain concerns of logic together. By moving logic into a file and labelling it you are keeping the aforementioned logic away from other code it isn't concerned with.

Imagine you have two models named Blog::Post and Blog::Comment. They both have duplicated logic for displaying a long form date:

# app/models/blog/post.rb
module Blog
  class Post < ActiveRecord::Base
    def long_date
      date.strftime("%A, #{date.day.ordinalize} %B %Y")
    end
  end
end
# app/models/blog/comment.rb
module Blog
  class Comment < ActiveRecord::Base
    def long_date
      date.strftime("%A, #{date.day.ordinalize} %B %Y")
    end
  end
end

This clearly violates the DRY principle, that is, Don't Repeat Yourself. The DRY principle is a well known principle these days and I wouldn't be surprised if you're already familiar with using mixins in this instance:

# app/models/concerns/blog/date_concern.rb
module Blog
  module DateFormattable
    def long_date
      date.strftime("%A, #{date.day.ordinalize} %B %Y")
    end
  end
end
# app/models/blog/post.rb
module Blog
  class Post < ActiveRecord::Base
    include Blog::DateFormattable
  end
end
# app/models/blog/comment.rb
module Blog
  class Comment < ActiveRecord::Base
    include Blog::DateFormattable
  end
end

And that's it. You removed repetition of logic by moving the definition of #long_date to Blog::DateFormattable and then used include to mixin the method to both Blog::Post and Blog::Comment.

Logical grouping and naming of concerns

There are a few points to make about concerns, the first being logical grouping of methods. In the example above we created a Blog::DateFormattable module. This module clearly will contain date-specific methods. If for example you wanted to add a bunch of methods for formatting the authors name of both Blog::Post and Blog::Comment then you would use another concern module.

# app/models/concerns/blog/authorable.rb
module Blog
  module Authorable
    def author_full_name
      "#{author.first_name} #{author.last_name}"
    end
  end
end
# app/models/blog/post.rb
module Blog
  class Post < ActiveRecord::Base
    include Blog::DateFormattable
    include Blog::Authorable
  end
end
# app/models/blog/comment.rb
module Blog
  class Comment < ActiveRecord::Base
    include Blog::DateFormattable
    include Blog::Authorable
  end
end

So now we've added #author_full_name to our models. We're still DRY and our concerns are descriptively named.

Testing Concerns

The second and final point I want to make is on testing concerns. You have two avenues here, testing the concern directly in isolation or testing the concern functionality in every class spec that includes it.

Let's look at testing concerns directly in isolation.

# spec/models/concerns/blog/authorable_spec.rb
describe Blog::Authorable do
  let(:authorable) {Class.new.extend(described_class)}

  before(:each) do
    authorable.stub(:author => double(:first_name => 'Luke', :last_name => 'Morton'))
  end

  context '#author_full_name' do
    it "should return the full name of the author" do
      authorable.author_full_name.should eq('Luke Morton')
    end
  end
end

In this example we use the ruby to it's full advantage and create an anonymous class with Class.new and mixin our concern. We then stub #author on our anonymous class which itself returns a double with the #first_name and #last_name methods. We then assert the return value of #author_full_name.

That's all well and good but personally I only see this method of testing concerns useful when using the test to guide your design. This hardly ever happens. More often you will define the method in the model and then realise you want to reuse it elsewhere. At this point you've already written the tests for the first implementation so writing a concern specific test to reimplement the function seems somewhat unnecessary.

The alternative is to use rspec's #shared_examples_for. Let's start with our spec for Blog::Post:

# spec/models/blog/post_spec.rb
require 'spec_helper'

describe Blog::Authorable do
  let(:post) {described_class.new}
  context '#author_full_name' do
    it "should return the full name of the author" do
      post.stub(:author => double(:first_name => 'Luke', :last_name => 'Morton'))
      post.author_full_name.should eq('Luke Morton')
    end
  end
end

Now we want to reuse this method in Blog::Comment so we should first move the test into a shared example.

# spec/support/shared_examples/authorable_example.rb
shared_example_for Blog::Authorable do
  before(:each) do
    authorable.stub(:author => double(:first_name => 'Luke', :last_name => 'Morton'))
  end

  context '#author_full_name' do
    it "should return a full name of the author" do
      authorable.author_full_name.should eq('Luke Morton')
    end
  end
end

And update the Blog::Post spec to use this shared example:

# spec/models/blog/post_spec.rb
require 'spec_helper'

describe Blog::Post do
  let(:post) {described_class.new}

  it_should_behave_like Blog::Authorable do
    let(:authorable) {post}
  end
end

The tests should still be passing. You can then go ahead and reuse the shared example in your Blog::Comment spec.

# spec/models/blog/post_spec.rb
describe Blog::Comment do
  let(:comment) {described_class.new}
  it_should_behave_like Blog::Authorable do
    let(:authorable) {comment}
  end
end

Run the tests and they will be failing. Once we include Blog::Authorable in Blog::Comment our tests will be passing again.

Shared examples do not add much more complexity to your tests and ensure that the methods work in the objects that include them rather than in isolation. Usually you want to test a unit of work in isolation – away from everything else – but since ruby mixins are essentially a way of copy and pasting methods into classes I believe they should be tested at the level they are being used. If you can't use a concern method directly why should it be tested directly?

Your mileage may vary and you might prefer to test your concerns in isolation and that's cool. The major win is from testing them in the first place!

OK, so there you have it. An explanation on using concerns in ActiveRecord models and testing concern logic. Next time I will be discussing an example of using the decorator pattern as an alternative to concerns.

Disclaimer One

In Rails < 4 you need to add the app/models/concerns directory to your autoload path in your application class like so:

config.autoload_paths += %W(#{config.root}/app/models/concerns)

Disclaimer Two

I didn't even go into using ActiveSupport::Concern and its #include method.

Further Reading

About the Author
Luke Morton
Chief Technology Officer at Made Tech. Talk to me about delivering value through digital and technology, how inclusion and feminism can address a digital skills shortage, and on creating mentoring and coaching cultures.
avatar

We are hiring! Find out more about a career at Made Tech.

Download a preview of our new book

Legacy technology is one of the biggest threats to public sector organisations. Whether you’ve started your journey already or don’t know where to begin, this book has been written to guide you to define and implement the right approach for your organisation.

Get your preview copy now