Adapter Pattern: Making Incompatible Payment APIs Work Together

java java21 scala scala2 scala3 kotlin design-patterns structural-patterns adapter-pattern

Imagine your checkout service already depends on a modern PaymentGateway interface, but your bank integration still exposes a legacy API with different field names, status codes, and validation rules. Rewriting every client is risky, expensive, and usually not an option during a migration.

The Adapter pattern solves this by translating between the interface your application expects and the one your legacy or third-party system actually provides.

The Problem: New Checkout, Old Banking API

In this example, the application expects a clean charge(PaymentRequest) contract, while the legacy bank API expects submitPayment(clientCode, minorUnits, isoCurrency) and returns status codes like "00" and "14".

Without an adapter, that translation leaks into checkout code everywhere.

Key Concepts

Concept In this example Why it matters
Target interface PaymentGateway Keeps checkout code stable
Adaptee LegacyBankApi Existing dependency we cannot easily change
Adapter LegacyBankPaymentAdapter Maps request/response and normalizes validation
Client CheckoutService Depends only on the target interface

Real Use Case: Checkout Migration Without Downtime

Suppose your team is modernizing an e-commerce platform. The checkout service is new and clean, but payment settlement is still handled by a legacy bank integration shared by multiple systems. You cannot replace that legacy API immediately because:

  1. It is already audited and certified in production.
  2. Other teams still depend on it.
  3. Replacing it would require a risky, big-bang migration.

The adapter gives you a safe middle path. Checkout keeps using a modern PaymentGateway contract, while the adapter translates every request and response to the legacy format. When you later swap the bank integration, you only replace the adapter internals, not every checkout caller.

Component Walkthrough: What Each Part Is Doing

The key concepts table identifies the pieces; here is their operational role in this concrete implementation:

  1. PaymentGateway (Target Interface) defines exactly what checkout needs: charge(PaymentRequest). It protects the rest of the application from legacy protocol details.
  2. PaymentRequest and PaymentResult (Application DTOs) model business-level input/output, not bank-specific data. They are the language your domain code understands.
  3. LegacyBankApi (Adaptee) represents an external interface with historical constraints: parameter names like clientCode and status codes like "00" or "14".
  4. LegacyBankPaymentAdapter (Translator + Guardrail) validates input, normalizes currency, maps request fields to legacy parameters, interprets legacy status codes, and maps them back to a domain-friendly result.
  5. CheckoutService (Client) depends only on PaymentGateway, so it never needs to know which bank provider or protocol is behind the scenes.
  6. Tests (Executable Contract) lock the expected behavior: approved payments stay approved, legacy rejections are mapped clearly, and invalid input fails fast before hitting the legacy API.

Request Flow: End-to-End in This Example

  1. Checkout calls charge(PaymentRequest(customerId, amountInCents, currency)).
  2. Adapter validates business rules (customerId not blank, amount positive, currency present).
  3. Adapter normalizes and translates fields to submitPayment(clientCode, minorUnits, isoCurrency).
  4. Legacy API returns bank-centric response (statusCode, reference, detail).
  5. Adapter maps it to PaymentResult(approved, transactionId, message) for checkout.

The Solution: Adapter Across JVM Languages

public final class LegacyBankPaymentAdapter implements PaymentGateway {
    private final LegacyBankApi legacyBankApi;
    @Override
    public PaymentResult charge(PaymentRequest request) {
        String currency = request.currency().trim().toUpperCase(Locale.ROOT);
        LegacyBankResponse response = legacyBankApi.submitPayment(
            request.customerId(), request.amountInCents(), currency);
        boolean approved = "00".equals(response.statusCode());
        return new PaymentResult(approved, response.reference(),
            approved ? "Payment approved" : response.detail());
    }
}

View in repository

class LegacyBankPaymentAdapter(
    private val legacyBankApi: LegacyBankApi,
) : PaymentGateway {
    override fun charge(request: PaymentRequest): PaymentResult {
        val currency = request.currency.trim().uppercase(Locale.ROOT)
        val response = legacyBankApi.submitPayment(
            request.customerId, request.amountInCents.toLong(), currency)
        val approved = response.statusCode == "00"
        return PaymentResult(approved, response.reference,
            if (approved) "Payment approved" else response.detail)
    }
}

View in repository

