Design Patterns And Data Architecture

Adapter Pattern

The adapter pattern lets one part of an application use a clean interface while another class translates calls to a different API. In PHP projects, adapters are common around payment SDKs, mail providers, SMS gateways, cloud storage clients, queue libraries, search services, framework services, and legacy code. The adapter does not make the external system disappear. It gives the rest of the application a boundary that uses the application's own vocabulary.

A useful adapter protects the codebase from a dependency's shape. Third-party SDKs often use arrays with provider-specific keys, exceptions with provider-specific types, response objects with many optional fields, or method names that reflect the vendor's API rather than the application's use case. If those details spread into controllers, services, jobs, and tests, changing provider or upgrading the SDK becomes expensive. An adapter keeps that translation in one place.

Do not use an adapter merely to rename every method one-for-one. If MailerAdapter::send() only calls Mailer::send() with the same arguments and no semantic boundary, the wrapper may not help. The adapter earns its place when it translates between two models: application intent on one side, dependency mechanics on the other.

The Shape Of An Adapter

An adapter usually has three pieces:

  • an application-owned interface
  • one concrete adapter that implements that interface
  • an external class, SDK, legacy function, or framework service hidden behind the adapter

The interface should describe what the application needs. ReceiptSender, InvoiceStorage, SmsNotifier, PaymentAuthorizer, and SearchIndexer are clearer than ProviderAdapter. The name should make sense even if the provider changes.

The adapter implementation receives the external dependency, translates input, calls the external API, translates output or exceptions, and returns an application-level result. It should not contain the whole business use case. Its job is boundary translation.

A Mailer Adapter

This example wraps a third-party mailer whose API expects an array payload. The rest of the application works with ReceiptSender instead.

PHP example
<?php

declare(strict_types=1);

interface ReceiptSender
{
    public function sendReceipt(string $email, string $message): void;
}

final class ThirdPartyMailer
{
    /** @param array{to: string, body: string, tags: list<string>} $payload */
    public function deliver(array $payload): void
    {
        echo $payload['to'] . ': ' . $payload['body'] . ' [' . implode(',', $payload['tags']) . ']' . PHP_EOL;
    }
}

final class ThirdPartyReceiptSender implements ReceiptSender
{
    public function __construct(
        private ThirdPartyMailer $mailer,
    ) {
    }

    public function sendReceipt(string $email, string $message): void
    {
        $this->mailer->deliver([
            'to' => $email,
            'body' => $message,
            'tags' => ['receipt'],
        ]);
    }
}

$sender = new ThirdPartyReceiptSender(new ThirdPartyMailer());
$sender->sendReceipt('ada@example.com', 'Your order is ready.');

// Prints:
// ada@example.com: Your order is ready. [receipt]

The controller or checkout service does not know the provider wants a tags array. If the mail provider changes, the application-owned ReceiptSender contract can remain stable while the adapter changes.

Own The Interface

The most important adapter rule is that the application should own the interface. If the rest of the code depends directly on a vendor interface, the vendor still controls the shape of your boundary. Sometimes that is acceptable for stable standards such as PSR interfaces. For product-specific SDKs, application-owned interfaces are usually safer.

Owning the interface does not mean inventing an abstraction for every package. A small project can use a framework mailer directly when the framework is already the application platform. An adapter becomes more valuable when the dependency crosses a business boundary, has volatile provider-specific details, is hard to test, or might reasonably be replaced.

Make the interface narrow. If the application only sends receipts, sendReceipt() is clearer than exposing every feature of the provider's email platform. A wide adapter that mirrors the entire provider SDK becomes a second SDK to maintain.

Translate Meaning, Not Just Types

An adapter should translate semantics. For a payment provider, that might mean converting application money values into minor units, mapping provider decline codes into PaymentDeclined, enforcing idempotency keys, and hiding provider response fields that the application should not depend on. For storage, it might mean translating application file visibility into provider ACLs or signed URL options.

This translation must be deliberate. If provider exception messages or status names leak directly through the adapter, the rest of the application may start depending on them. Later provider changes then become application changes.

Use application-level result objects or exceptions where the caller needs stable behavior. For example, ReceiptDeliveryFailed can wrap a provider exception while preserving safe context such as provider name, message ID, and retry category. Do not include secrets, full payloads, or raw access tokens in exception messages.

Adapters And Testing

Adapters improve testing when they make the application boundary fakeable. A service that depends on ReceiptSender can use a recording fake in tests. The fake records requested emails without contacting the provider.

The adapter itself still needs integration-style tests. It is the class that knows the provider payload shape, required headers, endpoint names, timeout behavior, and exception mapping. Test it with provider fakes, SDK test doubles, sandbox environments, or contract tests where appropriate.

Avoid mocking the adapter so heavily that no test ever proves translation. If the adapter is the only class that maps PaymentDeclined correctly, at least one test should exercise that mapping.

Adapters Around Legacy Code

Adapters are not only for third-party services. They are useful when modern code must call legacy functions or old classes with awkward APIs.

A legacy function may read globals, return false on failure, emit warnings, or expect loosely shaped arrays. An adapter can convert that into a typed method, validate input, handle failure consistently, and keep the old behavior away from new code.

This is a practical migration technique. Instead of rewriting a risky legacy subsystem immediately, create an adapter around the part you need, write tests for the adapter, then refactor behind the boundary over time. The application code should depend on the new interface from the start.

Adapters And Framework Services

Frameworks already provide useful abstractions. Laravel has mail, storage, queue, cache, and notification services. Symfony has mailer, HTTP client, serializer, event dispatcher, cache, and filesystem components. Do not wrap framework services automatically.

