Software development is a costly process. Requirements need to be gathered, decisions need to be made, and resources need to be scheduled to write the software. All of these steps require an investment of time and money to get a feature to the point where it starts bringing value to a business. After the feature is complete it often incurs some form of ongoing maintenance cost in term of both money and code complexity.
Often it makes sense for a business to use a commodity software solution for complex problems that are outside of the core competency of the business. Email delivery or payment processing are popular services many businesses acquire from a vendor because they require complicated relationships or strict regulatory compliance that most companies do not want to maintain in-house.
Although significantly cheaper than developing an in-house solution, adding a vendor library to a software project is not without cost. Vendor libraries often serve many clients, their interface may be constrained by features you do not need and they may expose data types that are inconsistent with the rest of your application’s domain model. Vendor APIs often talk with vendor servers and can be a source of non-determinism when testing your application. Integrating directly to a vendor API can make it painful to upgrade or replace the library as the needs of the business change or evolve. Luckily, the adapter pattern exists to help manage these drawbacks when integrating with vendor APIs.
What is the adapter pattern?
Put simply, the adapter pattern is used to implement a light wrapper around third-party APIs, one that is contextually relevant for your codebase, and can withstand upstream changes or wholesale replacements of the vendor API without impacting the rest of your application. This manages the risk of integration by providing the rest of your codebase with consistent interface that you control. Additionally, the adapter provides an ideal test seam for stubbing out the service during testing.
A Concrete Example
Let us imagine we are working on an ecommerce site and we’ve taken on the task of integrating with a vendor who will provide us with product recommendations. Behind the scenes, we’ve provided the recommendation vendor with an export of our product catalog and a feed of purchase data so the vendor can make recommendations. When we look at the vendor API, it talks about “item ids” and “recommendations”, however, in the domain model of our site, our application cares about “products”. So our first task will be ensuring our adapter knows how to translate “products” into “recommendations” and back.
import Product from 'my-app/domain/model/product'
class RecommendationAdapter {
constructor(vendor) {
// `vendor` is the vendor API object. We inject this into the
// constructor when initializing the RecomendationAdapter and keep a
// reference to it so all methods have access to it.
this.vendor = vendor;
}
forProduct(product) {
// The vendor API uses NodeJS style callbacks. First we transform
// this into a promise to match the conventions in the rest of the
// codebase
return new Promise((resolve, reject) => {
this.vendor.getRecomendation(product.id, function(error, response) {
if (error) {
reject(error);
} else {
resolve(response);
}
});
}).then(response => {
// now that we have the vendor response lets convert this into
// our standard product object
return response.data.recommendations.map(rec => {
return new Product({
id: rec._id,
updatedDate: new Date(rec.updated_date_str),
title: rec.name,
});
})
});
}
}
There is a lot going on here so let’s unpack it. We start by creating a class for our adapter.
class RecommendationAdapter { ... }
I recommend you name this after its featured role rather then the specific library you are using as a reminder to yourself and future developers that this class is responsible for serving your app’s code and not serving the vendor API. For example AnalyticsAdapter
would be a better name than GoogleAnalyticsAdaper
or PaymentAdapter
would be prefered over StripeAdapter
. This advice mostly applies to a language like JavaScript that does not have the concept of interfaces. If you are using something like TypeScript then it can be appropriate to name your implementation after the vendor as long as your interface is named for the role in your codebase.
Next we have our constructor function.
constructor(vendor) {
// `vendor` is the vendor API object. We inject this into the
// constructor when initializing the RecommendationAdapter and keep a
// reference to it so all methods have access to it.
this.vendor = vendor;
}
I usually find injecting the vendor API into the class as a dependency when the object is constructed makes it a bit easier to test because we can pass a mock into the constructor when testing.
Then we move on to our example method, forProduct
.
forProduct(product) {...}
The first thing you will notice is it takes a product
. The underlying vendor API only cares about the product id but we want to be consistent with the rest of our application where products
are the domain model that is usually passed as an argument from component to component.
Continuing on we see the start of the forProduct
method.
forProduct(product) {
// The vendor API uses NodeJS style callbacks first we transform
// this into a promise to match the conventions in the rest of the
// codebase
return new Promise((resolve, reject) => {
this.vendor.getRecomendation(product.id, function(error, response) {
if (error) {
reject(error);
} else {
resolve(response);
}
});
}).then(//...)
Again we can see the adapter cares about ensuring a consistent interface with the rest of the applications. The adapter converts the NodeJS style async API into a promise so the team can use familiar promise based patterns in the rest of the app.
Finally we get into the meat of the method:
// ...
}).then(response => {
// now that we have the vendor response lets convert this into
// our standard product object
return response.data.recommendations.map(rec => {
return new Product({
id: rec._id,
updatedDate: new Date(rec.attributes.updated_date_str),
title: rec.attributes.name,
});
});
});
Here is where the adapter pattern really shines. The vendor API returns some deeply nested data, but our adapter flattens out the response which will make it less annoying to mock in our tests and less brittle for our production code to consume. The adapter also translates the property names and converts the serialized date string into a Date
object for us. The goal is to return an array of Product
objects that the rest of the application can consume as if it had been returned from an in-house API.
Testing
Often vendor APIs include dependencies on external services beyond our control. This can make automated testing difficult because those dependencies may be inaccessible in the test environment, or return non-deterministic values. The adapter pattern helps with this by giving our codebase a test seam. A test seam is a place in our codebase where we can replace the real object with a fake object in the test environment. Let’s look at an example. Imagine we have the following React component:
import React from 'react';
class RecommendedProducts extends React.Component {
componentDidMount() {
this.props.adapter.forProduct(this.props.product).then(products => {
this.setState({ products });
});
}
render() {
const { products } = this.state;
if (!products) {
return <Loading />;
}
return (
<div>
{products.map(product => (<ProductDisplay product={product} />))}
</div>
);
}
}
In our production code the adapter
property is passed into the component by a parent component. In our tests we can provide a fake adapter
object to our component.
import React from 'react';
import { shallow } from 'enzyme';
import RecommendedProducts from './recommended-products';
describe('<RecommendedProducts />', () => {
it('should render a loading state while waiting for the recommended products', () => {
let adapter = {
// The forProduct promise never resolves here
forProduct() { return new Promise(resolve => null) }
};
let wrapper = shallow(<RecommendedProducts adapter={adapter} />);
expect(wrapper.find('Loading').length).to.equal(1);
});
it('should render a product display for each product returned by the adapter', () => {
let adapter = {
// Resolve forProduct with 3 fake product objects
forProduct() { return Promise.resolve([{}, {}, {}]) }
};
let wrapper = shallow(<RecommendedProducts adapter={adapter} />);
expect(wrapper.find('ProductDisplay').length).to.equal(3);
});
});
One thing you will notice here is we are replacing the RecommendedProducts
component’s direct dependency adapter
instead of the vendorAPI
which is responsible for the non determinism in our code base. When you are testing, it is generally a good idea to mock out an object’s direct dependencies instead of sub dependencies. This way our tests can give us feedback on the interface that is being used in the production code. This is a helpful feedback mechanism when writing your tests. If you find it inconvenient to mock the direct dependency you may realize the dependency itself is awkward and this can be used as a hint that you may want to refactor your adapter’s interface to make it more accommodating for the requirements of your codebase.
Changing Vendor APIs
Now what we are using the adapter pattern our codebase integrates directly with the adapter’s interface. If we ever need to upgrade or replace the underlying vendor API we can simply change the internals of the Adapter
class and keep exposing the old adapter interface to the rest of our codebase. This makes our codebase more resistant to change due to external factors outside our control. For example, the vendor may release a new API with functionality the business needs, the vendor may go out of business or our business may choose to partner with a new vendor to solve this particular problem for any number of reasons.
Conclusion
Next time you need to integrate with a vendor library to solve a problem, I hope you will reach for the adapter pattern. Its ability to change the interface of a vendor library to make it accommodate the conventions of your codebase is invaluable in any large software project. Additionally the pattern introduces a seam into your codebase that makes it easy to replace the vendor implementation in your tests or in your entire codebase if you ever decide to change vendors down the road.