DEV Community

Cover image for How to implement Adapter pattern in Ruby on Rails?
Vlad Hilko
Vlad Hilko

Posted on

How to implement Adapter pattern in Ruby on Rails?

Overview

In this article, we are going to discuss the adapter pattern. We will explore what the adapter pattern is, why we need it, and how it can be implemented in a Rails application. We will also examine the benefits and drawbacks it provides. Additionally, we will provide three different examples to help better understand the concept and the reasons behind it.

Definition

In simple terms, the Adapter pattern allows you to transform the interface of a class into a different interface that clients expect. By doing so, it enables classes with incompatible interfaces to collaborate and work together seamlessly. We usually use the Adapter pattern for the following purposes:

  • Integrating incompatible components

When you need to integrate two components that have incompatible interfaces, Adapter can bridge the gap by providing a common interface that allows them to work together seamlessly.

  • Implementing backward compatibility

If you need to make changes to an existing class or component but want to maintain compatibility with code that relies on the old interface, Adapter can be used to translate between the old and new interfaces.

  • Working with external libraries, services, or legacy systems

When integrating with external libraries or services that have their own specific interfaces, Adapter can be used to adapt their interface to fit the interface expected by your application. Adapters are also particularly useful when working with legacy systems or third-party components that have outdated or incompatible interfaces.

The Adapter pattern has the following pros and cons:

Advantages:

  • Enhanced flexibility

The Adapter pattern makes it easier to switch between implementations or integrate new components without modifying existing code.

  • Enhanced testability

The Adapter pattern enhances testability by isolating components, facilitating the use of mocking and dependency injection, and providing a clear interface for the adapted components.

  • Encapsulation of complexity

The Adapter pattern encapsulates complexity by simplifying complex logic, hiding implementation details, and separating concerns.

  • Independence from external solutions

The Adapter pattern enables you to switch or replace the external solution without affecting the rest of your codebase. This flexibility allows you to choose the most suitable external solution for your needs without the need for extensive modifications throughout your codebase.

Problems:

  • Potential increase in code complexity:

Introducing adapters can add complexity to the codebase, especially when dealing with a large number of adapters or complex adaptation logic.

  • Higher learning curve for developers:

Developers who are not familiar with the Adapter pattern may require additional time and effort to understand the purpose, design, and usage of adapters in the codebase. This learning curve can slow down development and onboarding processes for new team members.

Implementation

Let's start by implementing our first adapter. We'll begin with a simple abstract example to help us grasp the concept. Afterward, we'll provide two additional examples and demonstrate how to integrate them into a Rails application.

Example 1: Cat and Dog

In this example, we will discuss how two different objects with different interfaces can collaborate. Let's imagine that we have the following two classes:

class Cat
  def self.meow!
    p 'Meow!'
  end
end

class Dog 
  def self.woof!
    p 'Woof!'
  end
end

Cat.meow!
# => 'Meow!'
Dog.woof!
# => 'Woof!'
Enter fullscreen mode Exit fullscreen mode

For example, let's say we would like to use one of them based on a certain condition, like the one shown below:


if Rails.env.test? 
  Cat.meow!
else
  Dog.woof!
end
Enter fullscreen mode Exit fullscreen mode

Having many such conditions scattered throughout the code makes it difficult to maintain and less manageable. Therefore, we need to find a solution to address this issue. The main goal of the Adapter Pattern is to solve this problem by creating a common interface without altering the initial implementation. Let's explore how we can achieve it.

Firstly, we need to create a new Adapter class for each class that we intend to adopt with a new interface.

class CatAdapter
  def self.speak!
    Cat.meow!
  end
end

class DogAdapter
  def self.speak!
    Dog.woof!
  end
end

CatAdapter.speak!
# => 'Meow!'
DogAdapter.speak!
# => 'Woof!'
Enter fullscreen mode Exit fullscreen mode

We have created a common interface for the two objects, which allows us to choose which one to use in a single location, without the need for additional changes throughout the application. For instance, we can implement the following code at the Rails configuration level:

AnimalAdapter = Rails.env.test? ? CatAdapter : DogAdapter
Enter fullscreen mode Exit fullscreen mode

Now we can utilize AnimalAdapter throughout the entire application, and this adapter will encapsulate the logic of Cat under the hood

AnimalAdapter.speak!
# => 'Meow!'
Enter fullscreen mode Exit fullscreen mode

Example 2. Temporary Data Storage

