Adapter Pattern — Production Deep Dive (Java & Go)

Adapter Pattern — Production Deep Dive (Java & Go)

Why Adapter (in production)?

In production systems, you often integrate with legacy code, third‑party SDKs, or multiple providers that expose different shapes of APIs. You want your application core to depend on a stable, domain-friendly interface while still being able to talk to these outside systems. That’s exactly what the Adapter Pattern gives you: a glue layer that translates your interface (“Target”) to their interface (“Adaptee”).

Classic structure

+------------------+        +-----------------------+
|   Target (Yours) |<-------|      Adapter          |
| PaymentGateway   | uses   | implements Target     |
| charge(req)      |        | maps to Adaptee calls |
+------------------+        +-----------------------+
                                     |
                                     v
                            +------------------+
                            |  Adaptee (Theirs)|
                            | LegacyPayClient  |
                            | pay(x,y,z)       |
                            +------------------+
        

When should you use an Adapter? (Decision checklist)

Use an Adapter now if at least one is true:

  1. Interface mismatch: The library/service you must use doesn’t match your domain interface.
  2. Replaceability: You want to swap providers (e.g., Stripe ↔ Razorpay ↔ PayU) without touching business code.
  3. Legacy integration: You can’t modify legacy code (closed-source / risky / shared by many teams).
  4. Version migration: You need to migrate v1 → v2 of an SDK/API, but keep your internal interface stable.
  5. Multiple protocols: Your core expects gRPC, the SDK exposes REST, or vice versa—Adapter does the protocol translation.
  6. Vendor lock prevention: You want to avoid leaking vendor types into the core domain (clean architecture boundary).
  7. Observability/Control: You need a single place to log, trace, meter, timeouts, retries, and error mapping for an external dependency.

Avoid an Adapter if:

  • You can change the domain interface without damage and it naturally matches the SDK; or
  • All you need is a simplified view of a complex subsystem → consider Facade; or
  • You want to extend behavior transparently to callers → consider Decorator; or
  • You need to switch implementations behind a stable abstraction that you control on both sides → consider Bridge.


Production concerns (what great adapters look like)

  1. Thin and single-purpose Only translate types, calls, and errors. No business rules here.
  2. Stateless where possible Hold only immutable configuration and a thread-safe client. Prefer composition over inheritance (object adapter).
  3. Precise error mapping Map vendor errors to domain errors (e.g., CardDeclined, InsufficientFunds, TransientFailure). Preserve idempotency and signal retryability.
  4. Timeouts & retries Set timeouts at the adapter boundary. Apply caller-controlled retries (e.g., via policies) or keep adapter retries idempotent.
  5. Observability Add structured logs, metrics (latency, error rates), and tracing spans. Tag with provider, operation, status.
  6. Type translation Avoid leaking vendor DTOs into your core. Convert to domain DTOs. Keep conversions pure and unit-testable.
  7. Configuration & feature flags Make providers pluggable via config/DI. Use feature flags for gradual rollout/canary.
  8. Resource management SDKs may need connection pools. Ensure thread-safety (Java) / goroutine-safety (Go). Close resources gracefully.
  9. Performance Overhead is small if translation stays shallow. Avoid extra allocations and deep copies unless necessary. Consider caching for static lookups.
  10. Testing strategy


Side-by-side: Adapter vs friends

  • Adapter — Translates incompatible interfaces so your code can call them.
  • Facade — Provides a simplified API over a complex subsystem you own/use; no interface mismatch required.
  • Bridge — Decouple abstraction from implementation to vary independently. You control both sides.
  • DecoratorWraps an object to add behavior without changing its interface.
  • Proxy — Controls access (lazy load, remote proxy, caching, security), same interface as the real subject.


Java example (production-flavored, line-by-line commented)

Scenario: Your domain wants a stable PaymentGateway interface. You must integrate a legacy SDK LegacyPayClient that has a different API and error model.

// Domain-level request type representing a payment charge
public final class ChargeRequest {
    // Amount in the smallest currency unit (e.g., paise for INR)
    public final long amountInMinor;
    // ISO currency code e.g., "INR"
    public final String currency;
    // A unique idempotency key generated by the caller
    public final String idempotencyKey;
    // Masked card token or payment method id
    public final String paymentMethodToken;

    // All fields set via constructor to ensure immutability
    public ChargeRequest(long amountInMinor, String currency, String idempotencyKey, String paymentMethodToken) {
        this.amountInMinor = amountInMinor; // set amount
        this.currency = currency;           // set currency
        this.idempotencyKey = idempotencyKey; // set idempotency key
        this.paymentMethodToken = paymentMethodToken; // set payment method token
    }
}

// Domain-level result type for a charge operation
public final class ChargeResult {
    // Whether the charge succeeded
    public final boolean success;
    // The provider-generated transaction id (if any)
    public final String transactionId;
    // A stable, domain error code if failed (nullable)
    public final String errorCode;
    // Human-readable message for logs/ops (nullable)
    public final String message;

