Adapter Pattern for Compatibility in Java: Bridging Incompatible Interfaces

Article

The Adapter Pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate. It acts as a bridge between two incompatible interfaces, converting the interface of a class into another interface that clients expect. This pattern is particularly useful when integrating legacy code, third-party libraries, or systems with different interfaces.


Understanding the Adapter Pattern

When to Use the Adapter Pattern:

  • Legacy Integration: Integrating old systems with new interfaces
  • Third-party Libraries: Making incompatible libraries work together
  • Interface Standardization: Creating consistent interfaces across different implementations
  • Testing: Creating test adapters for external dependencies

Adapter Pattern Components:

  1. Target: The interface that clients expect
  2. Adaptee: The existing interface that needs adapting
  3. Adapter: The class that implements the Target interface and wraps the Adaptee

Adapter Pattern Implementations

1. Object Adapter (Composition-based)

Scenario: Integrating a legacy payment system with a modern payment interface

// Target Interface - Modern Payment System
public interface ModernPayment {
void processPayment(String customerId, double amount);
boolean validatePayment(String customerId);
String getPaymentStatus(String transactionId);
}
// Adaptee - Legacy Payment System (incompatible interface)
public class LegacyPaymentSystem {
public void makePayment(int customerCode, BigDecimal paymentAmount) {
System.out.println("Processing legacy payment: Customer " + 
customerCode + ", Amount: " + paymentAmount);
}
public boolean checkCustomer(int customerCode) {
System.out.println("Checking legacy customer: " + customerCode);
return customerCode > 0;
}
public String getTransactionStatus(int transactionNumber) {
return "COMPLETED"; // Simplified
}
// Legacy method that we don't want to expose
public void legacyInternalMethod() {
System.out.println("Internal legacy method");
}
}
// Adapter - Bridges ModernPayment and LegacyPaymentSystem
public class LegacyPaymentAdapter implements ModernPayment {
private final LegacyPaymentSystem legacyPaymentSystem;
public LegacyPaymentAdapter(LegacyPaymentSystem legacyPaymentSystem) {
this.legacyPaymentSystem = legacyPaymentSystem;
}
@Override
public void processPayment(String customerId, double amount) {
// Convert String customerId to int customerCode
int customerCode = Integer.parseInt(customerId);
// Convert double amount to BigDecimal
BigDecimal paymentAmount = BigDecimal.valueOf(amount);
legacyPaymentSystem.makePayment(customerCode, paymentAmount);
}
@Override
public boolean validatePayment(String customerId) {
int customerCode = Integer.parseInt(customerId);
return legacyPaymentSystem.checkCustomer(customerCode);
}
@Override
public String getPaymentStatus(String transactionId) {
int transactionNumber = Integer.parseInt(transactionId);
return legacyPaymentSystem.getTransactionStatus(transactionNumber);
}
// Additional adapter methods can provide enhanced functionality
public void processPaymentWithRetry(String customerId, double amount, int retries) {
for (int i = 0; i < retries; i++) {
try {
processPayment(customerId, amount);
return;
} catch (Exception e) {
System.out.println("Payment failed, retry " + (i + 1));
if (i == retries - 1) throw e;
}
}
}
}

2. Class Adapter (Inheritance-based)

Scenario: Adapting different logging systems

// Target Interface
public interface ModernLogger {
void debug(String message);
void info(String message);
void error(String message, Throwable throwable);
void warn(String message);
}
// Adaptee - Legacy Logger
public class LegacyLogger {
public void logDebug(String message) {
System.out.println("[DEBUG] " + message);
}
public void logInfo(String message) {
System.out.println("[INFO] " + message);
}
public void logError(String message) {
System.out.println("[ERROR] " + message);
}
public void logWarning(String message) {
System.out.println("[WARN] " + message);
}
}
// Class Adapter (using inheritance)
public class LegacyLoggerAdapter extends LegacyLogger implements ModernLogger {
@Override
public void debug(String message) {
logDebug(message);
}
@Override
public void info(String message) {
logInfo(message);
}
@Override
public void error(String message, Throwable throwable) {
logError(message + (throwable != null ? ": " + throwable.getMessage() : ""));
}
@Override
public void warn(String message) {
logWarning(message);
}
// Enhanced functionality
public void debug(String message, Object... args) {
logDebug(String.format(message, args));
}
}

