How do I avoid using Concerns when designing Rails database schemas?

430 views Asked by At

I read many blogs, and one of the themes that comes across often is that concerns (at least the way Rails defines them) are damaging to software. On balance I agree - simply including behaviour into models is violating the single responsibility principle. You end up with a god-class that does too much.

But as with many of the opinions gleaned from blogs, an alternative architecture is rarely provided.

So let's take an example app, loosely based on one I have to maintain. It's inherently a CMS, as many Rails apps tend to be.

Currently each model has a large number of concerns. Let's use a few here:

class Article < ActiveRecord::Base
  include Concerns::Commentable
  include Concerns::Flaggable
  include Concerns::Publishable
  include Concerns::Sluggable
  ...
end

You can imagine that 'Commentable' would require only a small amount of code added to the Article. Enough to establish relationships with comment objects and provide some utility methods to access them.

Flaggable, allowing users to flag inappropriate content, ends up adding some fields to the model: flagged, flagged_by, flagged_at for example. And some code to add functionality.

Sluggable adds a slug field for referencing in URLs. And some more code.

Publishable adds publish date and status fields, with yet more code.

Now what happens if we add a new kind of content?

class Album < ActiveRecord::Base
  include Concerns::Flaggable
  include Concerns::Sluggable
  include Concerns::Publishable
  ...
end

Albums are a bit different. You can't comment on them, but you can still publish them and flag them.

Then I add some more content types: Events and Profiles, let's say.

I see a few problems with this architecture as it stands:

  • We have multiple database tables with exactly the same fields (flagged_by, published_on etc.)
  • We can't retrieve multiple content types at once with a single SQL query.
  • Each model supports the duplicated field names with the included concerns, giving each class multiple responsibilities.

So what's a better way?

I've seen decorators promoted as a way to add functionality at the point where it's needed. This could help solve the issue of included code, but the database structure isn't necessarily improved. It also looks needlessly fiddly and involves adding extra loops to the code to decorate arrays of models.

So far my thinking goes like this:

Create a common 'content' model (and table):

class Content < ActiveRecord::Base
end

The associated table is probably quite small. It should probably have some kind of 'type' field, and maybe some things common to absolutely all content - like a type slug for URLs perhaps.

Then rather than adding concerns we can create an associated model for each behaviour:

class Slug < ActiveRecord::Base
  belongs_to :content
  ...
end

class Flag < ActiveRecord::Base
  belongs_to :content
  ...
end

class Publishing < ActiveRecord::Base
  belongs_to :content
  ...
end

class Album < ActiveRecord::Base
  belongs_to :content
  ...
end
...

Each of these is associated with one piece of content, so the foreign key can exist on the feature's model. All the behaviour relating to the feature can also exist solely on the feature's model, making OO purists happier.

In order to achieve the kind of behaviour that usually requires model hooks (before_create for example) I can see an observer pattern being more useful. (A slug is created once a 'content_created' event is sent, etc.)

This looks like it would clean things up no end. I can now search all content with a single query, I don't have duplicated field names in the database and I don't need to include code into the content model.

Before I merrily unleash it on my next project, has anyone tried this approach? Would it work? Or would splitting things up this much end up creating a hell of SQL queries, joins and tangled code? Can you suggest a better alternative?

1

There are 1 answers

5
max On BEST ANSWER

Concerns are basically just a thin wrapper around the mixin pattern. It is a very useful pattern for composing pieces of software around reusable traits.

Single Table Inheritance

The issue of having the same columns across several models is often solved with Single Table Inheritance. STI however is only really suited when the models are very similar.

So lets consider your CMS example. We have several different types of content:

Page, NewsArticle, BlogPost, Gallery

Which have pretty much identical database fields:

id
title
content
timestamps
published_at
published_by
# ...

So we decide to get rid of duplication and use a common table. It would be tempting to call it contents but that is extremely ambiguous - content of the content ...

So lets copy Drupal and call our common type Node.

class Node < ActiveRecord::Base
  include Concerns::Publishable
end

But we want to have different logic for each type of content. So we make subclasses for each type:

class Node < ActiveRecord::Base
  self.inheritance_column = :type 
  include Concerns::Publishable
end

class NewsArticle < Node
  has_and_belongs_to_many :agencies
end

class Gallery < Node
  has_and_belongs_to_many :photos
end

# ...

This works well until the STI models start to diverge for too much from each other. Then some duplication in the database schema can be a far smaller problem than the massive complications caused by trying to shoehorn everything into the same table. Most CMS systems built on relational databases struggle with this issue. One solution is to use a schemaless non-relational database.

Composing concerns

There is nothing in that says that concerns require you to store on the models table. Lets look at several of the concerns you have listed:

Flaggable
Sluggable
Commentable

Each of these would use a table flags, slugs, comments. The key is making the relation to object that they flag, slug or comment polymorphic.

comment:
  commented_type: string
  commented_id: int
slugs:
  slugged_type: string
  slugged_id: int
flags:
  flagged_type: string
  flagged_id: int
# ...

class Comment
  belongs_to: :commented, polymorphic: true
end

module Concerns::Commentable
  # ...
  has_many: :comments
end

I would suggest that you look at some of the libraries that solve these kind of common tasks such as FriendlyId, ActsAsTaggedOn etc to see how they are structured.

Conclusion

There is nothing fundamentally wrong with the idea of parallel inheritance. And the idea that you should forgo it just to placate some kind extreme OO purity ideal is ridiculous.

Traits are a part of object orientation just any other composition technique. Concerns are however not the magic-fix all that many blog posts would have you believe.