In this example, we are going to implement a temporary data storage that should perform the following functions:

  • Add the given data by key
  • Retrieve the data by key
  • Remove the data by key

Such temporary data storage can be useful in various scenarios, including:

  • Tracking the status of asynchronous jobs

For example, if we have a button that sends multiple emails, we can use temporary data storage to block the button and prevent duplicate sends until the job is finished and all emails are sent.

  • Storing intermediate results during data processing

In data-intensive applications, temporary storage is often used to store intermediate results during data processing pipelines. This enables efficient data transformation, aggregation, or analysis before generating the final output.

  • Temporary storage for any other purpose

Temporary data storage can be employed for various other use cases where we need to temporarily store data.

To demonstrate this adapter pattern, we will create three different implementations for each Rails environment:

  • For the Test environment, we will create a Memory-Based Temporary Data Storage.
  • For the Development environment, we will create an ActiveRecord-Based Temporary Data Storage.
  • For the Production environment, we will create a Redis-Based Temporary Data Storage.

Let's proceed with implementing each of them to gain a better understanding of the main purpose behind the adapter pattern.

Memory-Based Temporary Data Storage

The first solution will be the simplest one - we will create a Memory-Based Temporary Data Storage using a Ruby Hash. We will only use this solution for testing purposes, so we don't need to be concerned about data storage reliability.

First, let's add a new adapter file and define the common interface:

# app/adapters/temporary_data_store_adapter/memory.rb

# frozen_string_literal: true

module TemporaryDataStoreAdapter
  class Memory

    def set(key, value)
    end

    def get(key)
    end

    def delete(key)
    end

  end
end

Enter fullscreen mode Exit fullscreen mode

For our data storage, we have defined three required methods:

  • set
  • get
  • delete

Now, let's implement these methods:

# app/adapters/temporary_data_store_adapter/memory.rb

# frozen_string_literal: true

module TemporaryDataStoreAdapter
  class Memory

    def initialize
      @store = {}
    end

    def set(key, value)
      @store[key.to_s] = value.to_json
      'OK'
    end

    def get(key)
      return nil unless (value = @store[key.to_s])

      JSON.parse(value)
    end

    def delete(key)
      return nil unless (value = @store[key.to_s])

      @store.delete key.to_s
      JSON.parse(value)
    end

  end
end
Enter fullscreen mode Exit fullscreen mode

Let's check how it actually works in the Rails console:

# rails c

adapter = TemporaryDataStoreAdapter::Memory.new
# =>  #<TemporaryDataStoreAdapter::Memory:0x00000001166b1b80 @store={}>

adapter.set('key', {example: 'example'})
# => "OK"
adapter.get('key')
# => {"example"=>"example"}
adapter.delete('key')
# => {"example"=>"example"}
adapter.get('key')
# => nil
Enter fullscreen mode Exit fullscreen mode

That's it!

P.S. It also makes sense to add a clear method to remove all data from the Hash. You can run this command after every test that uses this adapter to avoid any global value problems in the specs:

def clear
  @store.clear
end
Enter fullscreen mode Exit fullscreen mode

ActiveRecord-Based Temporary Data Storage

Our second implementation of Temporary Data Storage utilizes ActiveRecord. I have mainly added this implementation to enhance our understanding of the Adapter concept, rather than for real-life usage.

Firstly, to use ActiveRecord, we need to create a new model and generate a migration to set up the data storage. Let's create the following model:

# app/models/temporary_data_entry.rb

# frozen_string_literal: true

class TemporaryDataEntry < ApplicationRecord
end
Enter fullscreen mode Exit fullscreen mode

And the corresponding migration:

# db/migrate/20230714142730_create_temporary_data_entries.rb

class CreateTemporaryDataEntries < ActiveRecord::Migration[7.0]
  def change
    create_table :temporary_data_entries do |t|
      t.string :key, null: false
      t.json :data

      t.timestamps
      t.index :key, unique: true
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Afterward, execute the following command:

rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Now, everything is set up, and we can proceed to create a new adapter. Let's take a look at how this adapter will be implemented:

# app/adapters/temporary_data_store_adapter/active_record.rb

# frozen_string_literal: true

module TemporaryDataStoreAdapter
  class ActiveRecord

    def set(key, data)
      temp_data_entry = TemporaryDataEntry.find_by(key: key)

      if temp_data_entry.present?
        temp_data_entry.update(data: data)
      else
        TemporaryDataEntry.create(key: key, data: data)
      end
      'OK'
    end

    def get(key)
      TemporaryDataEntry.find_by(key: key)&.data
    end

    def delete(key)
      TemporaryDataEntry.find_by(key: key)&.delete&.data
    end

  end
