The Adapter pattern allows you to make incompatible objects work well together by using an adapter object.
Here's a video version of this article if you prefer to watch instead:
And you can check out the code on Github if you want to try it out.
This first example is an application that needs to talk to a payment platform like PayPal.
# lib/01_no_adapter.rb
require_relative "./paypal_lib"
require_relative "./user"
# Client code is coupled with the 3rd party library.
class ClientApp
def self.call(user:)
PaypalLib.subscription(email: user.email)
PaypalLib.customer(
fname: user.first_name,
lname: user.last_name
)
end
end
ClientApp.call(user: User.new)
So our class is called ClientApp
, and the 3rd party library for talking with PayPal is called PaypalLib
to indicate it's something that we don't have control over. It's a library.
In this example it's not a real library, but it's a placeholder for one. It will just print some messages to the screen when we call its methods.
# lib/paypal_lib.rb
class PaypalLib
class << self
def subscription(email:)
puts "Creating subscription for #{email}..."
end
def customer(fname:, lname:)
puts "Creating customer for #{fname} #{lname}..."
end
end
end
We also have a User
object which we'll use to extract data from. Like email, first-name, last-name, and so forth.
# lib/user.rb
class User
attr_reader :id, :first_name, :last_name, :full_name, :email
def initialize
@id = 1
@first_name = "John"
@last_name = "Doe"
@full_name = "John Doe"
@email = "jdoe@email.com"
end
end
And as you can see, the client code is using the library directly through it's interface, which makes it tightly coupled to that 3rd party code.
What this coupling does is, when the time comes to extend our application to support another payment platform, or even upgrade the 3rd party library to a new version, we need to change the client code.
And not just in this class, but all the other classes we might have and that are coupled with the 3rd party library.
But let's assume we want to extend the application to support a new platform, namely Braintree.
So just like the PaypalLib
class, we have another one called BraintreeLib
that is also a fake library, and it too prints something to the screen when we call its methods.
# lib/braintree_lib.rb
class BraintreeLib
class << self
def user(full_name:)
puts "Creating user #{full_name}..."
end
def subscribe(external_id:)
puts "Creating subscription for ID: #{external_id}..."
end
end
end
But its methods and arguments (in other words, its API) is different than the PayPal one. And that is on purpose, because that's the problem we are trying to solve.
We want to align these different interfaces so that we can make future extensions easy.
But in order to extend our current implementation to support this new library, we'll have to go in, and add a condition so that we can identify the platform we want to use.
Note the "code changing" part. That's a red flag.
# lib/02_no_adapter.rb
require_relative "./paypal_lib"
require_relative "./braintree_lib"
require_relative "./user"
class ClientApp
def self.call(platform:, user:)
if platform == :paypal
PaypalLib.subscription(email: user.email)
PaypalLib.customer(
fname: user.first_name,
lname: user.last_name
)
elsif platform == :braintree
BraintreeLib.user(full_name: user.full_name)
BraintreeLib.subscribe(external_id: user.id)
else
raise "Wrong platform!"
end
end
end
ClientApp.call(platform: :braintree, user: User.new)
# ClientApp.call(platform: :paypal, user: User.new)
So we introduce the platform
argument, and an if/else
expression that gets to call the correct 3rd party library code.
The reason this approach is a red flag is it goes against the Open/Closed principle which says our classes need to be open for extension and closed for modification.
In other words, we should be able to extend our application to support future extensions (namely, multiple payment platforms) without having to change our ClientApp
class.
And the way to do that is to create an adapter class which decouples our client code from the 3rd party library code.
# lib/paypal_adapter.rb
require_relative "./paypal_lib"
class PaypalAdapter
class << self
def subscribe(user)
PaypalLib.subscription(email: user.email)
end
def register(user)
PaypalLib.customer(
fname: user.first_name,
lname: user.last_name
)
end
end
end
# lib/braintree_adapter.rb
require_relative "./braintree_lib"
class BraintreeAdapter
class << self
def subscribe(user)
BraintreeLib.subscribe(external_id: user.id)
end
def register(user)
BraintreeLib.user(full_name: user.full_name)
end
end
end
# lib/03_adapter.rb
require_relative "./paypal_adapter"
require_relative "./braintree_adapter"
require_relative "./user"
# Client code is decoupled fron any 3rd party library.
class ClientApp
def self.call(platform:, user:)
platform.subscribe(user)
platform.register(user)
end
end
ClientApp.call(platform: BraintreeAdapter, user: User.new)
# ClientApp.call(platform: PaypalAdapter, user: User.new)
By introducing an adapter, we can have a unified API for all current and future payment platforms we may want to add.
No matter how incompatible the new 3rd party library code will be, we can deal with it in its adapter class.
The client code (namely the ClientApp
class) will have no knowledge about the details of each adapter, it will use them all the same way.
And because of that, it will conform to the open/closed principle. We'll be able to extend it without changing its implementation.
All we need to do is provide it with a different adapter class as it's first argument.