Use an adapter when the application needs a business-specific boundary. InvoicePdfStorage can be clearer than passing a generic filesystem service everywhere. CustomerNotifier can hide whether the framework notification channel is email, SMS, or an in-app message.

If the framework service is already the boundary and the project is tightly coupled to that framework, extra adapters may add noise. The decision should be based on replacement risk, testability, semantic clarity, and how far provider details would otherwise spread.

Error Handling And Retries

Adapters should not hide failure categories. A caller may need to know whether an operation failed because of invalid input, missing configuration, authentication failure, rate limit, timeout, temporary provider outage, or permanent rejection.

Map provider failures into application-level categories. Do not return only false. A boolean gives no recovery path. A typed exception or result object can tell the caller whether to retry, ask the user to correct data, alert operations, or mark a job as permanently failed.

Retry policy may live outside the adapter if it is a use-case decision, but the adapter should expose enough information to support that decision. For example, an email adapter can classify provider rate limits as temporary while a malformed recipient address is permanent.

Common Failure Modes

One failure mode is the leaky adapter. The interface returns provider response objects, provider enum names, or provider exceptions directly. That makes the boundary look clean while still coupling the application to provider details.

Another failure mode is the pass-through adapter. Every method mirrors the SDK with the same names and arguments. This creates more files without reducing coupling. Prefer a narrow interface around application needs.

A third failure mode is hiding too much. If the adapter catches every exception and returns null, callers cannot distinguish unavailable provider, invalid request, permission failure, and code defect. Hiding detail from the public user is good; hiding operational meaning from the application is not.

A fourth failure mode is putting business workflow into the adapter. A payment adapter should authorize, capture, refund, or void according to its contract. It should not decide whether an order is allowed to ship, update inventory, and send customer email unless the interface explicitly represents that whole business operation.

Review Criteria

When reviewing an adapter, ask which dependency detail it prevents from spreading. If there is no answer, the adapter may be unnecessary. If the answer is provider payload shape, exception names, authentication setup, or legacy return behavior, the adapter likely has a real job.

Check that the application-owned interface is small and meaningful. Check that inputs and outputs use application vocabulary. Check that provider configuration and credentials remain at the infrastructure boundary. Check that failures are mapped into categories the caller can act on.

Check tests on both sides. Application services should test against a fake interface implementation. The adapter should test translation to and from the provider shape. If only one side is tested, the boundary can still break silently.

What To Check

Before moving on, make sure you can:

  • explain adapter as boundary translation between two APIs
  • define an application-owned interface around a dependency
  • keep provider arrays, exceptions, and response objects from leaking outward
  • distinguish useful adapters from pass-through wrappers
  • use adapters for third-party SDKs, framework services, and legacy code only when they clarify ownership
  • map provider failures into application-level categories
  • test both the application boundary and the adapter translation

After this lesson, you should be able to look at a third-party or legacy dependency in a PHP project and decide whether direct use is acceptable, whether a narrow adapter would protect the codebase, and what semantic translation the adapter must own.

Practice

Task: Wrap A Third-Party Mailer

Design an adapter boundary.

Requirements

Name the application-owned interface, the adapter class, the method signature, and the payload translation. Explain what the checkout code should depend on.

Check your work

The answer should keep the third-party array shape out of the checkout code.

Show solution

Create ThirdPartyReceiptSender implements ReceiptSender. Its constructor receives the third-party mailer. Its sendReceipt() method builds the provider payload, for example ['to' => $email, 'body' => $message, 'tags' => ['receipt']], and calls deliver().

This keeps provider field names, tagging rules, and future provider changes inside the adapter. Checkout tests can use a fake ReceiptSender that records requested receipts without sending email.

Task: Design A Payment Adapter

A payment SDK returns provider-specific status strings such as approved, insufficient_funds, expired_card, and provider_unavailable. It may also throw SDK exceptions.

Design an adapter interface and failure mapping for the application.

Requirements

Include the application method name, input values, result or exception categories, retry meaning, and what provider details should not leak.

Check your work

The answer should give callers stable application-level outcomes rather than raw provider strings.

Show solution

The adapter maps approved to an approved result. insufficient_funds and expired_card become declined results with safe application codes such as insufficient_funds and expired_card. provider_unavailable and timeout exceptions become temporary failures that a job may retry if the idempotency key is stable. Authentication or configuration exceptions should become operational failures, not user-correctable declines.

The adapter should not leak raw SDK response objects, secret-bearing payloads, provider stack traces, or undocumented provider status names. It can preserve safe context such as provider name, provider reference ID, retry category, and public decline code.

Task: Review A Leaky Adapter

A storage adapter interface returns the cloud provider's response object. Controllers inspect provider fields to decide whether a file is private, public, missing, or temporarily unavailable.

Review the design and propose a better boundary.

Requirements

Explain why the adapter leaks provider details, what the interface should return instead, how errors should be represented, and how tests should change.

Check your work

The answer should move provider-specific response handling into the adapter.

Show solution

The adapter is leaky because controllers still understand the provider's response object and field names. If the provider SDK changes, controller behavior can break. If a second provider is introduced, every controller branch may need another provider-specific case.

Create an application-owned interface such as InvoiceFileStorage with methods like storePrivateInvoice(InvoiceFile $file): StoredInvoiceFile and temporaryDownloadUrl(InvoiceId $id): TemporaryUrl. Return application result objects that contain stable fields such as storage key, visibility, expiry time, or download URL.

Represent missing files, permission problems, temporary provider outages, and configuration failures with typed exceptions or result categories. Controllers can then translate those application outcomes into HTTP responses without knowing provider details.

Tests should change so controller tests use a fake InvoiceFileStorage. Adapter tests should cover provider response mapping, missing object mapping, temporary failure mapping, and URL expiry behavior.