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:
"mongo"
require
class Database
attr_reader :client
def self.load(database_url: ENV.fetch("DATABASE_URL"))
database_url: database_url)
new(end
def reset!
db.dropend
private
attr_reader :db
def initialize(database_url:)
@client = Mongo::Client.new(database_url)
@db = client.database
end
end
The main object, the TodoApp
.
class TodoApp
attr_reader :todo_tasks
attr_reader :done_tasks
def self.load(db:)
db: db)
new(end
# ...
end
And the TaskList
.
class TaskList
Enumerable
include
def self.load(db:, collection_name:)
db: db, collection_name: collection_name)
new(end
# ...
end
Keep 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.rb └──
Also, we need to instantiate it on the main Roda file.
do |r|
route Database.load
db = TodoApp.load(db: db)
todo_app =
done_tasks = todo_app.done_tasks
todo_tasks = todo_app.todo_tasks
# ...
end
And on the specs.
"todo tasks" do
describe Given(:db) { Database.load }
Given(:todo_app) { TodoApp.load(db: db) }
# ...
end
The 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:16
Solution: 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
end
Now 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.load
I'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.load
To 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)
"#{key} is already on the Registry." if @registered_objects.has_key?(key)
raise
@registered_objects[key] = value
end
private
def initialize
@registered_objects = {}
end
end
We'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)
end
Using 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
newend
def initialize
@db = REGISTRY[:db]
@todo_tasks ||= TaskList.load(collection_name: :todo_tasks)
@done_tasks ||= TaskList.load(collection_name: :done_tasks)
end
Now that we made this change, we can retrieve it on the Web application.
class RodaApp < Roda # rubocop:disable Lint/ConstantDefinitionInBlock
:render
plugin :sessions, secret: ENV.fetch("SESSION_SECRET")
plugin
do |r|
route REGISTRY[:db]
db =
# ...
end
end
And 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:51
Next, 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
newend
# ...
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
end
And again, we retrieve it on the web application.
class RodaApp < Roda # rubocop:disable Lint/ConstantDefinitionInBlock
:render
plugin :sessions, secret: ENV.fetch("SESSION_SECRET")
plugin
do |r|
route REGISTRY[:db]
db = REGISTRY[:todo_app]
todo_app = end
end
And on the specs.
Given(:todo_app) { REGISTRY[:todo_app] }
And run the specs.
"bundle exec rspec --format progress /app/spec"
docker-compose run --rm web sh -c 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:23
We 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.new
Now we can, for example, log every time we enter a route.
# frozen_string_literal: true
class RodaApp < Roda # rubocop:disable Lint/ConstantDefinitionInBlock
# ...
do |r|
route # ...
do
r.root REGISTRY[:logger].info("Rendering the Root path")
# ...
end
"new_todo" do
r.post "new_todo", "description")
description = r.params.dig(REGISTRY[:logger].info("Adding #{description}")
# ...
end
"mark_done" do
r.post "new_todo", "description")
description = r.params.dig(REGISTRY[:logger].info("Marking #{description} as DONE")
# ...
end
"mark_todo" do
r.post "new_todo", "description")
description = r.params.dig(REGISTRY[:logger].info("Marking #{description} as TODO")
# ...
end
# ...
end
end
And we can add a route to display the logger messages.
"logger_messages" do
r.get "logger", locals: {
view logger_messages: REGISTRY[:logger].output
}end
And 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!