    // Private constructor to enforce factory methods
    private ChargeResult(boolean success, String transactionId, String errorCode, String message) {
        this.success = success;              // assign success flag
        this.transactionId = transactionId;  // assign transaction id
        this.errorCode = errorCode;          // assign domain error code
        this.message = message;              // assign message
    }

    // Factory for success result
    public static ChargeResult success(String transactionId) {
        return new ChargeResult(true, transactionId, null, null); // success variant
    }

    // Factory for failure result
    public static ChargeResult failure(String errorCode, String message) {
        return new ChargeResult(false, null, errorCode, message); // failure variant
    }
}

// Target (your domain) interface that the application code depends on
public interface PaymentGateway {
    // Charge the customer and return a domain-level result
    ChargeResult charge(ChargeRequest request);
}

// ---- Adaptee (3rd-party / legacy) types you cannot change ----

// Simulated legacy SDK request
class LegacyPayCharge {
    public long amount;            // amount field (minor units)
    public String currencyCode;    // currency field
    public String cardToken;       // card token field
    public String dedupeKey;       // idempotency key field
}

// Simulated legacy SDK response
class LegacyPayResponse {
    public boolean ok;             // success flag
    public String txn;             // transaction id
    public String err;             // error string like "DECLINED", "NETWORK", etc.
}

// Simulated legacy SDK client
class LegacyPayClient {
    // Charges a card and returns a response (throws on catastrophic failures)
    public LegacyPayResponse pay(LegacyPayCharge req) throws LegacyPayException {
        // ... imagine network call here ...
        return new LegacyPayResponse(); // placeholder
    }
}

// Simulated legacy SDK exception
class LegacyPayException extends Exception {
    public LegacyPayException(String message) { super(message); } // pass message to base
}

// ---- Adapter implementation ----

// Concrete adapter that implements your domain interface by delegating to LegacyPayClient
public final class LegacyPayAdapter implements PaymentGateway {
    // The wrapped legacy client instance
    private final LegacyPayClient client;

    // Adapter constructor takes the adaptee dependency
    public LegacyPayAdapter(LegacyPayClient client) {
        this.client = client; // store client for use in charge()
    }

    // Implementation of the domain method, translating to the legacy API
    @Override
    public ChargeResult charge(ChargeRequest request) {
        // Translate domain request to legacy request type
        LegacyPayCharge legacyReq = new LegacyPayCharge(); // create legacy request
        legacyReq.amount = request.amountInMinor;          // map amount
        legacyReq.currencyCode = request.currency;         // map currency
        legacyReq.cardToken = request.paymentMethodToken;  // map payment method
        legacyReq.dedupeKey = request.idempotencyKey;      // map idempotency

        try {
            // Call the adaptee and get its response
            LegacyPayResponse resp = client.pay(legacyReq); // delegate to legacy client

            // Interpret and map the response into domain result
            if (resp.ok) {                                  // check success flag
                return ChargeResult.success(resp.txn);      // on success, return success variant
            }
            // Map legacy error strings to stable domain error codes
            String domainError = mapErrorCode(resp.err);    // translate error code
            return ChargeResult.failure(domainError, resp.err); // return failure with details

        } catch (LegacyPayException ex) {
            // Catastrophic or transport-level failure mapped to retryable error
            return ChargeResult.failure("TRANSIENT_FAILURE", ex.getMessage()); // map exception
        }
    }

    // Helper to map legacy error strings to domain error codes
    private String mapErrorCode(String legacyErr) {
        // Guard for null/unknown
        if (legacyErr == null) return "UNKNOWN";          // default for null
        switch (legacyErr) {                               // choose mapping
            case "DECLINED": return "CARD_DECLINED";     // business decline
            case "INSUFFICIENT": return "INSUFFICIENT_FUNDS"; // insufficient funds
            case "NETWORK": return "TRANSIENT_FAILURE";  // retryable failure
            case "FRAUD": return "SUSPECTED_FRAUD";      // fraud signal
            default: return "UNKNOWN";                    // unknown error
        }
    }
}
        

Notes for production

  • Keep LegacyPayAdapter stateless and thread-safe (no mutable shared state).
  • Place timeouts, circuit breakers, and retries at a higher layer (HTTP/gRPC client or resiliency library), or expose hooks from the adapter for these concerns.
  • Make the adapter discoverable via DI so swapping providers is config-only.

Contract Test Sketch (JUnit 5)

// Verifies that any PaymentGateway implementation behaves by contract
class PaymentGatewayContract {
    // Factory to supply different implementations (e.g., adapters to Stripe, Razorpay)
    PaymentGateway gateway;

