kukicola.io

Writing about ruby, rails and other web technologies.

[DRAFT](Un)successful ActiveRecord refactor

Posted on 2020-03-09 by Karol BąkComments
ruby
rails
code quality

Rails ships with a very powerful tool which is ActiveRecord. It provides access to database, maps records, handles relations, keeps business logic and more. Isn't it a bit too much? I think we can all agree that it violates first of SOLID principles - Single Responsibility Principle.

Does it mean we should get rid of it? I don't think so. It's a great tool which helps us write applications really fast but on the other hand it "encourage" us to write bad code. How many of us have seen massive, fat models responsible for way too much? We fight with this using concerns, services and other patterns but this time I would like to try to fix the source of the problem - ActiveRecord main concept.

Before we start - since I complain about ActiveRecord so much, why don't I just replace it with something else? There is a really cool library called rom-rb and it's easy to configure with Rails. There is just one problem - compatibility. Many different Rails components depends on ActiveRecord. What is more - a lot of gems too.

The idea

The idea is to separate application code from database related one by moving it out of our model. To achieve that I would like to introduce 3 types of classes instead of standard model:

  1. Entity - class responsible for keeping record attributes and business logic, without access to database.
  2. Relation - responsible for database communication and relations.
  3. Repository - bridge between app and database, which pulls and saves Entities to database using Relations.

Example app

To test my solution I have prepared a really simple "blog api" app with 2 models and 3 endpoints.

app/models/post.rb:

class Post < ApplicationRecord
  has_many :comments, dependent: :destroy

  scope :published, -> { where(published: true) }

  def content_preview
    content.truncate(100)
  end

  def comments_count
    comments.size
  end
end

app/models/comment.rb:

class Comment < ApplicationRecord
  belongs_to :post
end

app/controllers/posts_controller.rb:

class PostsController < ApplicationController
  def index
    render json: PostList.new.call
  end

  def show
    render json: Post.find(params[:id]).as_json(include: :comments)
  end

  def comment
    Post.find(params[:id]).comments.create(comment_params)

    head :ok
  end

  private

  def comment_params
    params.permit(:author, :comment)
  end
end

app/services/post_list.rb:

class PostList
  def call
    collection.as_json(only: [:id, :title, :created_at], methods: [:content_preview, :comments_count])
  end

  private

  def collection
    Post.published.includes(:comments).order(created_at: :desc)
  end
end

