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:
Avoid an Adapter if:
Production concerns (what great adapters look like)
Side-by-side: Adapter vs friends
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
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.
Recommended by LinkedIn
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
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)
Concurrency & Resiliency
Migration playbook (SDK v1 -> v2 or Provider A -> B)
Common pitfalls (and how to avoid them)
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)
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.