Adapters 101
… and check why 5600+ Rails engineers read also this
Adapters 101
Sometimes people get confused as to what is the roles of adapters, how to use them, how to test them and how to configure them. Misunderstanging often comes from lack of examples so let’s see some of them.
Our example will be about sending apple push notifications (APNS). Let’s say in our system we are sending push notifications with text (alert) only (no sound, no badge, etc). Very simple and basic usecase. One more thing that we obviously need as well is device token. Let’s have a simple interface for sending push notifications.
def notify(device_token, text)
end
That’s the interface that every one of our adapters will have to follow. So let’s
write our first implementation using the apns
gem.
module ApnsAdapters
class Sync
def notify(device_token, text)
APNS.send_notification(device_token, text)
end
end
end
Wow, that was simple, wasn’t it? Ok, what did we achieve?
- We’ve protected ourselves from the dependency on
apns
gem. We are still using it but no part of our code is calling it directly. We are free to change it later (which we will do) - We’ve isolated our interface from the implementation as Clean Code architecture teaches us. Of course in Ruby we don’t have interfaces so it is kind-of virtual but we can make it a bit more explicit, which I will show you how, later.
- We designed API that we like and which is suitable for our app. Gems and 3rd party
services often offer your a lot of features which you might not be even using.
So here we explicitly state that we only use
device_token
andtext
. If it ever comes to dropping the old library or migrating to new solution, you are coverd. It’s simpler process when the cooperation can be easily seen in one place (adapter). Evaluating and estimating such task is faster when you know exactly what features you are using and what not.
Adapters in real life
As you can imagine looking at the images, the situation is always the same. We’ve got to parts with incompatible interfaces and adapter mediating between them.
Adapters and architecture
Part of your app (probably a service) that we call client is relying on some kind of interface for its proper behavior. Of course ruby does not have explicit interfaces so what I mean is a compatibility in a duck-typing way. Implicit interface defined by how we call our methods (what parameters they take and what they return). There is a component, an already existing one (adaptee) that can do the job our client wants but does not expose the interface that we would like to use. The mediator between these two is our adapter.
The interface can be fulfilled by possibily many adapters. They might be wrapping another API or gem which we don’t want our app to interact directly with.
Multiple Adapters
Let’s move further with our task.
We don’t wanna be sending any push notifications from our development environment and
from our test environment. What are our options? I don’t like putting code such as
if Rails.env.test? || Rails.env.production?
into my codebase. It makes testing
as well as playing with the application in development mode harder. For such usecases new
adapter is handy.
module ApnsAdapters
class Fake
attr_reader :delivered
def initialize
clear
end
def notify(device_token, text)
@delivered << [device_token, text]
end
def clear
@delivered = []
end
end
end
Now whenever your service objects are taking apns_adapter
as dependency you can use this one
instead of the real one.
describe LikingService do
subject(:liking) { described_class.new(apns_adapter) }
let(:apns_adapter) { ApnsAdapters::Fake.new }
before { apns_adapter.clear }
specify "delivers push notifications to friends" do
liking.painting_liked_by(user_id, painting_id)
expect(apns_adapter.delivered).to include(
[user_device_token, "Your friend 'Robert' liked 'The Kiss' "]
)
end
end
I like this more then using doubles and expectations because of its simplicity. But using mocking techniques here would be apropriate as well. In that case however I would recommend using Verifying doubles from Rspec or to go with bogus. I recommend watching great video about possible problems that mocks and doubles introduce from the author of bogus and solutions for them. Integration tests are bogus.
Injecting and configuring adapters
Ok, so we have two adapters, how do we provide them to those who need these adapters to work? Well, I’m gonna show you an example and not talk much about it because it’s going to be a topic of another blogpos.
module LikingServiceInjector
def liking_service
@liking_service ||= LikingService.new(Rails.config.apns_adapter)
end
end
class YourController
include LikingServiceInjector
end
#config/environments/development.rb
config.apns_adapter = ApnsAdapter::Fake.new
#config/environments/test.rb
config.apns_adapter = ApnsAdapter::Fake.new
One more implementation
Sending push notification takes some time (just like sending email or communicating with any remote service) so quickly we decided to do it asynchronously.
module ApnsAdapters
class Async
def notify(device_token, text)
Resque.enqueue(ApnsJob, device_token, text)
end
end
end
And the ApnsJob
is going to use our sync adapter.
class ApnsJob
def self.perform(device_token, text)
new(device_token, text).call
rescue => exc
HoneyBadger.notify(exc)
raise
end
def initialize(device_token, text)
@device_token = device_token
@text = text
end
def call
ApnsAdapter::Sync.new.notify(@device_token, @text)
end
end
Did you notice that HoneyBadger is not hidden behind adapter? Bad code, bad code… ;)
What do we have now?
The result
We separated our interface from the implementations. Of course our interface is not defined (again, Ruby) but we can describe it later using tests. App with the interface it dependend is one component. Every implementation can be a separate component.
Our goal here was to get closer to Clean Architecture . Use Cases (Interactors, Service Objects) are no longer bothered with implementation details. Instead they relay on the interface and accept any implementation that is consistent with it.
The part of application which responsibility is to put everything in motion is called Main by Uncle Bob. We put all the puzzles together by using Injectors and Rails configuration. They define how to construct the working objects.
Changing underlying gem
In reality I no longer use apns
gem because of its global configuration. I
prefer grocer
because I can more easily and safely use it to send push
notifications to 2 separate mobile apps or even same iOS app but built with
either production or development APNS certificate.
So let’s say that our project evolved and now we need to be able to send push notifications to 2 separate mobile apps. First we can refactor the interface of our adapter to:
def notify(device_token, text, app_name)
end
Then we can change the implementation of our Sync
adapter to use grocer
gem
instead (we need some tweeks to the other implementations as well).
In simplest version it can be:
module ApnsAdapters
class Sync
def notify(device_token, text, app_name)
notification = Grocer::Notification.new(
device_token: device_token,
alert: text,
)
grocer(app_name).push(notification)
end
private
def grocer(app_name)
@grocer ||= {}
@grocer[app_name] ||= begin
config = APNS_CONFIG[app_name]
Grocer.pusher(
certificate: config.fetch('pem']),
passphrase: config.fetch('password']),
gateway: config.fetch('gateway_host'),
port: config.fetch('gateway_port'),
retries: 2
)
end
end
end
end
However every new grocer instance is using new conncetion to Apple push notifications service. But, the recommended way is to reuse the connection. This can be especially usefull if you are using sidekiq. In such case every thread can have its own connection to apple for every app that you need to support. This makes sending the notifications very fast.
require 'singleton'
class GrocerFactory
include Singleton
def pusher_for(app)
Thread.current[:pushers] ||= {}
pusher = Thread.current[:pushers][app] ||= create_pusher(app)
yield pusher
rescue
Thread.current[:pushers][app] = nil
raise
end
private
def create_pusher(app_name)
config = APNS_CONFIG[app_name]
pusher = Grocer.pusher(
certificate: config.fetch('pem']),
passphrase: config.fetch('password']),
gateway: config.fetch('gateway_host'),
port: config.fetch('gateway_port'),
retries: 2
)
end
end
In this implementation we kill the grocer instance when exception happens (might happen because of problems with delivery, connection that was unused for a long time, etc). We also reraise the exception so that higher layer (probably sidekiq or resque) know that the task failed (and can schedule it again).
And our adapter:
module ApnsAdapters
class Sync
def notify(device_token, text, app_name)
notification = Grocer::Notification.new(
device_token: device_token,
alert: text,
)
GrocerFactory.instance.pusher_for(app_name) do |pusher|
pusher.push(notification)
end
end
end
end
The process of sharing instances of grocer
between threads could be
probably simplified with some kind of threadpool library.
Adapters configuration
I already showed you one way of configuring the adapter by using Rails.config
.
YourApp::Application.configure do
config.apns_adapter = ApnsAdapters::Async.new
end
The downside of that is that the instance of adapter is global. Which means you might need to take care of it being thread-safe (if you use threads). And you must take great care of its state. So calling it multiple times between requests is ok. The alternative is to use proc as factory for creating instances of your adapter.
YourApp::Application.configure do
config.apns_adapter = proc { ApnsAdapters::Async.new }
end
If your adapter itself needs some dependencies consider using factories or injectors for fully building it. From my experience adapters usually can be constructed quite simply. And they are building blocks for other, more complicated structures like service objects.
Testing adapters
I like to verify the interface of my adapters using shared examples in rspec.
shared_examples_for :apns_adapter do
specify "#notify" do
expect(adapter.method(:notify).arity).to eq(2)
end
# another way without even constructing instance
specify "#notify" do
expect(described_class.instance_method(:notify).arity).to eq(2)
end
end
Of course this will only give you very basic protection.
describe ApnsAdapter::Sync do
it_behaves_like :apns_adapter
end
describe ApnsAdapter::Async do
it_behaves_like :apns_adapter
end
describe ApnsAdapter::Fake do
it_behaves_like :apns_adapter
end
Another way of testing is to consider one implementation as leading and correct (in terms of interface, not in terms of behavior) and another implementation as something that must stay identical.
describe ApnsAdapters::Async do
subject(:async_adapter) { described_class.new }
specify "can easily substitute" do
example = ApnsAdapters::Sync
example.public_instance_methods.each do |method_name|
method = example.instance_method(method_name)
copy = subject.public_method(method_name)
expect(copy).to be_present
expect([-1, method.arity]).to include(copy.arity)
end
end
end
This gives you some very basic protection as well.
For the rest of the test you must write something specific to the adapter implementation. Adapters doing http request can either stub http communication with webmock or vcr. Alternatively, you can just use mocks and expectations to check, whether the gem that you use for communication is being use correctly. However, if the logic is not complicated the test are quickly becoming typo test, so they might even not be worth writing.
Test specific for one adapter:
describe ApnsAdapter::Async do
it_behaves_like :apns_adapter
specify "schedules" do
described_class.new.notify("device", "about something")
ApnsJob.should have_queued("device", "about something")
end
specify "job forwards to sync" do
expect(ApnsAdapters::Sync).to receive(:new).and_return(apns = double(:apns))
expect(apns).to receive(:notify).with("device", "about something")
ApnsJob.perform("device", "about something")
end
end
In many cases I don’t think you should test Fake
adapter because this is what we use for
testing. And testing the code intended for testing might be too much.
Dealing with exceptions
Because we don’t want our app to be bothered with adapter implementation (our clients don’t care about anything except for the interface) our adapters need to throw the same exceptions. Because what exceptions are raised is part of the interface. This example does not suite us well to discuss it here because we use our adapters in fire and forget mode. So we will have to switch for a moment to something else.
Imagine that we are using some kind of geolocation service which based on user provided address (not a specific format, just String from one text input) can tell us the longitude and latitude coordinates of the location. We are in the middle of switching to another provided which seems to provide better data for the places that our customers talk about. Or is simply cheaper. So we have two adapters. Both of them communicate via HTTP with APIs exposed by our providers. But both of them use separate gems for that. As you can easily imagine when anything goes wrong, gems are throwing their own custom exceptions. We need to catch them and throw exceptions which our clients/services except to catch.
require 'hypothetical_gooogle_geolocation_gem'
require 'new_cheaper_more_accurate_provider_gem'
module GeolocationAdapters
ProblemOccured = Class.new(StandardError)
class Google
def geocode(address_line)
HypotheticalGoogleGeolocationGem.new.find_by_address(address_line)
rescue HypotheticalGoogleGeolocationGem::QuotaExceeded
raise ProblemOccured
end
end
class NewCheaperMoreAccurateProvider
def geocode(address_line)
NewCheaperMoreAccurateProviderGem.geocoding(address_line)
rescue NewCheaperMoreAccurateProviderGem::ServiceUnavailable
raise ProblemOccured
end
end
end
This is something people often overlook which in many cases leads to leaky abstraction. Your services should only be concerned with exceptions defined by the interface.
class UpdatePartyLocationService
def call(party_id, address)
party = party_db.find_by_id(party_id)
party.coordinates = geolocation_adapter.geocode(address)
db.save(party)
rescue GeolocationAdapters::ProblemOccured
scheduler.schedule(UpdatePartyLocationService, :call, party_id, address, 5.minutes.from_now)
end
end
Although some developers experiment with exposing exceptions that should be caught as part of the interface (via methods), I don’t like this approach:
require 'hypothetical_gooogle_geolocation_gem'
require 'new_cheaper_more_accurate_provider_gem'
module GeolocationAdapters
ProblemOccured = Class.new(StandardError)
class Google
def geocode(address_line)
HypotheticalGoogleGeolocationGem.new.find_by_address(address_line)
end
def problem_occured
HypotheticalGoogleGeolocationGem::QuotaExceeded
end
end
class NewCheaperMoreAccurateProvider
def geocode(address_line)
NewCheaperMoreAccurateProviderGem.geocoding(address_line)
end
def problem_occured
NewCheaperMoreAccurateProviderGem::ServiceUnavailable
end
end
end
And the service
class UpdatePartyLocationService
def call(party_id, address)
party = party_db.find_by_id(party_id)
party.coordinates = geolocation_adapter.geocode(address)
db.save(party)
rescue geolocation_adapter.problem_occured
scheduler.schedule(UpdatePartyLocationService, :call, party_id, address, 5.minutes.from_now)
end
end
But as I said I don’t like this approach. The problem is that if you want to communicate something domain specific via the exception you can’t relay on 3rd party exceptions. If it was adapter responsibility to provide in exception information whether service should retry later or give up, then you need custom exception to communicate it.
Adapters ain’t easy
There are few problems with adapters. Their interface tends to be
lowest common denominator between features supported by implementations.
That was the reason which sparkled big discussion about queue interface for
Rails which at that time was removed from it. If one technology limits you so
you schedule background job only with JSON compatibile attributes you are
limited to just that. If another technology let’s you use Hashes with every
Ruby primitive and yet another would even allow you to pass whatever ruby object
you wish then the interface is still whatever JSON allows you to do. No only
you won’t be able to easily pass instance of your custom class as paramter for
scheduled job. You won’t even be able to use Date
class because there is no
such type in JSON. Lowest Common Denominator…
You won’t easily extract Async adapter if you care about the result. I think that’s obvious. You can’t easily substitute adapter which can return result with such that cannot. Async is architectural decision here. And rest of the code must be written in a way that reflects it. Thus expecting to get the result somehow later.
Getting the right level of abstraction for adapter might not be easy. When you cover
api or a gem, it’s not that hard. But once you start doing things like
NotificationAdapter
which will let you send notification to user without bothering
the client whether it is a push for iOS, Android, Email or SMS, you might find yourself in
trouble. The closer the adapter is to the domain of adaptee, the easier it is to
write it. The closer it is to the domain of the client, of your app, the harder it
is, the more it will know about your usecases. And the more complicated and
unique for the app, such adapter will be. You will often stop for a moment to reflect
whether given functionality is the responsibility of the client, adapter or maybe
yet another object.
Summary
Adapters are puzzles that we put between our domain and existing solutions such as gems, libraries, APIs. Use them wisely to decouple core of your app from 3rd party code for whatever reason you have. Speed, Readability, Testability, Isolation, Interchangeability.