Steve Grunwell

Open-source contributor, speaker, and electronics tinkerer

A pile of LEGO bricks spread out across a hardwood floor

Decouple Your Application Code with the Adapter Pattern

The last few months at work I’ve been deep in a refactoring project, cleaning up over twenty years of technical debt. It’s been a massive undertaking, but it’s rewarding work when I’m finally able to remove code that’s been hanging out well-past its expiration date.

One of the patterns that’s come in extremely handy is the Adapter Pattern, which lets me decouple application code from the underlying libraries that we use. We’ll look at how that works from a refactoring end at the end of this post, but first let’s understand what the Adapter Pattern is and how it works.

The Adapter Pattern and brewing coffee

In the broadest sense, the Adapter Pattern is a way of writing our code where we define common interfaces, then write “adapters” that allow any number of implementations to be used. Our application code is then given an instance—any instance—of the interface, and the implementation-specific details are abstracted into the adapters.

As I’ve done in many posts before, let’s use coffee as an example: let’s imagine we’re working on an app called Barista, which serves coffee to visitors. When we first get started, maybe we only have one way to prepare coffee: a good, old fashioned Bunn coffee maker. Our “MakeCoffee” job might look something like this:

The job receives an instance of Bunn\DripBrewer, a valid size (in this example, using a backed enum), and a boolean $isToGo argument, which determines whether we get a to-go cup or a mug.

When we call the serve() method, we determine what kind of mug to use, then ensure that we have enough coffee in the brewer (and, if not, we brew a fresh pot). Finally, we call the Bunn\DripBrewer::getCoffee() method to fill our mug with the appropriate number of ounces of coffee.

Introducing more brewing methods

As it turns out, people really enjoy the coffee from Barista but are asking for new brewing methods. Now we need to determine how we’ll do this, because our MakeCoffee job is tightly-coupled to the Bunn\DripBrewer() class.

Regardless of how we choose to prepare coffee, there are some generalities, which we might put into an interface:

We might then move our Bunn\DripBrewer logic into an adapter that implements this interface:

Notice that this is nearly identical to our old Jobs/MakeCoffee::serve() method, but we’re injecting the Bunn\DripBrewer instance in our constructor.

Now we can update our serve() method so that it accepts a BrewerInterface instance instead:

The result is the same, but now all of the logic for brewing with Bunn\DripBrewer is encapsulated in our App\Brewers\Drip adapter.

We’re now free to implement new brew methods, including Chemex, V60 pour-over, French Press, and more: as long as our adapters implement the BrewerInterface, the application code doesn’t need to care. Here’s an example of what our App\Brewers\Chemex adapter class might look like:

You can see that the Chemex is much more involved: we’re receiving a kettle and grinder via our constructor, and we’re handling all of the logic specific to the third-party Chemex\ChemexCoffeemaker package. However, our input (int $amount) and output (an instance of the App\Beverages\BeverageInterface) are the same: everything else is just an implementation detail.

Selecting an adapter

Now that our MakeCoffee job can accept any BrewerInterface, we need to determine how this gets decided. While this is largely dependent upon your application structure, this could be as simple as a factory class:

Here, we call BrewerFactory::getBrewer() with whatever method was selected when the order was placed.

If we’re using a Dependency Injection/Service container, we can centralize any configuration there. Even if the configuration is in our factory class, it’s centralized in one place: any time we need a brewer, we just need to ask BrewerFactory for one.

Testing the brew methods

One of the major benefits of the Adapter Pattern is how it aids in testing: instead of our MakeCoffee job being coupled to Bunn\DripBrewer, we can mock the BrewerInterface interface in our tests:

Then, when it comes to the individual adapters, we can ensure that, for example, our Chemex adapter is setting the Chemex\ChemexCoffeemaker parameters appropriately before brewing.

The Adapter Pattern in the real world

In the real world, most of our applications aren’t acting as virtual representations of baristas (yet). For a more practical look at the Adapter Pattern in action, look no further than Symfony’s Mailer component (which also powers Laravel’s mail capabilities).

The package defines a common Transport interface and ships with a few common transports (e.g. sendmail, smtp, etc.). There are then around a dozen different packages that can be installed to enable, for example, sending through transactional email services like Mandrill or Mailgun.

Once you’ve installed the necessary transport (read: adapter) and supplied the necessary configuration, swapping out transports is just a matter of changing which one gets injected into the application: no other code needs to be changed!

Leveraging the Adapter Pattern during a refactor

One place where the Adapter Pattern can come in particularly useful is during a refactor. Imagine you have an older library in your app with no clear upgrade path: you want to remove the old library and replace it with something new, but it’s used in a few places and can’t simply be ripped out all at once.

In these situations, it can be helpful to define your interface, then write adapters for both the new and current libraries. You’ll want to write the appropriate tests (even for the library you’re about to remove), then you can begin replacing the current implementations within your app code with the new approach. If your app supports feature flags (which is a post for another day), this would be a great place to flag the change:

At this point, all we’ve done is move from directly referencing the old library to referencing it through our adapter. Now, we can update our configuration (whether it’s in your DI container, a factory method, or another approach) to swap out our implementation. This is another place where—assuming you have feature flags available—it would be wise to slowly ramp up the use of the new library.

If everything has gone according to plan, you’ve successfully decoupled those locations from the old library and replaced the implementation with your new adapter!

A word of caution

As I’ve demonstrated here, the Adapter Pattern can be immensely useful, enabling you to decouple your application code from the underlying libraries or services you may be using.

However, like any design pattern, the Adapter Pattern should be used responsibly: it’s very easy to prematurely abstract things, especially if there’s little chance you might actually swap out the implementation. Sure, you could replace Monolog, but how likely does it seem you’ll rip out the entire logging infrastructure?

Previous

The Beauty of PHP Value Objects

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Be excellent to each other.