end
Enter fullscreen mode Exit fullscreen mode

Let's test these methods in the console:

# rails c


adapter = TemporaryDataStoreAdapter::ActiveRecord.new
# => #<TemporaryDataStoreAdapter::ActiveRecord:0x000000010ec64800>
adapter.set('key', {example: 'example'})
# => "OK"
adapter.get('key')
# => {"example"=>"example"}
adapter.delete('key')
# => {"example"=>"example"}
adapter.get('key')
# => nil
Enter fullscreen mode Exit fullscreen mode

That's it.

Redis-Based Temporary Data Storage

Our final implementation will be based on Redis. First of all, let's install the Redis gem and ensure that we are connected to the server. Add the following gem to your Gemfile:

# Gemfile 

gem 'redis'
Enter fullscreen mode Exit fullscreen mode

Then execute:

bundle install
Enter fullscreen mode Exit fullscreen mode

Next, start the Redis server and verify if the connection is successful:

# REDIS_URL='redis://127.0.0.1:6379'
# rails c 

Redis.new(url: ENV.fetch('REDIS_URL')).info
# => {"redis_version"=>"7.0.5" ... }
Enter fullscreen mode Exit fullscreen mode

As we can see, the Redis connection is successful. Now, let's proceed with adding a new Redis-based temporary data storage adapter:

# app/adapters/temporary_data_store_adapter/redis.rb

# frozen_string_literal: true

module TemporaryDataStoreAdapter
  class Redis

    def set(key, value)
      redis.set key, value.to_json
    end

    def get(key)
      return nil unless (value = redis.get(key))

      JSON.parse(value)
    end

    def delete(key)
      return nil unless (value = redis.getdel(key))

      JSON.parse(value)
    end

    private

    def redis
      @redis ||= ::Redis.new(url: ENV.fetch('REDIS_URL'))
    end

  end
end
Enter fullscreen mode Exit fullscreen mode

Let's check if it works:


adapter = TemporaryDataStoreAdapter::Redis.new
# =>  #<TemporaryDataStoreAdapter::Redis:0x0000000112017c60>

adapter.set('key', {example: 'example'})
# => "OK"
adapter.get('key')
# => {"example"=>"example"}
adapter.delete('key')
# => {"example"=>"example"}
adapter.get('key')
# => nil
Enter fullscreen mode Exit fullscreen mode

That's it. All adapters have been successfully added. Now, let's take a look at how we can choose the one that is suitable for our needs.

Initialize Adapter

We're going to use different adapters depending on the Rails environment:

  • For the TEST environment, we'll use the Memory-Based Temporary Data Storage Adapter.
  • For the DEVELOPMENT environment, we'll use the ActiveRecord-Based Temporary Data Storage Adapter.
  • For the PRODUCTION environment, we'll use the Redis-Based Temporary Data Storage Adapter.

To solve this problem, we'll initialize each adapter in their respective environments in the config/environments directory:

  • Memory-Based Temporary Data Storage Adapter
# config/environments/test.rb

# frozen_string_literal: true

Rails.application.configure do
  # ...
  config.after_initialize do
    config.temporary_data_store_adapter = TemporaryDataStoreAdapter::Memory.new
  end
end
Enter fullscreen mode Exit fullscreen mode
  • ActiveRecord-Based Temporary Data Storage Adapter
# config/environments/development.rb

# frozen_string_literal: true

Rails.application.configure do
  # ...
  config.after_initialize do
    config.temporary_data_store_adapter = TemporaryDataStoreAdapter::ActiveRecord.new
  end
end
Enter fullscreen mode Exit fullscreen mode
  • Redis-Based Temporary Data Storage Adapter
# config/environments/production.rb

# frozen_string_literal: true

Rails.application.configure do
  # ...
  config.after_initialize do
    config.temporary_data_store_adapter = TemporaryDataStoreAdapter::Redis.new
  end
end
Enter fullscreen mode Exit fullscreen mode

Once initialized, we can call this adapter like this:

# rails c

Rails.application.config.temporary_data_store_adapter
# => #<TemporaryDataStoreAdapter::ActiveRecord:0x0000000114816ec8>
Enter fullscreen mode Exit fullscreen mode

