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:
- It is already audited and certified in production.
- Other teams still depend on it.
- 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:
PaymentGateway(Target Interface) defines exactly what checkout needs:charge(PaymentRequest). It protects the rest of the application from legacy protocol details.PaymentRequestandPaymentResult(Application DTOs) model business-level input/output, not bank-specific data. They are the language your domain code understands.LegacyBankApi(Adaptee) represents an external interface with historical constraints: parameter names likeclientCodeand status codes like"00"or"14".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.CheckoutService(Client) depends only onPaymentGateway, so it never needs to know which bank provider or protocol is behind the scenes.- 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
- Checkout calls
charge(PaymentRequest(customerId, amountInCents, currency)). - Adapter validates business rules (
customerIdnot blank, amount positive, currency present). - Adapter normalizes and translates fields to
submitPayment(clientCode, minorUnits, isoCurrency). - Legacy API returns bank-centric response (
statusCode,reference,detail). - 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());
}
}
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)
}
}
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)
}
}
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)
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());
}
@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)
}
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"
}
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"
When to Use Adapter Pattern
Use an adapter when:
- You must integrate legacy or third-party APIs you cannot change.
- Your core application interface is cleaner than the external dependency’s model.
- You want migration logic in one place instead of scattered across services.
- 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:
- Java 21: LegacyBankPaymentAdapter.java
- Kotlin: LegacyBankPaymentAdapter.kt
- Scala 2: LegacyBankPaymentAdapter.scala
- Scala 3: LegacyBankPaymentAdapter.scala
Test files:
- Java 21: LegacyBankPaymentAdapterTest.java
- Kotlin: LegacyBankPaymentAdapterTest.kt
- Scala 2: LegacyBankPaymentAdapterTest.scala
- Scala 3: LegacyBankPaymentAdapterTest.scala
This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.