Why the adapter pattern saves my backside constantly
Why the adapter pattern saves my backside constantly
NOTE: I am interested in making this into a talk, so if you think it might be one worth listening to at an event you attend, give me a shout!
The adapter pattern is amazing. There I said it. You see the world of backend web development nowadays typically involves gathering data from a wide range of sources, doing some business logic with that data and producing an output, whether that be storing data in your own database, or exposing data via an endpoint etc. Let's take this example, you are building a small service responsible for ordering the correct postage. The service takes in an order, and then needs to integrate with several postage providers like Evri, Parcelforce, DHL, DPD etc. It would be easy to do something like this:
if ($order->deliveryMethod === 'dpd') {
$response = $dpdClient->createShipment([
'name' => $order->customerName,
'address' => $order->address,
'weight' => $order->weight,
]);
} else if ($order->deliveryMethod === 'evri') {
$response = $evriClient->shipParcel([
'full_name' => $order->customerName,
'line_1' => $order->address->line1,
'parcel_weight_kg' => $order->weight,
]);
}
You could go one step further and use a switch statement:
switch ($order->deliveryMethod) {
case 'dpd':
$response = $dpdClient->createShipment([
'name' => $order->customerName,
'address' => $order->address,
'weight' => $order->weight,
]);
break;
case 'evri':
$response = $evriClient->shipParcel([
'full_name' => $order->customerName,
'line_1' => $order->address->line1,
'parcel_weight_kg' => $order->weight,
]);
break;
}
This is an approach I see all too often and to be honest, it works. Whilst the codebase is fairly small, it gets the job done and allows you to keep up your development velocity. The thing is, you're slowly shooting yourself in the foot.
Let's say we get a request to make the service smarter and find the cheapest carrier for our order. We might then decide to loop through all the carriers to get a price, then do some logic to find the cheapest suitable price, then place the shipping request with the carrier, before returning the details as a response which might look something like this:
$quotes = [];
foreach ($carriers as $carrier) {
switch ($carrier) {
case 'dpd':
$quotes[] = $dpdClient->getQuote([
'weight' => $order->weight,
'postcode' => $order->postcode,
]);
break;
case 'evri':
$quotes[] = $evriClient->calculatePrice([
'parcel_weight_kg' => $order->weight,
'destination_postcode' => $order->postcode,
]);
break;
}
}
$cheapest = collect($quotes)->sortBy('price')->first();
switch ($cheapest['carrier']) {
case 'dpd':
$shipment = $dpdClient->createShipment(...);
break;
case 'evri':
$shipment = $evriClient->shipParcel(...);
break;
}
The code above is now starting to look pretty messy and has several reasons to change, which is usually a warning sign.
The adapter pattern, if we use the definition by https://refactoring.guru is:
An adapter wraps one of the objects to hide the complexity of conversion happening behind the scenes. The wrapped object isn’t even aware of the adapter. For example, you can wrap an object that operates in meters and kilometers with an adapter that converts all of the data to imperial units such as feet and miles.
Now, I appreciate that definition might not make much sense at first but let's look at how we can implement that in our service.
First, let's create a common shipping interface:
interface ShippingProvider
{
public function getName(): string;
public function getQuote(Order $order): Quote;
public function createShipment(Order $order): Shipment;
}
And let's use that interface to create some providers for DPD and Evri:
class DpdAdapter implements ShippingProvider
{
public function __construct(
private DpdClient $client
) {}
public function getQuote(Order $order): Quote
{
$response = $this->client->getQuote([
'weight' => $order->weight,
'postcode' => $order->postcode,
]);
return new Quote(
carrier: 'dpd',
price: $response['price'],
deliveryDays: $response['delivery_days']
);
}
public function createShipment(Order $order): Shipment
{
$response = $this->client->createShipment([
'name' => $order->customerName,
'address' => $order->address,
'weight' => $order->weight,
]);
return new Shipment(
trackingNumber: $response['tracking_number'],
labelUrl: $response['label']
);
}
}
class EvriAdapter implements ShippingProvider
{
public function __construct(
private EvriClient $client
) {}
public function getQuote(Order $order): Quote
{
$response = $this->client->calculatePrice([
'parcel_weight_kg' => $order->weight,
'destination_postcode' => $order->postcode,
]);
return new Quote(
carrier: 'evri',
price: $response['amount'],
deliveryDays: $response['eta_days']
);
}
public function createShipment(Order $order): Shipment
{
$response = $this->client->shipParcel([
'full_name' => $order->customerName,
'line_1' => $order->address->line1,
'parcel_weight_kg' => $order->weight,
]);
return new Shipment(
trackingNumber: $response['tracking_id'],
labelUrl: $response['label_url']
);
}
}
Now, let's look at what our main business logic will look like:
$quotes = [];
foreach ($providers as $provider) {
$quotes[] = $provider->getQuote($order);
}
$cheapest = collect($quotes)
->sortBy('price')
->first();
$selectedProvider = collect($providers)
->first(fn ($provider) =>
$provider->getName() === $cheapest->carrier
);
$shipment = $selectedProvider->createShipment($order);
You can see we have no switch statements, no carrier-specific logic, no weird payload transformations taking place polluting our main business logic. In effect, we ensure everything speaks our service's language rather than the individual carrier's language.
But the real superpower is that we isolate changes to just one file. If Evri go ahead and decide they're changing their API's, we make the change in once place. We don't have to update across multiple files where we've utilised their raw API responses etc.
That's why the adapter pattern saves my backside constantly.
External systems change all the time. APIs evolve, providers disappear, payloads change and business requirements grow.
Adapters let those changes stay isolated instead of leaking throughout your entire codebase.