However, this doesn't look too convenient, so let's add a wrapper:

# app/adapters/adapter.rb

# frozen_string_literal: true

module Adapter
  class << self

    def method_missing(method, *args, &block)
      Rails.application.config.public_send("#{method}_adapter")
    rescue NameError
      super
    end

  end
end
Enter fullscreen mode Exit fullscreen mode

Now, we can call the adapter like this:

# rails c -e development

Adapter.temporary_data_store
# => #<TemporaryDataStoreAdapter::ActiveRecord:0x0000000107634c98>

# rails c -e test

Adapter.temporary_data_store
# => #<TemporaryDataStoreAdapter::Memory:0x00000001101743a8 @store={}>

# rails c -e production

Adapter.temporary_data_store
# => #<TemporaryDataStoreAdapter::Redis:0x0000000112ef9698>
Enter fullscreen mode Exit fullscreen mode

That's it. We now have an identical interface, and our previous incompatible solution becomes interchangeable.

Example 3. Datadog.

In the last example, we will add an Abstract Monitoring Adapter to consolidate what we've already learned. Let's imagine that we have DataDog monitoring, and we want to disable it for the development and test environments. How would we do it? Let's create the following:

# app/adapters/monitoring_adapter/datadog.rb

# frozen_string_literal: true

module MonitoringAdapter
  class Datadog

    def call
      puts 'Send a real API request to DataDog'
    end

  end
end
Enter fullscreen mode Exit fullscreen mode

And the Fake one:

# frozen_string_literal: true

module MonitoringAdapter
  class Fake

    def call
      puts 'Pretend that the request to DataDog has been sent'
    end

  end
end
Enter fullscreen mode Exit fullscreen mode

Let's initialize them:

  • Fake
# config/environments/test.rb

# frozen_string_literal: true

Rails.application.configure do
  # ...
  config.after_initialize do
    config.monitoring_adapter = MonitoringAdapter::Fake.new
  end
end
Enter fullscreen mode Exit fullscreen mode
  • Datadog
# config/environments/production.rb

# frozen_string_literal: true

Rails.application.configure do
  # ...
  config.after_initialize do
    config.monitoring_adapter = MonitoringAdapter::Datadog.new
  end
end
Enter fullscreen mode Exit fullscreen mode

And let's check:

# rails c -e production

Adapter.monitoring.call
# => Send a real API request to DataDog

# rails c -e test

Adapter.monitoring.call
# => Pretend that the request to DataDog has been sent
Enter fullscreen mode Exit fullscreen mode

That's it.

Conclusion

In summary, the adapter pattern proves to be a valuable tool in Rails applications, enabling the integration of components with different interfaces without modifying existing code. By creating adapter classes for each component, we can achieve a common interface and easily switch between implementations. This approach enhances code maintainability, reduces complexity, and improves testability by isolating components and providing clear contracts. With adapters, developers can seamlessly integrate external libraries or services, adapt to legacy systems, and handle compatibility issues efficiently.

Top comments (4)

Collapse
 
sammygadd profile image
Sammy Henningsson

Interesting read!

Though, I think the adapter pattern is typically used when you already have some object that doesn't fit into the something. Then you wrap that object in an adapter to get the right interface. Since you built each of the storage implementations you have the luxury to design the interface. By creating each storage implementation with this interface you don't need an adapter.
Basically, IMHO, I think that for this to truly show the adapter pattern the adapters should be initialized with instances (of things that don't have the right interface), e.g a hash or an instance of Redis.

Why would you use method_missing to only support one single method?

Collapse
 
vladhilko profile image
Vlad Hilko

Hey Sammy, thanks for your feedback. I appreciate it. I used to think about initializing this adapter with arguments, but for the problem that I tried to solve here, it would look like a default argument that never changed. So, I decided to keep it simpler and use them as hardcoded dependencies. Maybe it's not classic, but it still looks like a quite valid approach. If I'm not mistaken, Flipper does something similar under the hood for his adapters: flippercloud.io/docs/adapters

Why would you use method_missing to only support one single method?

Actually, it's not only one single method. The idea was to support all adapters that will be initialized in the configuration file:

config.temporary_data_store_adapter = ...
config.monitoring_adapter = ...
# etc..
Enter fullscreen mode Exit fullscreen mode
Collapse
 
peredery profile image
Artyom Peredery

Isnt "Example 3. Datadog" dependency injection? Something like dry-container

Collapse
 
davonrails profile image
David Fab

👌