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!'
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
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!'
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
Now we can utilize AnimalAdapter
throughout the entire application, and this adapter will encapsulate the logic of Cat under the hood
AnimalAdapter.speak!
# => 'Meow!'
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
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
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
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
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
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
Afterward, execute the following command:
rails db:migrate
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
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
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'
Then execute:
bundle install
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" ... }
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
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
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
- 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
- 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
Once initialized, we can call this adapter like this:
# rails c
Rails.application.config.temporary_data_store_adapter
# => #<TemporaryDataStoreAdapter::ActiveRecord:0x0000000114816ec8>
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
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>
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
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
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
- 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
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
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)
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?
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
Actually, it's not only one single method. The idea was to support all adapters that will be initialized in the configuration file:
Isnt "Example 3. Datadog" dependency injection? Something like dry-container
👌