Category: Structural
The problem
Two pieces of code need to talk to each other, but their interfaces don't match: different method names, different parameter shapes, different error-handling conventions. The two most common reasons this happens are (a) one side is a legacy or third-party API you can't change, and (b) the "new" side was designed without knowing about the old one. Rewriting the legacy side is often not an option — it might be a mainframe system, a vendor SDK, or just code with too much blast radius to touch.
The solution
Introduce a thin wrapper that implements the interface the client expects, and translates each call into whatever the adaptee actually understands.
classDiagram
class Target {
<<interface>>
}
class Adapter {
}
class Adaptee {
}
Target <|.. Adapter
Adapter --> Adaptee : delegates to
Client --> Target
Classic example
classic/EnumerationIteratorAdapter
is the canonical Java example of this pattern: it adapts the pre-Java-2 Enumeration
contract (hasMoreElements() / nextElement()) to the modern Iterator contract
(hasNext() / next()), so code written against Iterator — for-each loops, streams — can
consume anything that only exposes an Enumeration. This is exactly what
Collections.enumeration()'s counterpart solves in the JDK itself.
EnumerationIteratorAdapterTest
walks a wrapped enumeration end to end and checks it throws NoSuchElementException once
exhausted, same as any other Iterator.
Applied example: fronting a mainframe account system
applied/MainframeAccountGateway
stands in for a real mainframe/COBOL account system: fixed-width positional records
(ACCOUNT[10] + NAME[25] + BALANCE_CENTS[10] + STATUS[1]) and a checked exception on
failure — the kind of interface you actually get modernizing a decades-old core banking
system, not a hypothetical one.
applied/MainframeAccountLookupAdapter
exposes that gateway behind the modern AccountLookupPort
contract that new microservices code depends on. New code never parses a fixed-width string
or catches a checked MainframeUnavailableException — the adapter absorbs both, translating
the legacy checked exception into an unchecked AccountLookupException at the boundary. This
is the same shape as fronting a real mainframe during a modernization effort: the legacy
system doesn't change, but nothing downstream of the adapter has to know it exists.
MainframeAccountLookupAdapterTest
covers record parsing, the "unknown account" sentinel record, and the exception translation.
When not to use it
- If you control both sides of the interface and they're just inconsistent by accident, fix the inconsistency instead of adapting around it — an adapter should bridge two things that each have a legitimate reason to look the way they do.
- Don't let adapters accumulate business logic. An adapter's job is translation, not validation or decision-making — if it starts doing either, that logic belongs one layer up.
- If you're adapting the same interface in many unrelated places, consider whether a proper anti-corruption layer (a small internal module, not just one class) is a better fit than scattering adapters through the codebase.
Test coverage
100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:
./gradlew :structural:adapter:jacocoTestReport
Report at structural/adapter/build/reports/jacoco/test/html/index.html.
Further reading
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — Chapter 4 formalizes Adapter (both the object and class variants).
- Meyer, B. (1988). Object-Oriented Software Construction. Prentice Hall. — introduces the Open-Closed Principle; an adapter is a direct application of it, extending compatibility with a new interface without modifying either the client or the adaptee.
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley. — introduces the Anti-Corruption Layer, the module-level generalization of what a single Adapter does at the class level; referenced directly in "When not to use it" above.
Unit tests
src/test/java/com/designpatterns/structural/adapter/classic/EnumerationIteratorAdapterTest.java
package com.designpatterns.structural.adapter.classic;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EnumerationIteratorAdapterTest {
@Test
void walksEveryElementOfTheAdaptedEnumeration() {
Enumeration<String> legacyEnumeration = Collections.enumeration(List.of("PIX", "TED", "BOLETO"));
Iterator<String> iterator = new EnumerationIteratorAdapter<>(legacyEnumeration);
assertThat(iterator).toIterable().containsExactly("PIX", "TED", "BOLETO");
}
@Test
void throwsNoSuchElementExceptionOnceExhausted() {
Enumeration<String> emptyEnumeration = Collections.enumeration(List.of());
Iterator<String> iterator = new EnumerationIteratorAdapter<>(emptyEnumeration);
assertThat(iterator.hasNext()).isFalse();
assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
}
}
src/test/java/com/designpatterns/structural/adapter/applied/MainframeAccountLookupAdapterTest.java
package com.designpatterns.structural.adapter.applied;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MainframeAccountLookupAdapterTest {
private static final String ACCOUNT_NUMBER = "1234567";
private static final String PADDED_ACCOUNT_NUMBER = "0001234567";
@Test
void parsesTheLegacyFixedWidthRecordIntoAModernSnapshot() {
String record = PADDED_ACCOUNT_NUMBER + leftJustify("JOAO DA SILVA", 25) + "0000015000" + "A";
MainframeAccountGateway gateway = new MainframeAccountGateway(Map.of(PADDED_ACCOUNT_NUMBER, record));
MainframeAccountLookupAdapter adapter = new MainframeAccountLookupAdapter(gateway);
AccountSnapshot snapshot = adapter.findByAccountNumber(ACCOUNT_NUMBER);
assertThat(snapshot.accountNumber()).isEqualTo(PADDED_ACCOUNT_NUMBER);
assertThat(snapshot.holderName()).isEqualTo("JOAO DA SILVA");
assertThat(snapshot.balanceCents()).isEqualTo(15_000L);
assertThat(snapshot.active()).isTrue();
}
@Test
void returnsAnInactiveZeroBalanceSnapshotWhenTheAccountIsUnknown() {
MainframeAccountGateway gateway = new MainframeAccountGateway(Map.of());
MainframeAccountLookupAdapter adapter = new MainframeAccountLookupAdapter(gateway);
AccountSnapshot snapshot = adapter.findByAccountNumber(ACCOUNT_NUMBER);
assertThat(snapshot.balanceCents()).isZero();
assertThat(snapshot.active()).isFalse();
}
@Test
void translatesTheLegacyCheckedExceptionIntoAnUncheckedLookupException() {
MainframeAccountGateway gateway = new MainframeAccountGateway(Map.of());
MainframeAccountLookupAdapter adapter = new MainframeAccountLookupAdapter(gateway);
assertThatThrownBy(() -> adapter.findByAccountNumber("9999999999"))
.isInstanceOf(AccountLookupException.class)
.hasCauseInstanceOf(MainframeUnavailableException.class);
}
private static String leftJustify(String value, int width) {
return String.format("%-" + width + "s", value);
}
}