class LegacyBankPaymentAdapter(legacyBankApi: LegacyBankApi) extends PaymentGateway {
  override def charge(request: PaymentRequest): PaymentResult = {
    val currency = normalizeCurrency(request.currency)
    val response = legacyBankApi.submitPayment(
      request.customerId, request.amountInCents, currency)
    val approved = response.statusCode == "00"
    PaymentResult(approved, response.reference,
      if (approved) "Payment approved" else response.detail)
  }
}

View in repository

class LegacyBankPaymentAdapter(legacyBankApi: LegacyBankApi) extends PaymentGateway:
  override def charge(request: PaymentRequest): PaymentResult =
    val currency = normalizeCurrency(request.currency)
    val response = legacyBankApi.submitPayment(
      request.customerId, request.amountInCents, currency)
    val approved = response.statusCode == "00"
    PaymentResult(approved, response.reference,
      if approved then "Payment approved" else response.detail)

View in repository

Scala Developer Mental Model

  • In Java 21, Adapter often appears as an explicit class implementing the target interface and wrapping a legacy dependency.
  • In Scala 2/3, this translation layer is still explicit, but ADTs and concise case classes reduce ceremony around request and response mapping.
  • In Kotlin, data classes plus concise null/validation handling make object adapters clean and readable.

Comparison: Java 21 vs Scala 2 vs Scala 3 vs Kotlin

Language Adapter shape Validation style Mapping clarity
Java 21 class ... implements PaymentGateway Guards + exceptions Explicit and verbose
Scala 2 class ... extends PaymentGateway Option + exceptions Compact, expressive
Scala 3 Same as Scala 2 with indentation syntax Option + exceptions Compact and modern
Kotlin class ... : PaymentGateway require(...) Very concise

Testing the Adapter with a Real Checkout Scenario

The tests prove practical behavior: checkout approval, legacy rejection mapping, and fail-fast validation.

@Test
void shouldApproveCheckoutPayment() {
    CheckoutService service = new CheckoutService(paymentGateway);
    String confirmation = service.checkout("cust-42", 1599, "eur");
    assertTrue(confirmation.startsWith("CONFIRMED:TX-CUST-42-1599"));
}

@Test
void shouldRejectUnsupportedCurrency() {
    PaymentResult result = paymentGateway.charge(
        new PaymentRequest("cust-42", 1599, "pln"));
    assertEquals("Unsupported currency: PLN", result.message());
}

View full test file

@Test
fun shouldApproveCheckoutPayment() {
    val service = CheckoutService(paymentGateway)
    val confirmation = service.checkout("cust-42", 1599, "eur")
    assertTrue(confirmation.startsWith("CONFIRMED:TX-CUST-42-1599"))
}

@Test
fun shouldRejectUnsupportedCurrency() {
    val result = paymentGateway.charge(PaymentRequest("cust-42", 1599, "pln"))
    assertEquals("Unsupported currency: PLN", result.message)
}

View full test file

test("Adapter should approve checkout payment through legacy bank API") {
  val service = new CheckoutService(paymentGateway)
  val confirmation = service.checkout("cust-42", 1599, "eur")
  confirmation should startWith ("CONFIRMED:TX-CUST-42-1599")
}

test("Adapter should reject unsupported currencies from legacy API") {
  paymentGateway.charge(PaymentRequest("cust-42", 1599, "pln")).message shouldBe
    "Unsupported currency: PLN"
}

View full test file

test("Adapter should approve checkout payment through legacy bank API"):
  val service = CheckoutService(paymentGateway)
  val confirmation = service.checkout("cust-42", 1599, "eur")
  confirmation should startWith ("CONFIRMED:TX-CUST-42-1599")

test("Adapter should reject unsupported currencies from legacy API"):
  paymentGateway.charge(PaymentRequest("cust-42", 1599, "pln")).message shouldBe
    "Unsupported currency: PLN"

View full test file

When to Use Adapter Pattern

Use an adapter when:

  1. You must integrate legacy or third-party APIs you cannot change.
  2. Your core application interface is cleaner than the external dependency’s model.
  3. You want migration logic in one place instead of scattered across services.
  4. You need to preserve backward compatibility during phased rewrites.

Avoid it when both interfaces are already under your control and can be unified directly.

Code Samples

All examples in this post are available in the repository:

Implementation files:

Test files:


This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.