Recently, after reading Object on Rails, I started thinking and experimenting with various ideas of making Rails applications code cleaner. Here’s one of these ideas.
Let’s imagine we have two model classes, connected with a has-many/belongs-to relation, e.g.:
# file user.rb class User < ActiveRecord::Base has_many :posts, dependent: :destroy # ... # rest of the user stuff # ... end
# file post.rb class Post < ActiveRecord::Base belongs_to :author, class: "User" end
The above code is probably how most of Rails programmers would go about implementing a “user has many posts/post belongs to its author” scenario. It’s the tutorials-approved way. But when you look at it from a architectonic point of view, you have just created a circular dependency.
Rails developers sneer at dependencies, since Rails handles finding and loading automatically. Probably half of us never had to use
require in our code. Of course, circular dependency in Rails is nowhere as bad as in C#, C++ or Java. But still, if you don’t pay attention to this problem, your models become a bunch of tightly coupled, entangled classes that cannot be used and tested without all of their friends. Modularizing application in this case becomes a really difficult task.
OK, so how do we deal with this problem? One option is to remove the
has_many :posts snippet from
User class. Doing this, however, we lose all of the goodies that
has_many provides: navigating to user’s posts, filtering them, building new objects that are automatically connected to the user, automatic removal of all posts together with their author etc.
But does this functionality really belong to
User class? To me, this is a violation of Single Responsibility Principle. Class should only have one reason for change. Changing a (presumably finished)
User class in order to add a
Post class is an example of Shotgun surgery anti-pattern. (In fact, we need to change the
User class every time we add any class that has some relation to
Let’s look at it this way: can a user exist without any posts? Sure, every newly created user has no posts. There might be users that will never create any posts. On the other hand, can a post exist without an author? A blog post always has an author, unless it’s a random sequence of letters from a random number generator. The
belongs_to :author relation defines a strong dependence from post to user. Good practice says dependencies between layers should always go only in one direction. We should try to achieve this at class level, too, as much as possible.
Let’s try a thought experiment. How about injecting the
has_many :posts relation into
User class only when the
Post class is loaded? In Ruby, it’s easily done
# file user.rb class User < ActiveRecord::Base # ... # the user stuff, nothing about posts # ... end
# file post.rb class Post < ActiveRecord::Base belongs_to :author, class: "User" end class User < ActiveRecord::Base has_many :posts, dependent: :destroy end
Now, is this code cleaner? Until we reference the
Post class in our code, the
User does not “know” anything about posts. In fact, we can’t even use
user.posts method — this results in a
NoMethodError. In a Rails application that should not be a problem, the
Post class will be mentioned sonner or later, injecting the
has_many :posts relation.
What are the pros & cons of this approach? On the pros side, the
User class is free of any reference of the
Post class. We are not tempted to sprinkle
User class with
published_posts methods, that always seem like useful shortcuts at first, but in the long run make our code unmaintainable. All the code related to posts is now in the
post.rb file, even if technically we still add the
has_many :posts to
On the cons side, it’s harder to find everything that constitutes the
User class at runtime. This class is reopened when other files are loaded and new methods are defined on it. In the end, its interface is as cluttered as it was in the beginning. There’s also a possibility that we may somehow stumble on some missing relation, because some of the files have not been loaded yet (to be honest, I didn’t test the idea too extensively and I don’t know how likely is that problem, but my feeling is that it’s not).