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 :).
Top comments (0)