Decoding The Adapter Pattern

This pattern is one of my favourite patterns and especially valuable in today's open-source dominated era, where engineers globally contribute solutions, enriching the developer community. It's crucial, however, to avoid tightly coupling our codebase to specific implementations, as we'll explore through real-world scenarios in this article.

Analogy: The Tale of the Universal Remote

Imagine a room filled with gadgets, each operated by its unique remote. Managing them becomes cumbersome until you discover a universal remote that interfaces with all devices seamlessly. It doesn't alter the gadgets' operations but standardizes interaction.

Similarly, in software development, the Adapter Pattern acts as this 'universal remote,' facilitating communication between different interfaces without modifying their core functions.

Real-world Example: Switching Media Services

Usually in my projects I utilize ImageKit for media services, leveraging its CDN capabilities. However, should we decide to switch to another service like Cloudinary, manually updating every ImageKit instance in our codebase would be impractical and violate the Open-Closed Principle.

To circumvent this, we employ the Adapter Pattern, starting with defining a contract:

interface MediaInterface 
{
    public function getUrl(string $path);
    public function upload(string $fileUrl, string $filename);
}

Next, we implement the interface for our current service, ImageKit:

class ImageKitService implements MediaInterface 
{
    public function getUrl(string $path) 
    {
        // Implement getUrl() method.
    }

    public function upload(string $fileUrl, string $filename) 
    {
        // Implement upload() method.
    }
}

Then, we create an internal class to interact with our media service:

class MediaService 
{
    protected MediaInterface $media;

    public function __construct(MediaInterface $mediaInterface) 
    {
        $this->media = $mediaInterface;
    }

    public function getUrl(string $path) 
    {
        return $this->media->getUrl($path);
    }

    public function upload(string $fileUrl, string $filename) 
    {
        return $this->media->upload($fileUrl, $filename);
    }
}

// usage
$mediaService = new MediaService(new ImageKitService);
$mediaService->getUrl('some-url');
$mediaService->upload('file-url','file-name');

Refactoring Geocoding Service

Another common scenario involves geocoding services. When the library we're using becomes obsolete, the Adapter Pattern can facilitate a smooth transition to a new library.

We start by defining a common interface:

interface GeocoderInterface 
{
    public function fetchCoordinates(string $address);
}

Next, we ensure our existing class or adapter conforms to this contract:

class LegacyGeoAdapter implements GeocoderInterface 
{
    public function fetchCoordinates(string $address) 
    {
        // Logic to return the coordinates.
    }
}

We can now use LegacyGeoAdapter in our codebase as follows:

class Address 
{
    public function __construct(
        protected GeocoderInterface $geocoder,
        protected string $address
    ) {}

    public function getCoordinates() 
    {
        return $this->geocoder->fetchCoordinates($this->address);
    }
}

$address = new Address(
    new LegacyGeoAdapter(),
    '120 highway street, Manchester'
);

$address->getCoordinates();

Let's define our new Adapter, which will conform to GeocodeInterface:

class NewGeoAdapter implements GeocoderInterface 
{
    public function fetchCoordinates(string $address) 
    {
        // Logic to return coordinates.
    }
}

Finally, we replace the LegacyGeoAdapter with NewGeoAdapter:

$address = new Address(
    new NewGeoAdapter(),
    '120 highway street, Manchester'
);

$address->getCoordinates();

Benefits

  • Flexibility: Easily switch between services without extensive code refactoring.
  • Consistency: Maintain a uniform method of interaction, regardless of the underlying service.
  • Open-Closed Principle: Our code remains open for extension but closed for modification.
  • Dependency Inversion: By runtime injection of our classes, we achieve Dependency Inversion, a core SOLID design principle.

Comparison with Other Patterns

The Adapter Pattern is distinct from the Factory and Strategy patterns, though they all involve object creation. The Factory Pattern focuses on object creation, the Strategy Pattern on defining a family of algorithms, and the Adapter Pattern on bridging incompatible interfaces to work together seamlessly.

Conclusion

The Adapter Pattern is not just about coding; it's about future-proofing our applications. It ensures our systems can adapt to new challenges or innovative open-source solutions, embodying the essence of building enduring systems in an ever-evolving world.