    // Success path test
    @org.junit.jupiter.api.Test
    void charge_success_returnsTransactionId() {
        // arrange: create gateway with a fake legacy client returning ok
        // act: call charge
        // assert: success=true and transactionId not null
    }

    // Error mapping test
    @org.junit.jupiter.api.Test
    void charge_declined_mapsToCardDeclined() {
        // arrange: fake returns err="DECLINED"
        // assert: failure with errorCode="CARD_DECLINED"
    }
}
        

Go example (production-flavored, line-by-line commented)

Scenario: Same domain idea in Go. Your service depends on a PaymentGateway interface. You integrate a third‑party SDK with a different method signature and error model.

package payments

import (
    "context"           // context for deadlines/cancellation
    "errors"            // standard error utilities
    "fmt"               // formatting for messages
    // imagine vendor SDK import here: vendor "github.com/vendor/legacy"
)

// Domain-level request type representing a payment charge
type ChargeRequest struct {
    AmountInMinor    int64  // amount in minor units (e.g., paise)
    Currency         string // ISO currency code (e.g., INR)
    IdempotencyKey   string // unique idempotency key provided by caller
    PaymentMethodTok string // payment method token or saved card id
}

// Domain-level result type for a charge operation
type ChargeResult struct {
    Success       bool   // whether the charge succeeded
    TransactionID string // provider-generated transaction id when Success is true
    ErrorCode     string // domain error code when Success is false
    Message       string // human-readable message for logs/ops
}

// Target (your domain) interface that the application depends on
// This is what the rest of your service uses, not the vendor types
 type PaymentGateway interface {
    Charge(ctx context.Context, req ChargeRequest) (ChargeResult, error) // domain-level charge
}

// ---- Adaptee (3rd-party) we cannot change ----

// Imagine a vendor request type with different field names
 type vendorCharge struct {
    Amt         int64  // amount
    Curr        string // currency
    CardTok     string // payment method token
    DedupeKey   string // idempotency key
}

// Imagine a vendor response type
 type vendorResp struct {
    OK   bool   // success flag
    Txn  string // transaction id
    Err  string // error string: "DECLINED", "NETWORK", etc.
}

// Imagine a vendor client with a different API surface
 type vendorClient interface {
    Pay(ctx context.Context, req vendorCharge) (vendorResp, error) // performs network call
}

// ---- Adapter implementation ----

// legacyPayAdapter implements PaymentGateway by delegating to vendorClient
 type legacyPayAdapter struct {
    cli vendorClient // wrapped vendor client instance; assumed goroutine-safe
 }

// NewLegacyPayAdapter constructs an adapter given a vendor client
 func NewLegacyPayAdapter(cli vendorClient) PaymentGateway {
    return &legacyPayAdapter{cli: cli} // return concrete adapter as PaymentGateway
 }

// Charge implements the domain interface using the vendor client's API
 func (a *legacyPayAdapter) Charge(ctx context.Context, req ChargeRequest) (ChargeResult, error) {
    // Translate domain request to vendor request
    vreq := vendorCharge{ // initialize vendor request struct
        Amt:       req.AmountInMinor,     // map amount
        Curr:      req.Currency,          // map currency
        CardTok:   req.PaymentMethodTok,  // map payment method token
        DedupeKey: req.IdempotencyKey,    // map idempotency key
    }

    // Delegate call to vendor client
    vresp, err := a.cli.Pay(ctx, vreq) // invoke adaptee
    if err != nil {                    // check for transport or catastrophic errors
        // Map transport error to a domain-level transient failure
        return ChargeResult{            // construct failure result
            Success:   false,           // mark as failure
            ErrorCode: "TRANSIENT_FAILURE", // domain code indicating retryable error
            Message:   err.Error(),     // include original error message
        }, nil // return no Go error so caller relies on domain result consistently
    }

    // Map vendor response to domain result
    if vresp.OK {                        // check success
        return ChargeResult{             // construct success
            Success:       true,         // success flag
            TransactionID: vresp.Txn,    // map transaction id
        }, nil // no error
    }

    // Map vendor error strings to stable domain error codes
    code := mapVendorErr(vresp.Err)      // translate error code
    return ChargeResult{                 // construct failure result
        Success:   false,                // failure flag
        ErrorCode: code,                 // mapped domain error code
        Message:   vresp.Err,            // original message for logs
    }, nil // no error (domain result carries failure)
 }

// mapVendorErr translates vendor-specific error strings to domain codes
 func mapVendorErr(v string) string {
    switch v {                               // match on vendor error string
    case "DECLINED":
        return "CARD_DECLINED"             // card declined by issuer
    case "INSUFFICIENT":
        return "INSUFFICIENT_FUNDS"        // insufficient funds
    case "NETWORK":
        return "TRANSIENT_FAILURE"         // retryable network/transport failure
    case "FRAUD":
        return "SUSPECTED_FRAUD"           // suspected fraud
    default:
        return "UNKNOWN"                   // unknown error
    }
 }

