#06The Registry Pattern

Subscribe to our episode notifications

* indicates required

Intuit Mailchimp

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
end

The main object, the TodoApp.

class TodoApp
  attr_reader :todo_tasks
  attr_reader :done_tasks

  def self.load(db:)
    new(db: db)
  end

  # ...
end

And the TaskList.

class TaskList
  include Enumerable

  def self.load(db:, collection_name:)
    new(db: db, collection_name: collection_name)
  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.

route do |r|
  db = Database.load
  todo_app = TodoApp.load(db: db)
  done_tasks = todo_app.done_tasks
  todo_tasks = todo_app.todo_tasks

  # ...
end

And on the specs.

describe "todo tasks" do
  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)
    raise "#{key} is already on the Registry." if @registered_objects.has_key?(key)

    @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
  new
end

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
  plugin :render
  plugin :sessions, secret: ENV.fetch("SESSION_SECRET")

  route do |r|
    db = REGISTRY[: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
    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
end

And 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
end

And 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: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
  # ...

  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
end

And we can add a route to display the logger messages.

r.get "logger_messages" do
  view "logger", locals: {
         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!

Subscribe to our episode notifications

* indicates required

Intuit Mailchimp

Comments