Design Pattern: Adapter
In development, sometimes we find that we have rewritten a module, but its interface does not match other modules. Sometimes, we want to use a third-party library, but its interface does not meet our needs.
At this time, it is usually not possible or not wise to change old code or the source code of the third-party library. So, what should we do?
The Adapter Pattern in design patterns is very useful here. It lets us make objects with different interfaces work together.
Let’s look at an example to understand how the Adapter Pattern works. Suppose we have a Chinese
class that only has a speakChinese
method. It can only output Chinese:
// Adaptee: Only speaks Chinese
class Chinese {
public void speakChinese() {
System.out.println("你好世界!");
}
}
But our system needs to output English. What should we do?
The solution is simple—let’s hire a translator to help translate Chinese into English:
// Target interface: We expect to output English
interface EnglishSpeaker {
void speakEnglish();
}
// Adapter: Implements the target interface and holds an instance of the adaptee
class TranslatorAdapter implements EnglishSpeaker {
private Chinese adaptee;
// Pass the adaptee object through the constructor
public TranslatorAdapter(Chinese adaptee) {
this.adaptee = adaptee;
}
@Override
public void speakEnglish() {
// Call the adaptee method
adaptee.speakChinese();
// Add translation logic to meet the target interface
System.out.println("Translated: Hello World!");
}
}
// Client code
public class Main {
public static void main(String[] args) {
Chinese chinese = new Chinese();
EnglishSpeaker adapter = new TranslatorAdapter(chinese);
// For the client, it only knows the EnglishSpeaker interface and calls speakEnglish
// The adapter correctly converts the interface and translates Chinese to English
adapter.speakEnglish();
// 你好世界!
// Translated: Hello World!
}
}
The Adapter Pattern mainly includes these core roles:
- Target: The interface that client code expects. In this example, it is the
EnglishSpeaker
interface. - Adaptee: The class to be adapted. It has the required function, but its interface does not match the target. Here, it is the
Chinese
class. - Adapter: The bridge between the target and the adaptee. It implements the target interface, holds an adaptee instance, and converts calls from the target interface to the adaptee’s methods. In this example, it is the
TranslatorAdapter
class. - Client: Uses the target interface to interact with the adapter.
What we showed above is called an "object adapter". The adapter receives the adaptee object through the constructor, so it can call the adaptee’s methods.
There is also a "class adapter", where the adapter inherits the adaptee class. It can call the adaptee’s methods like this:
class TranslatorAdapter extends Chinese implements EnglishSpeaker {
@Override
public void speakEnglish() {
// Call the adaptee method
super.speakChinese();
System.out.println("Translated: Hello World!");
}
}
The two ways only differ in how to call the adaptee’s method. In essence, they are the same.
Modern software recommends using "composition over inheritance". Also, programming languages like Java do not support multiple inheritance. So, object adapters based on composition are more common than class adapters based on inheritance.
More Scenarios
Power Adapter
In real life, a power adapter is a classic example of the Adapter pattern. Household sockets provide 220V AC, but devices like phones and laptops need different DC voltages (like 5V, 9V, 15V, etc.). The power adapter “translates” 220V AC into the DC voltage required by the device.
Let’s implement a smart power adapter that can provide the right voltage for any device.
First, define the voltage types and data structure:
// Enum: Voltage Type
public enum VoltageType {
AC, // Alternating Current
DC // Direct Current
}
// Data class: Voltage value and type
public record Voltage(int value, VoltageType type) {
@Override
public String toString() {
return String.format("%dV %s", value, type);
}
// Rewrite equals and hashCode for list comparison
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Voltage voltage = (Voltage) o;
return value == voltage.value && type == voltage.type;
}
}
Next, define the Adaptee (Household Socket):
// Adaptee: Household socket
public class HouseholdSocket {
public Voltage supply() {
System.out.println("Household Socket: Provides -> 220V AC");
return new Voltage(220, VoltageType.AC);
}
}
Define the Target interface (Smart Charging Protocol):
// Target: Smart Charging Protocol
public interface SmartChargeTarget {
// Negotiate and provide voltage
Voltage supplyVoltage(List<Voltage> deviceSupportedVoltages);
}
Implement the Adapter (Smart Power Adapter):
// Adapter: Smart Power Adapter
public class SmartPowerAdapter implements SmartChargeTarget {
private final List<Voltage> adapterSupportedVoltages;
private final HouseholdSocket adaptee;
// Constructor: supports different types of adapters
public SmartPowerAdapter(HouseholdSocket adaptee, List<Voltage> supportedVoltages) {
this.adaptee = adaptee;
this.adapterSupportedVoltages = supportedVoltages;
System.out.println("-> Adapter created, supports: " + this.adapterSupportedVoltages);
}
@Override
public Voltage supplyVoltage(List<Voltage> deviceSupportedVoltages) {
Voltage bestMatch = findBestVoltageMatch(deviceSupportedVoltages);
if (bestMatch != null) {
System.out.println("Adapter: Negotiation successful, matched best voltage: " + bestMatch);
return convert(bestMatch);
} else {
System.out.println("Adapter: Negotiation failed, no compatible voltage found.");
return null;
}
}
// Negotiation: Find the highest voltage supported by both device and adapter
private Voltage findBestVoltageMatch(List<Voltage> deviceVoltages) {
for (Voltage deviceVoltage : deviceVoltages) {
if (adapterSupportedVoltages.contains(deviceVoltage)) {
return deviceVoltage;
}
}
return null;
}
private Voltage convert(Voltage dcOutput) {
Voltage acInput = adaptee.supply();
System.out.printf("Adapter: Converts %s to %s\n", acInput, dcOutput);
return dcOutput;
}
}
Client code: different devices Device
charge using the adapter:
class Device {
private final String deviceName;
private final List<Voltage> supportedVoltages;
// Constructor: supports different models and voltages
public Device(String deviceName, List<Voltage> supportedVoltages) {
this.deviceName = deviceName;
this.supportedVoltages = supportedVoltages;
}
public void startCharging(SmartChargeTarget charger) {
Voltage finalVoltage = charger.supplyVoltage(supportedVoltages);
if (finalVoltage != null) {
System.out.printf("%s: ✔ Charging voltage is: %s%n", deviceName, finalVoltage);
} else {
System.out.printf("%s: ❌ Adapter doesn't support any of my charging voltages%n", deviceName);
}
}
}
public class Main {
public static void main(String[] args) {
// 220V household socket
HouseholdSocket socket = new HouseholdSocket();
// Create an adapter that supports 15V, 9V, 5V
SmartPowerAdapter powerfulAdapter = new SmartPowerAdapter(socket, List.of(
new Voltage(15, VoltageType.DC),
new Voltage(9, VoltageType.DC),
new Voltage(5, VoltageType.DC)
));
// Scene 1: Modern phone negotiates best voltage with the smart adapter
Device modernPhone = new Device("iPhone 17", List.of(
new Voltage(15, VoltageType.DC),
new Voltage(9, VoltageType.DC),
new Voltage(5, VoltageType.DC)
));
modernPhone.startCharging(powerfulAdapter);
// Adapter: Negotiation successful, matched best voltage: 15V DC
// Household Socket: Provides -> 220V AC
// Adapter: Converts 220V AC to 15V DC
// iPhone 17: ✔ Charging voltage is: 15V DC
// Scene 2: Old phone uses the smart adapter, gets a low compatible voltage
Device oldPhone = new Device("Nokia", List.of(
// Only supports 5V
new Voltage(5, VoltageType.DC)
));
oldPhone.startCharging(powerfulAdapter);
// Adapter: Negotiation successful, matched best voltage: 5V DC
// Household Socket: Provides -> 220V AC
// Adapter: Converts 220V AC to 5V DC
// Nokia: ✔ Charging voltage is: 5V DC
// Scene 3: Cannot adapt, charging refused
// Create an old adapter that only outputs 5V
SmartPowerAdapter oldAdapter = new SmartPowerAdapter(socket, List.of(
new Voltage(5, VoltageType.DC)
));
// Device needs at least 15V to charge
Device gamingDevice = new Device("Gaming Device", List.of(
new Voltage(15, VoltageType.DC)
));
gamingDevice.startCharging(oldAdapter);
// Adapter: Negotiation failed, no compatible voltage found.
// Gaming Device: ❌ Adapter doesn't support any of my charging voltages.
}
}
This example shows the core idea of the Adapter pattern:
- Target: The
SmartChargeTarget
interface defines the smart charging negotiation protocol. - Adaptee: The
HouseholdSocket
class only provides 220V AC. - Adapter: The
SmartPowerAdapter
class converts 220V AC to the DC voltage needed by the device. - Client: Devices get suitable power from the adapter.
With the adapter, different devices can get the right power from a regular household socket.
Java I/O
The Java I/O library uses the Adapter pattern a lot. InputStream
is for byte streams, and Reader
is for character streams. If you want to read a byte stream as characters (like FileInputStream
), you need an adapter.
InputStreamReader
is that adapter. It takes an InputStream
(adaptee) and implements the Reader
interface (target), converting bytes to characters.
// FileInputStream reads bytes (Adaptee)
FileInputStream fis = new FileInputStream("test.txt");
// InputStreamReader is an adapter, converts bytes to UTF-8 characters (Target)
Reader reader = new InputStreamReader(fis, StandardCharsets.UTF_8);
// Now we can use Reader API to read characters from the file
int character;
while ((character = reader.read()) != -1) {
System.out.print((char) character);
}
reader.close();
In this example:
- Target: The
Reader
interface to read characters. - Adaptee: The
FileInputStream
class for byte streams. - Adapter: The
InputStreamReader
class converts byte stream to character stream.
Summary
Main advantages of the Adapter pattern:
- Makes code flexible and extensible: Client code is decoupled from the details of the adaptee. It's easy to switch adaptees.
- Follows the Single Responsibility Principle: Conversion logic is inside the adapter, and not scattered everywhere.
Main disadvantage:
Increases code complexity: You need to write extra adapter classes and target interfaces. If possible, it's simpler to change the adaptee's interface directly.
In short, when you need to integrate an incompatible component, the Adapter pattern is a powerful and practical tool.