About Rails concerns

> TL;DR. Don’t use Rails concerns.

Introduction

If you are an experienced Rails developer, you won’t need explanations about what a concern is. For those who are new to the framework, here is a short explanation:

The Concern is a tool provided by the ActiveSupport lib for including modules in classes, creating mixins.
module Emailable
include ActiveSupport::Concern
  def deliver(email)
# send email here...
end
end

class Document
include Emailable
  def archive
@archived = true
deliver({to: 'me@mydomain.com', subject: 'Document archived', body: @content})
end
end

Sounds great, right? Any class including our Emailable concern would be able to send emails. Unfortunately, not every example in a common Rails project is as clear as the one explained above.

In this post we’ll talk about some anti-patterns regarding Concerns, what problems can arise, and how we can solve them.

Concerns used wrong

Using Concerns to make the classes smaller

Very often, concerns are used to reduce the size of a class. This is even more common in projects using tools such as Rubocop in their CI process. In those situations, when a file exceeds a particular threshold, the quickest solution is to extract a concern. The logic is moved elsewhere and the number of lines of that class is reduced. But that reduction comes with a cost.

First of all, the refactor is purely cosmetic. Although we move the logic to a separate file, in runtime, the behavour will still be in the original class. Modules are a form of inheritance in ruby, so the responsibilities will be still there.

Secondly, by using a concern we are losing explicitness. For instance, consider the following example

class Document
include Emailable, Storable, Erasable
  def archive
@archived = true
deliver({to: 'me@mydomain.com', subject: 'Document archived', body: @content})
remove
end
end

The method deliver is quite intuitive because of its arguments, so we can assume it is implemented in the Emailableconcern. But what about remove? I could suspect it is provided by Erasable, but it could also be implemented inStorable. Now imagine a class with 10 concerns, lots of method calls and not-so-meaningful names. Following the execution flow can be quite hard, and we may find ourselves doing searches to find out where's the method we are calling.

A good heuristic to find this antipattern is to look for how many classes implement a concern. If there’s only one, then we may be loosing the good parts of concerns.

Bi-directional dependencies

Consider the following example:

module Printable
include ActiveSupport::Concern
  def print
raise UnknownFormatError unless ['pdf', 'doc'].include?(@format)
    # do print @content
end
end

class Document
include Printable
  def initialize(format, content)
@format = format
@content = content
end
  def export
# ...
print
end
end

The class Document depends on the Printable concern. But, equally, the Printable concern knows implementation details of Document (the @format and @content instance attributes). The problem with bi-directional dependencies can be found in any form of inheritance in which superclasses know implementation details of their subclasses.

Whenever possible, bi-directional dependencies should be avoided. The knowledge should flow one way only, and the communication should be explicitly declared via the public interface. Implicit knowledge doesn’t scale, and any future consumer of Emailable may forget to declare @format. So, a fixed version of the concern would be the following:

module Printable
include ActiveSupport::Concern
  def print(format, content)
raise UnknownFormatError unless ['pdf', 'doc'].include?(format)
    # do print content
end
end

class Document
include Printable
  def initialize(format, content)
@format = format
@content = content
end
  def export
# ...
print(@format, @content)
end
end

Triangular dependencies

The problem above can become bigger if the dependency goes further than bi-directional.

module Connectable
include ActiveSupport::Concern
  def connect_to(device)
# ...
end
end
module Printable
include ActiveSupport::Concern
  def print(format, content)
connect_to(Printer::lookup)
# ...
end
end
class Document
include Connectable, Printable
  def export
# ...
print(@format, @content)
end
  def upload_to(remote_resource)
connect_to(remote_resource)
#...
end
end

In the example above, Document depends on two concerns, one to print the contents and the other to upload them to a remote resource. But Printable depends also on Connectable. The dependency is already defined in the Documentclass, but resolved implicitly. This kinds of situations are hard to deal with and should be avoided whenever possible.

Concerns used properly

It’s easy to see when something is broken. It’s not that easy to do the reverse and affirm that something has no flaws. I’d say good concerns are those without perceptible defects.We have described above some of those flaws, but you could consider your own code smells.

A good concern should be able to work in isolation, so it must be dependency-free. It should have a very concrete and limited responsibility. The kind of responsibilities for a concern should be framework or infrastructure related. That means that they shouldn’t contain business logic. Business logic is better modelled as abstractions (classes), rather than concerns. Value objects, services, repositories, aggregates or whatever artifact that fits better.

But even good concerns present software design problems. Concerns are a bit harder to test, since you need more arrangement. But maybe the most important problem is that concerns promote the is a relation between our classes. With theis a relation, an object inherits behaviour directly, so more and more responsibilites are aggregated to the object as long as we keep adding concerns. True seggregation of responsibilities come with has a relationship, either via composition or aggregation.

Finally, it’s hard to say what problem concerns solve. Every problem that concerns solve can be solved with composition or aggregation. Better than that, composition/aggregation solve the same problem but explicitly.

Explicit is better than implicit

The PEP-20, Zen of Python reads: Explicit is better than implicit. Implicit means you need a previous knowledge. Explicitness is self-explanatory, so it makes our brain work less. No one wants to loose time looking for the place where a method has been defined.

Writing an aggregation has the same cost as writing a concern, and it makes things explicit. Using composition is a bit more costly, but when I’m looking for loose coupling or polymorfism, it’s what I prefer.

# Aggregation
class Document
def archive
@archived = true
Mailer.deliver({to: 'me@mydomain.com', subject: 'Document archived', body: @content})
Store.remove
end
end

# Composition
class Document
def initialize(mailer, store)
@mailer = mailer
@store = store
end
  def archive
@archived = true
@mailer.deliver({to: 'me@mydomain.com', subject: 'Document archived', body: @content})
@store.remove
end
end