Real-World Examples

1. File Format Adapter

Scenario: Adapting different file format readers to a unified interface

// Target Interface
public interface FileReader {
List<String> readLines();
String readAll();
Map<String, Object> getMetadata();
boolean supports(String fileExtension);
}
// Adaptee 1 - CSV Reader
public class CsvReader {
private final String filePath;
public CsvReader(String filePath) {
this.filePath = filePath;
}
public List<String[]> readCsvData() throws IOException {
List<String[]> data = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
String line;
while ((line = br.readLine()) != null) {
data.add(line.split(","));
}
}
return data;
}
public boolean isCsvFile() {
return filePath.toLowerCase().endsWith(".csv");
}
}
// Adaptee 2 - JSON Reader
public class JsonReader {
private final String filePath;
public JsonReader(String filePath) {
this.filePath = filePath;
}
public JsonNode parseJson() throws IOException {
ObjectMapper mapper = new ObjectMapper();
return mapper.readTree(new File(filePath));
}
public boolean isJsonFile() {
return filePath.toLowerCase().endsWith(".json");
}
}
// CSV Adapter
public class CsvFileAdapter implements FileReader {
private final CsvReader csvReader;
public CsvFileAdapter(String filePath) {
this.csvReader = new CsvReader(filePath);
}
@Override
public List<String> readLines() {
try {
List<String[]> csvData = csvReader.readCsvData();
return csvData.stream()
.map(row -> String.join(" | ", row))
.collect(Collectors.toList());
} catch (IOException e) {
throw new RuntimeException("Failed to read CSV file", e);
}
}
@Override
public String readAll() {
List<String> lines = readLines();
return String.join("\n", lines);
}
@Override
public Map<String, Object> getMetadata() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("format", "CSV");
metadata.put("supports", "Tabular data");
return metadata;
}
@Override
public boolean supports(String fileExtension) {
return "csv".equalsIgnoreCase(fileExtension);
}
}
// JSON Adapter
public class JsonFileAdapter implements FileReader {
private final JsonReader jsonReader;
public JsonFileAdapter(String filePath) {
this.jsonReader = new JsonReader(filePath);
}
@Override
public List<String> readLines() {
try {
JsonNode root = jsonReader.parseJson();
List<String> lines = new ArrayList<>();
root.fieldNames().forEachRemaining(fieldName -> {
lines.add(fieldName + ": " + root.get(fieldName).asText());
});
return lines;
} catch (IOException e) {
throw new RuntimeException("Failed to read JSON file", e);
}
}
@Override
public String readAll() {
try {
return jsonReader.parseJson().toPrettyString();
} catch (IOException e) {
throw new RuntimeException("Failed to read JSON file", e);
}
}
@Override
public Map<String, Object> getMetadata() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("format", "JSON");
metadata.put("supports", "Structured data");
return metadata;
}
@Override
public boolean supports(String fileExtension) {
return "json".equalsIgnoreCase(fileExtension);
}
}

2. Database Adapter

Scenario: Adapting different database clients to a unified repository interface

