Enhance repository pattern with refinements

Recently I'm spending a lot of time on thinking how to enforce some rules and boundaries in a significant Ruby project, but in a way that can be applied only to specific parts of application. An interesting idea came to my mind today so I went into code and tried it out.

Disclaimer: This is just an idea, I have not tested it in a production at all (although tested it on a production codebase)

Repository pattern

To decouple you persistence layer from domain layer, you can use repositories. Basic repository with ActiveRecord might look like this:

class PostRepository
  def find(id)
    Post.find(id)
  end

  def all_published
    Post.where(published: true).all
  end

  # ...
end

The advantage of this pattern is that you decouple your persistence logic into one place. The problem is (in the context of this post) that you still return ActiveRecord objects, which then can be misused and call persistence methods inside you business logic code.

Refinements

Refinements were added into Ruby to solve for a global "monkey patching" - extending classes globally. The whole idea can be read in the first paragraphs in the documentation. A short example:

module LoudInteger
  refine Integer do
    def hello
      "hello, #{self}!"
    end
  end
end

11.hello # => NoMethodError (undefined method `hello' for 11:Integer)

using LoudInteger

11.hello # => "hello, 11!"

What I found interesting is that you can enable refinements at the top level and they will be applied for a single file, which I feel might be very useful.

You may activate refinements at top-level, and inside classes and modules. You may not activate refinements in method scope. Refinements are activated until the end of the current class or module definition, or until the end of the current file if used at the top-level.

I thought it might be interesting to connect those two things together.

The domain layer refinement boundary

I've started with a simple module called DomainLayer. The api I want is very simple - at the beginning of a file that encapsulates domain logic I will call my refinements. When there is persistence method called inside this file, I want it to raise exception. Like this:

# frozen_string_literal: true

using DomainLayer

class PostService
  def publish(id)
    post = Post.find(id) # boom!
    post.publish
    post.save!
  end
end

The code to achieve this is actually rather simple.

module DomainLayer
  DomainLayerAccessError = Class.new(StandardError)

  refine ActiveRecord::Base do
    def find(*)
      raise DomainLayerAccessError, "don't use persistence methods in domain layer!"
    end
  end
end

a simple test can show that it is working correctly:

Failure/Error: raise DomainLayerAccessError, "don't use persistence methods in domain layer!"

DomainLayer::DomainLayerAccessError:
  don't use persistence methods in domain layer!
# ./app/lib/domain_layer.rb:8:in `find'
# ./app/domains/cms/post_service.rb:7:in `publish'

I have to use the repository to make it work:

# frozen_string_literal: true

using DomainLayer

class PostService
  def initialize(post_repository: PostRepository.new)
    @post_repository = post_repository
  end

  def publish(id)
    post = post_repository.find(id)
    post.publish
    post_repository.save(post)
  end

  private

  attr_reader :post_repository
end
Finished in 0.32838 seconds (files took 0.58 seconds to load)
1 example, 0 failures

Looks good, simple and easy to understand.

I've added the "repository pattern" in the title because of the example, but this pattern obviously can be used to much more than just repositories. I can imagine all the layers to enforce its own rules. Not only layers but, if you keep you domains/subdomains/modules separate, I guess you could enforce what can be used publicly. In fact Shopify for example created a specially designed tool just for this problem. Now we begin to have some language features to support this idea.

I'm a big fan of these kind of solutions because of its opt-in nature. Just like with rbs in separate files, just for most important code, I think there is a place for ideas with refinements for this code as well.

I will definitely explore more :).

27