So as you can see in my Post model has few responsibilities:

  • keeps association with comments
  • keeps database querying logic - scope
  • keeps business logic - content_preview (I know, it's a really complex logic)

Controller has 3 different actions:

  • index - pulling whole posts list (without full content and comments)
  • show - pulling single post with all the data and comments
  • comment - creating a new comment for specific post

Apart from that we have a simple service to keep some of the logic away from controller. This service has to know the internals of the Post - which scopes to use, what associations to preload. I'll try to fix that as well.

Implementation

Let's start with base classes. Our Entity can look like this:

class BaseEntity
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON
end

I'm including some ActiveModel (not ActiveRecord!) modules to have access to methods like attribute, assign_attributes and to_json. As you can see this class has no database related logic.

Now let's move on to Relation:

class BaseRelation < ActiveRecord::Base

  def self.table_name
    self.name.sub('Relation', '').pluralize.underscore
  end

  def to_entity
    entity_class = self.class.name.sub('Relation', 'Entity').constantize
    entity = entity_class.new(**attributes.symbolize_keys)
    self.class.reflections.keys.each do |association|
      if association(association.to_sym).loaded?
        entity.send("#{association}=", send(association).map(&:to_entity))
      end
    end
    entity
  end
end

After seeing this you are probably like "What the hell is this?". I know. It doesn't look exactly as I imagined. First method will handle proper database table selection - if we got PostRelation it will look for posts table. The second one is responsible for generating entities from data pulled from database. It also converts preloaded associations into entities. The biggest problem with this class is that it inherits from ActiveRecord::Base. So basically isn't it just a normal ActiveRecord model? Well - yes, but we will use it differently. I really tried to include only few ActiveRecord modules, to keep easy database querying but without mapping it to objects. Unfortunetelly they strongly depend on each other so I ended up including most of them. To make it more readable let's just stay with ActiveRecord::Base.

Finally, the last part - Repository:

class BaseRepository
  attr_reader :relation

  def initialize(relation: nil)
    @relation = relation || self.class.name.sub('Repository', 'Relation').constantize
  end

  def find(id)
    relation.find(id).to_entity
  end

  def create(entity)
    relation.create(entity.attributes).to_entity
  end
end

In real world it should include all basic methods like update, delete etc. but in this case I need only this two. So this class basically uses relation to execute proper query to database and returns results as entities.

Rewriting the app

Now it's the time to rewrite the application to use new classes. Again let's start with entities:

app/entities/post_entity.rb:

class PostEntity < BaseEntity
  attribute :id
  attribute :title
  attribute :content
  attribute :published
  attribute :created_at
  attribute :updated_at
  attribute :comments

  def content_preview
    content.truncate(100)
  end

  def comments_count
    comments&.size
  end
end

app/entities/comment_entity.rb:

class CommentEntity < BaseEntity
  attribute :id
  attribute :author
  attribute :content
  attribute :created_at
  attribute :updated_at
  attribute :post_id
end

No rocket science here. Entities keeps object attributes and my super complex business logic.

Let's move on to relations:

app/relations/post_relation.rb:

class PostRelation < BaseRelation
  has_many :comments, class_name: 'CommentRelation', foreign_key: 'post_id'
end

app/relations/comment_relation.rb:

class CommentRelation < BaseRelation
  belongs_to :post, class_name: 'PostRelation', foreign_key: 'post_id'
end

Also really simple - just keeping associations between tables.

And the last part - repositories:

app/repositories/post_repository.rb:

class PostRepository < BaseRepository
  def published_with_comments
    relation.where(published: true).includes(:comments).order(created_at: :desc).map(&:to_entity)
  end

  def find_with_comments(id)
    relation.includes(:comments).find(id).to_entity
  end
end

app/repositories/comment_repository.rb:

class CommentRepository < BaseRepository
end

For comments we don't need any specific methods. In PostRepository we will handle two cases:

  • pulling all posts with comments - will be useful instead in service,
  • fetching single post with comments - for a single post action.

As you can see, whole logic from Post model is now arranged by its responsibilities into 3 different classes. The question is - do they follow SRP? I think it's hard to say. Let's take a look at repository. On the one hand we can say that it handles only database communication (so it is SRP?) but on the other one it handle both fetching and saving (so it isn't SRP?). Maybe it would be good to introduce one more concept - "commands" but let's leave this question open.

Now, let's rewrite our service:

class PostList
  def initialize(repository = PostRepository.new)
    @repository = repository
  end

  def call
    collection.map { |entity| entity.as_json(only: [:id, :title, :created_at], methods: [:content_preview, :comments_count]) }
  end

  private

  attr_reader :repository

  def collection
    repository.published_with_comments
  end
end

There are two big advantages of this implementation:

  1. Service doesn't know about internals of this published_with_comments query
  2. Since repository is injected in the initializer it can be easily replaced with fake one is tests and it doesn't even have to connect with the database.

The only thing left is PostsController:

class PostsController < ApplicationController
  def index
    render json: PostList.new.call
  end

  def show
    render json: PostRepository.new.find_with_comments(params[:id]).as_json(include: :comments)
  end

  def comment
    entity = CommentEntity.new(comment_params.merge(post_id: params[:id]))
    CommentRepository.new.create(entity)

    head :ok
  end

  private

  def comment_params
    params.permit(:author, :content)
  end
end

No big improvements here to be honest, just adjusted it to the new classes.

Summary

Now, when the app is rewritten using new core concepts we can evaluate the solution.

Pros

  • Better code distribution and isolation - entities don't know anything about database, relations dont't know anything about business logic, queries are grouped into repositories.
  • In theory it should be compatible with everything dependent on ActiveRecord (just use Relation instead of model)
  • Better specs in some cases (like our service) which dont't use database
  • It's really hard to achieve N+1 associations loading problem.

Cons

  • Way more code
  • Still keeping full ActiveRecord model (as Relation)
  • Using Rails tool in non-rails-way

I personally have mixed feelings about this. I guess it would probably be better just to use rom-rb instead, if not using ActiveRecord dependent stuff. I think I won't use this solution in my real world projects but it was worth a try. Let me know what do you think in the comments!

We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.
Got it