Collaboration
Disclaimer: This episode is produced as an partnership with HanamiMastery.
Check out our companion episode featured on their website.
Introduction
I wrote a To-Do app that uses Ruby, Roda and a small Ruby framework I'm writing.
It works well for what it does.
It needs to access a database object in order to load, save or destroy tasks. This means that this database object is passed into many other objects. For example:
require "mongo"
class Database
attr_reader :client
def self.load(database_url: ENV.fetch("DATABASE_URL"))
new(database_url: database_url)
end
def reset!
db.drop
end
private
attr_reader :db
def initialize(database_url:)
@client = Mongo::Client.new(database_url)
@db = client.database
end
endThe main object, the TodoApp.
class TodoApp
attr_reader :todo_tasks
attr_reader :done_tasks
def self.load(db:)
new(db: db)
end
# ...
endAnd the TaskList.
class TaskList
include Enumerable
def self.load(db:, collection_name:)
new(db: db, collection_name: collection_name)
end
# ...
endKeep in mind that this is a tiny application that has very few classes. Specifically, it has three classes plus the database implementation.
This means that the database needs to be passed to 66% of the objects, which is a lot. If this application grows, we'll need to pass it to several objects.
tree lib
lib
├── database.rb
├── task_list.rb
├── task.rb
└── todo_app.rbAlso, we need to instantiate it on the main Roda file.
route do |r|
db = Database.load
todo_app = TodoApp.load(db: db)
done_tasks = todo_app.done_tasks
todo_tasks = todo_app.todo_tasks
# ...
endAnd on the specs.
describe "todo tasks" do
Given(:db) { Database.load }
Given(:todo_app) { TodoApp.load(db: db) }
# ...
endThe same happens with the main TodoApp object which needs to be present it both places.
Due to this, we need to control the creation and maintenance of the instances, which is something we don't want to take care of.
Lets run all the specs I used to drive the design of this application to show they're passing.
$ docker-compose run --rm web sh -c "bundle exec rspec --format progress /app/spec"
[+] Creating 1/0
✔ Container task-list-roda-app-mongodb-1 Running0.0s
The Gemfile's dependencies are satisfied
Randomized with seed 5581
.............
Finished in 0.18745 seconds (files took 0.35286 seconds to load)
13 examples, 0 failures
Randomized with seed 5581
RSpec Compilation finished at Mon Sep 11 17:26:16Solution: The Registry Pattern
In order to solve our dependencies hell, we're going to implement the Registry Pattern.
The definition of the Registry pattern that resonated with me the most comes from C2 Wiki, which states:
A registry is a global association from keys to objects, allowing the objects to be reached from anywhere.
So we need to implement a centralized registry to keep global objects at hand.
Lets create a Registry class with a load class method that creates an instance and memoizes it.
Then we create a private initializer.
I've been using this pattern in my projects because it allows me to very easily implement subclasses of my classes and load them from this instance creation method. We can talk about this in another episode.
class Registry
def self.load
@registry ||= new
end
private
def initialize
end
endNow that we know how to load the registry, lets assign it to a constant at the bottom of the file. This way, when the file is loaded, the main registry will be created.
REGISTRY = Registry.loadI'm not arguing this is the the best or the only way to do this, it's just the way I'm implementing it in this example.
We now need a way to register objects with a key. Lets write some implementation code.
One way we can approach this is to write a register method for our instance.
REGISTRY.register(:db, Database.load)But there's a better Ruby idiom to write this: using square brackets.
And now that we've chosen this idiom, lets also register the :todo_app.
REGISTRY[:db] = Database.load
REGISTRY[:todo_app] = TodoApp.loadTo implement this, we first assign an empty hash to an instance variable we'll call @registered_objects.
Then we define the []= method. And we implement it by assigning to the hash.
We'll also raise an error if the key has already been registered.
class Registry
def self.load
@load ||= new
end
def []=(key, value)
raise "#{key} is already on the Registry." if @registered_objects.has_key?(key)
@registered_objects[key] = value
end
private
def initialize
@registered_objects = {}
end
endWe'll also need a method to retrieve this objects. We could call it retrieve or get, but I prefer to be consistent and use the square brackets idiom again.
And to retrieve it, we can use the fetch method from the Hash instance.
# def retrieve
# def get
def [](key)
@registered_objects.fetch(key)
endUsing the registry
Now that we've implemented the pattern, let's propagate it throughout our application.
The first place we'll remove it from is the TodoApp class.
We first remove it from the load method and the initializer, and next we retrieve the @db instance variable from the database we get from our registry.
attr_reader :db
def self.load
new
end
def initialize
@db = REGISTRY[:db]
@todo_tasks ||= TaskList.load(collection_name: :todo_tasks)
@done_tasks ||= TaskList.load(collection_name: :done_tasks)
endNow that we made this change, we can retrieve it on the Web application.
class RodaApp < Roda # rubocop:disable Lint/ConstantDefinitionInBlock
plugin :render
plugin :sessions, secret: ENV.fetch("SESSION_SECRET")
route do |r|
db = REGISTRY[:db]
# ...
end
endAnd on the spec files.
Given(:db) { REGISTRY[:db] }And run the specs to see they still work.
docker-compose run --rm web sh -c "bundle exec rspec --format progress /app/spec"
[+] Creating 1/0
✔ Container task-list-roda-app-mongodb-1 Running 0.0s
The Gemfile's dependencies are satisfied
/app/boot.rb:11: warning: already initialized constant REGISTRY
/app/lib/registry.rb:23: warning: previous definition of REGISTRY was here
Randomized with seed 34287
.............
Finished in 0.12313 seconds (files took 0.34936 seconds to load)
13 examples, 0 failures
Randomized with seed 34287
RSpec Compilation finished at Mon Sep 11 17:48:51Next, we can do the same with the TodoApp.
We remove it from the load method and the initializer and we fetch the database from the registry.
class TodoApp
# ...
def self.load
new
end
# ...
private
attr_reader :db
def initialize
@db = REGISTRY[:db]
@todo_tasks ||= TaskList.load(collection_name: :todo_tasks)
@done_tasks ||= TaskList.load(collection_name: :done_tasks)
end
endAnd again, we retrieve it on the web application.
class RodaApp < Roda # rubocop:disable Lint/ConstantDefinitionInBlock
plugin :render
plugin :sessions, secret: ENV.fetch("SESSION_SECRET")
route do |r|
db = REGISTRY[:db]
todo_app = REGISTRY[:todo_app]
end
endAnd on the specs.
Given(:todo_app) { REGISTRY[:todo_app] }And run the specs.
docker-compose run --rm web sh -c "bundle exec rspec --format progress /app/spec"
[+] Creating 1/0
✔ Container task-list-roda-app-mongodb-1 Running0.0s
The Gemfile's dependencies are satisfied
/app/boot.rb:11: warning: already initialized constant REGISTRY
/app/lib/registry.rb:23: warning: previous definition of REGISTRY was here
Randomized with seed 28875
.............
Finished in 0.12799 seconds (files took 0.35522 seconds to load)
13 examples, 0 failures
Randomized with seed 28875
RSpec Compilation finished at Mon Sep 11 17:52:23We can also test this working on the web application, which it does.
Adding a third key
Now that we have our registry, we can add a third key very easily. For example, lets add a Logger.
REGISTRY[:logger] = MyLogger.newNow we can, for example, log every time we enter a route.
# frozen_string_literal: true
class RodaApp < Roda # rubocop:disable Lint/ConstantDefinitionInBlock
# ...
route do |r|
# ...
r.root do
REGISTRY[:logger].info("Rendering the Root path")
# ...
end
r.post "new_todo" do
description = r.params.dig("new_todo", "description")
REGISTRY[:logger].info("Adding #{description}")
# ...
end
r.post "mark_done" do
description = r.params.dig("new_todo", "description")
REGISTRY[:logger].info("Marking #{description} as DONE")
# ...
end
r.post "mark_todo" do
description = r.params.dig("new_todo", "description")
REGISTRY[:logger].info("Marking #{description} as TODO")
# ...
end
# ...
end
endAnd we can add a route to display the logger messages.
r.get "logger_messages" do
view "logger", locals: {
logger_messages: REGISTRY[:logger].output
}
endAnd now, when we use our app and we check out the logger messages, we can see that they are all there.
Simplify: dry-system
We could simplify all this work by using the dry-system gem, or by creating our project with Hanami, which already uses it.
You can understand how to do it by visiting the companion episode we've written over on HanamiMastery. In that episode we're going to talk about how Hanami uses dry-system to implement the Registry pattern and how to use it.
Don't miss it!
Conclusion
The Registry pattern allows us to have access to all our configured components without having to carry their configuration and setup logic around.
Also, it can help in specs by allowing us to change the implementation of specific components, eliminating the need to mock methods on constants.
And it reduces the number of objects created by our application.
On the other hand, one disadvantage of this pattern is that we end up with a global object which, is not recommended to have.
I hope you liked this episode and I'll see you on the next one.
Oh, and don't forget to visit our friends at HanamiMastery and give them our love.
Bye!