// Example usage inside a service layer
 func ChargeCustomer(ctx context.Context, pg PaymentGateway, req ChargeRequest) (string, error) {
    // Call the domain interface (decoupled from vendor SDK)
    res, _ := pg.Charge(ctx, req)           // ignore Go error because adapter encodes into result
    if res.Success {                         // on success
        return res.TransactionID, nil        // return transaction id
    }
    // Decide retry policy based on domain error code
    if res.ErrorCode == "TRANSIENT_FAILURE" { // retryable
        return "", fmt.Errorf("temporary failure: %s", res.Message) // bubble up for retry
    }
    // Non-retryable business failure
    return "", fmt.Errorf("charge failed: %s", res.ErrorCode) // return application error
 }
        

Notes for production

  • Keep the adapter goroutine-safe (avoid mutable shared state or guard with mutex if the vendor client isn’t safe).
  • Prefer returning domain results and encode errors inside for consistency (or document a consistent error strategy).
  • Wrap calls with context timeouts and tracing at the call site or inside the vendor client.

Table-driven test sketch (Go)

func TestLegacyPayAdapter_ErrorMapping(t *testing.T) {
    // Arrange: fake vendor client with controllable responses
    // Table: DECLINED -> CARD_DECLINED, NETWORK -> TRANSIENT_FAILURE, etc.
    // Act: call Charge
    // Assert: domain ErrorCode equals expected
}
        

Observability blueprint (drop-in checklist)

  • Tracing: Start a span named adapter.payment.charge with tags: provider, currency, amountMinor, result.
  • Metrics: Histogram adapter_charge_latency_ms by provider & outcome; counter adapter_charge_error_total by domain error.
  • Logging: On failure, log structured fields: provider, domainError, vendorErr, idempotencyKey (hashed), txnId (if any).


Concurrency & Resiliency

  • Thread/goroutine safety: Verify the underlying client’s safety; wrap with mutex if necessary, or use pooling.
  • Timeouts: Enforce per-call timeouts to prevent request pileups.
  • Circuit breaker: Trip on consecutive TRANSIENT_FAILURE to shed load.
  • Idempotency: Preserve the caller’s idempotency key when retried.


Migration playbook (SDK v1 -> v2 or Provider A -> B)

  1. Define/confirm the Target interface (e.g., PaymentGateway).
  2. Build Adapter A (current provider) and Adapter B (new provider).
  3. Route traffic by config/feature flag; start at 1% canary.
  4. Dual-run critical flows for a subset (shadow mode) to compare results.
  5. Cut over gradually; keep roll-back ready by flipping the flag.
  6. Remove Adapter A only after sufficient soak and alerts stay green.


Common pitfalls (and how to avoid them)

  • God Adapter: It starts doing validation, business rules, caching. → Keep it thin; push those concerns to appropriate layers.
  • Leaking vendor types: Domain now depends on SDK types. → Convert at the boundary; never export vendor DTOs.
  • Adapter chaining: Adapter → Adapter → Adapter piles up. → Refactor to a single boundary per provider; use Facade if simplification is needed.
  • Silent error swallowing: Mapping hides actionable info. → Log original vendor codes/messages alongside domain code.
  • Non-deterministic mapping: Random mapping based on text messages. → Maintain a stable lookup table with tests.


Mini FAQ

Q. Can I put retries inside the adapter? A. Only if the operation is idempotent and you have strict backoff limits. Prefer caller-controlled retries so policies are consistent.

Q. Is performance overhead a concern? A. Translation cost is tiny compared to network calls. Avoid unnecessary copies and heavy logs on hot paths.

Q. How is this different from a Facade? A. Facade simplifies a subsystem you own/use; Adapter solves interface mismatch so your domain interface remains stable.

Q. When should I use inheritance (class adapter) in Java? A. Rarely in modern code. Prefer composition (object adapter). Inheritance couples you tightly to the adaptee’s type hierarchy.


Ready-to-use checklist (copy into your PR)

  • Target interface defined and owned by our domain.
  • Adapter is thin: only type/error translation, no business logic.
  • No vendor DTOs/types leak across the boundary.
  • Error mapping table reviewed and tested.
  • Timeouts applied; retry policy documented.
  • Metrics, tracing, and logs added.
  • Contract tests cover success and each error path.
  • Feature-flagged provider selection.
  • Documentation updated for operating runbooks.

Adapter Pattern in Production: Keep your core clean and providers swappable. Build a thin, stateless adapter that maps types, errors, and semantics; add timeouts, tracing, and metrics; and guard with contract tests. Perfect for integrating legacy SDKs, multiple payment gateways, or API version upgrades.


To view or add a comment, sign in

More articles by Suraj Singh

Others also viewed

Explore content categories