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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
use App\Enums\Size; use App\Mugs\Mug; use App\Mugs\MugInterface; use App\Mugs\ToGoCup; use Bunn\DripBrewer; class MakeCoffee { public function __construct( private DripBrewer $brewer, private Size $size, private bool $isToGo ) {} public function serve(): MugInterface { $mug = $this->isToGo ? new ToGoCup($size) : new Mug($size); if ($this->brewer->getAvailableCoffee() < $this->size->value) { $this->brewer->brewFreshPot(); } return $mug->fill($this->brewer->getCoffee($size->value)); } private function brewFreshPot(): void { $this->brewer->brew(DripBrewer::FULL_POT); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 |
use App\Beverages\BeverageInterface; interface BrewerInterface { /** * Prepare the given amount (in fluid ounces) of a beverage. * * @param int $amount The number of fluid ounces to prepare. */ public function prepare(int $amount): BeverageInterface; } |
We might then move our Bunn\DripBrewer
logic into an adapter that implements this interface:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
use Bunn\DripBrewer; class Drip implements BrewerInterface { public function __construct( private DripBrewer $brewer ) {} public function prepare(int $amount): BeverageInterface { if ($this->brewer->getAvailableCoffee() < $amount) { $this->brewFreshPot(); } return $this->brewer->getCoffee($amount); } private function brewFreshPot(): void { $this->brewer->brew(DripBrewer::FULL_POT); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
use App\Brewers\BrewerInterface; use App\Enums\Size; use App\Mugs\Mug; use App\Mugs\MugInterface; use App\Mugs\ToGoCup; class MakeCoffee { public function __construct( private BrewerInterface $brewer, private Size $size, private bool $isToGo ) {} public function serve(): MugInterface { $mug = $this->isToGo ? new ToGoCup($size) : new Mug($size); return $mug->fill($this->brewer->prepare($size->value)); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
use App\Beverages\Coffee; use App\Enum\Grind; use App\Grinder; use App\Kettle; use Chemex\ChemexCoffeemaker; use Chemex\ChemexCoffeemaker\BrewedCoffee; class Chemex implements BrewerInterface { public function __construct( private ChemexCoffeemaker $chemex, private Kettle $kettle, private Grinder $grinder ) {} public function prepare(int $amount): BeverageInterface { $water = $this->kettle ->setDegreesFahrenheit(200) ->getWater(); $coffee = $this->chemex ->setCoffeeSource($this->grinder->grind(Grind::MEDIUM)) ->setWaterSource($water) ->setSizeInOunces($amount) ->brew(); return self::convertBrewedCoffeeToBeverageInterface($coffee); } /** * Convert a Chemex-specific BrewedCoffee instance into our common * BeverageInterface. */ public static function convertBrewedCoffeeToBeverageInterface( BrewedCoffee $coffee ): BeverageInterface { return new Coffee($coffee->getContents()); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
use Bunn\DripBrewer; use Chemex\ChemexCoffeemaker; class BrewerFactory { public static function getBrewer( ?BrewingMethods $method = null ): BrewerInterface { switch ($method) { case BrewingMethods::Chemex: return self::makeChemex(); case BrewingMethods::Drip: default: return self::makeDrip(); } } private static function makeChemex(): Chemex { return new Chemex( new ChemexCoffeemaker(/* any configuration necessary */), new Kettle(), new Grinder() ); } private static function makeDrip(): Drip { return new Drip( new DripBrewer() ); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
use App\Beverages\BeverageInterface; use App\Brewers\BrewerInterface; use App\Enums\Size; use App\Jobs\MakeCoffee; use App\Mugs\ToGoCup; use PHPUnit\Framework\TestCase; /** * @covers App\Jobs\MakeCoffee */ final class MakeCofeeTest extends TestCase { public function setUp(): void { $this->brewer = new class implements BrewerInterface { public function prepare(int $amount): BeverageInterface { return new Coffee(); } }; } public function testToGoOrdersAreInToGoCups(): void { $job = new MakeCoffee($this->brewer, Size::Medium, true); $coffee = $job->serve(); $this->assertInstanceOf(ToGoCup::class, $coffee); $this->assertSame(Size::Medium->value, $coffee->getSize()); } // More 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:
1 2 3 4 5 6 7 8 |
if (FeatureFlags::isEnabled('some_feature_flag_name')) { /* * The new approach. If this doesn't include a return statement, * you'll want to include an "else" statement. */ } // The old approach. |
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?
Leave a Reply