// Target Interface
public interface UserRepository {
void save(User user);
User findById(String id);
List<User> findAll();
void delete(String id);
}
// Adaptee 1 - MongoDB Client
public class MongoUserClient {
private final MongoCollection<Document> collection;
public MongoUserClient(MongoDatabase database) {
this.collection = database.getCollection("users");
}
public void insertUser(Document userDoc) {
collection.insertOne(userDoc);
}
public Document findUserById(String objectId) {
return collection.find(new Document("_id", new ObjectId(objectId))).first();
}
public List<Document> findAllUsers() {
return collection.find().into(new ArrayList<>());
}
public void removeUser(String objectId) {
collection.deleteOne(new Document("_id", new ObjectId(objectId)));
}
}
// Adaptee 2 - SQL Database Client
public class SqlUserClient {
private final Connection connection;
public SqlUserClient(Connection connection) {
this.connection = connection;
}
public void createUser(String sql, Object... params) throws SQLException {
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
stmt.executeUpdate();
}
}
public ResultSet queryUser(String sql, Object... params) throws SQLException {
PreparedStatement stmt = connection.prepareStatement(sql);
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
return stmt.executeQuery();
}
public void deleteUser(String sql, Object... params) throws SQLException {
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
stmt.executeUpdate();
}
}
}
// MongoDB Adapter
public class MongoUserRepository implements UserRepository {
private final MongoUserClient mongoClient;
private final ObjectMapper objectMapper;
public MongoUserRepository(MongoUserClient mongoClient) {
this.mongoClient = mongoClient;
this.objectMapper = new ObjectMapper();
}
@Override
public void save(User user) {
try {
String json = objectMapper.writeValueAsString(user);
Document doc = Document.parse(json);
mongoClient.insertUser(doc);
} catch (Exception e) {
throw new RuntimeException("Failed to save user to MongoDB", e);
}
}
@Override
public User findById(String id) {
try {
Document doc = mongoClient.findUserById(id);
if (doc != null) {
return objectMapper.readValue(doc.toJson(), User.class);
}
return null;
} catch (Exception e) {
throw new RuntimeException("Failed to find user in MongoDB", e);
}
}
@Override
public List<User> findAll() {
try {
return mongoClient.findAllUsers().stream()
.map(doc -> {
try {
return objectMapper.readValue(doc.toJson(), User.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
} catch (Exception e) {
throw new RuntimeException("Failed to find all users in MongoDB", e);
}
}
@Override
public void delete(String id) {
mongoClient.removeUser(id);
}
}
// SQL Adapter
public class SqlUserRepository implements UserRepository {
private final SqlUserClient sqlClient;
public SqlUserRepository(SqlUserClient sqlClient) {
this.sqlClient = sqlClient;
}
@Override
public void save(User user) {
try {
String sql = "INSERT INTO users (id, name, email) VALUES (?, ?, ?)";
sqlClient.createUser(sql, user.getId(), user.getName(), user.getEmail());
} catch (SQLException e) {
throw new RuntimeException("Failed to save user to SQL database", e);
}
}
@Override
public User findById(String id) {
try {
String sql = "SELECT * FROM users WHERE id = ?";
ResultSet rs = sqlClient.queryUser(sql, id);
if (rs.next()) {
return new User(
rs.getString("id"),
rs.getString("name"),
rs.getString("email")
);
}
return null;
} catch (SQLException e) {
throw new RuntimeException("Failed to find user in SQL database", e);
}
}
@Override
public List<User> findAll() {
try {
String sql = "SELECT * FROM users";
ResultSet rs = sqlClient.queryUser(sql);
List<User> users = new ArrayList<>();
while (rs.next()) {
users.add(new User(
rs.getString("id"),
rs.getString("name"),
rs.getString("email")
));
}
return users;
} catch (SQLException e) {
throw new RuntimeException("Failed to find all users in SQL database", e);
}
}
@Override
public void delete(String id) {
try {
String sql = "DELETE FROM users WHERE id = ?";
sqlClient.deleteUser(sql, id);
} catch (SQLException e) {
throw new RuntimeException("Failed to delete user from SQL database", e);
}
}
}

Advanced Adapter Patterns

1. Two-Way Adapter

Scenario: When you need bidirectional compatibility between two systems

// Interface A
public interface NewSystem {
void newProcess(String data);
String newAnalysis();
}
// Interface B
public interface OldSystem {
void legacyProcess(int code, String information);
String legacyReport();
}
// Two-Way Adapter
public class BidirectionalAdapter implements NewSystem, OldSystem {
private final NewSystem newSystem;
private final OldSystem oldSystem;
public BidirectionalAdapter(NewSystem newSystem, OldSystem oldSystem) {
this.newSystem = newSystem;
this.oldSystem = oldSystem;
}
// NewSystem implementation using OldSystem
@Override
public void newProcess(String data) {
// Convert new format to old format
int code = data.hashCode();
oldSystem.legacyProcess(code, data);
}
@Override
public String newAnalysis() {
String legacyReport = oldSystem.legacyReport();
// Convert old format to new format
return "Analysis: " + legacyReport;
}
// OldSystem implementation using NewSystem
@Override
public void legacyProcess(int code, String information) {
// Convert old format to new format
String combinedData = code + ":" + information;
newSystem.newProcess(combinedData);
}
@Override
public String legacyReport() {
String newAnalysis = newSystem.newAnalysis();
// Convert new format to old format
return "Legacy: " + newAnalysis;
}
}

2. Pluggable Adapters

Scenario: Creating flexible adapters that can work with multiple adaptees

// Generic Target Interface
public interface NotificationSender {
void send(String recipient, String message);
boolean supports(String channelType);
}
// Pluggable Adapter
public class UniversalNotificationAdapter implements NotificationSender {
private final Map<String, NotificationService> services;
public UniversalNotificationAdapter() {
this.services = new HashMap<>();
}
public void registerService(String channelType, NotificationService service) {
services.put(channelType, service);
}
@Override
public void send(String recipient, String message) {
// Determine channel type from recipient
String channelType = determineChannelType(recipient);
NotificationService service = services.get(channelType);
if (service != null) {
service.dispatch(recipient, message);
} else {
throw new IllegalArgumentException("Unsupported channel type: " + channelType);
}
}
@Override
public boolean supports(String channelType) {
return services.containsKey(channelType);
}
private String determineChannelType(String recipient) {
if (recipient.contains("@")) return "email";
if (recipient.matches("\\+?[\\d\\s-]+")) return "sms";
if (recipient.startsWith("@")) return "slack";
return "unknown";
}
}
// Adaptee Interface
public interface NotificationService {
void dispatch(String target, String content);
}
// Concrete Adaptees
public class EmailService implements NotificationService {
@Override
public void dispatch(String target, String content) {
System.out.println("Sending email to: " + target + " - " + content);
}
}
public class SmsService implements NotificationService {
@Override
public void dispatch(String target, String content) {
System.out.println("Sending SMS to: " + target + " - " + content);
}
}
public class SlackService implements NotificationService {
@Override
public void dispatch(String target, String content) {
System.out.println("Sending Slack message to: " + target + " - " + content);
}
}

Testing Adapters

Adapter Testing Strategy

// Test for Payment Adapter
public class LegacyPaymentAdapterTest {
@Test
public void testProcessPayment() {
// Given
LegacyPaymentSystem legacySystem = mock(LegacyPaymentSystem.class);
LegacyPaymentAdapter adapter = new LegacyPaymentAdapter(legacySystem);
// When
adapter.processPayment("123", 100.50);
// Then
verify(legacySystem).makePayment(123, BigDecimal.valueOf(100.50));
}
@Test
public void testValidatePayment() {
// Given
LegacyPaymentSystem legacySystem = mock(LegacyPaymentSystem.class);
when(legacySystem.checkCustomer(456)).thenReturn(true);
LegacyPaymentAdapter adapter = new LegacyPaymentAdapter(legacySystem);
// When
boolean isValid = adapter.validatePayment("456");
// Then
assertTrue(isValid);
verify(legacySystem).checkCustomer(456);
}
}
// Mock for testing
public class MockLegacyPaymentSystem extends LegacyPaymentSystem {
private List<String> operations = new ArrayList<>();
@Override
public void makePayment(int customerCode, BigDecimal paymentAmount) {
operations.add("makePayment:" + customerCode + ":" + paymentAmount);
}
@Override
public boolean checkCustomer(int customerCode) {
operations.add("checkCustomer:" + customerCode);
return customerCode > 0;
}
public List<String> getOperations() {
return operations;
}
}

Best Practices

  1. Prefer Object Adapters: Use composition over inheritance for more flexibility
  2. Single Responsibility: Each adapter should handle one specific adaptation
  3. Error Handling: Provide meaningful error messages when adaptation fails
  4. Document Assumptions: Clearly document any assumptions made during adaptation
  5. Test Thoroughly: Adapters can hide compatibility issues
  6. Consider Performance: Be aware of any performance overhead from adaptation
  7. Use Factory Methods: Create adapters through factory methods for better control

Conclusion

The Adapter Pattern is a powerful tool for achieving compatibility between systems with incompatible interfaces. Whether you're integrating legacy systems, working with third-party libraries, or creating unified interfaces across different implementations, adapters provide a clean, maintainable solution. By understanding the different types of adapters (object vs. class) and applying best practices, you can effectively bridge interface gaps while keeping your codebase clean and extensible. The pattern's true power lies in its ability to enable communication between systems that weren't designed to work together, making it an essential tool in any Java developer's arsenal.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper