Ch 17.4 Structural Patterns (Decorator, Adapter, Proxy)
1. Decorator — Dynamically Adding Functionality
"Adds functionality to an existing object dynamically, without modifying it." More flexible than inheritance for composing behaviors. Java's BufferedReader and Collections.unmodifiableList() are canonical examples.
// Coffee ordering system
interface Coffee {
String getDescription();
int getCost();
}
// Base coffee
class SimpleCoffee implements Coffee {
@Override public String getDescription() { return "Americano"; }
@Override public int getCost() { return 3000; }
}
// Abstract decorator
abstract class CoffeeDecorator implements Coffee {
protected final Coffee coffee;
CoffeeDecorator(Coffee coffee) { this.coffee = coffee; }
}
// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
MilkDecorator(Coffee coffee) { super(coffee); }
@Override public String getDescription() { return coffee.getDescription() + " + Milk"; }
@Override public int getCost() { return coffee.getCost() + 500; }
}
class SyrupDecorator extends CoffeeDecorator {
private final String flavor;
SyrupDecorator(Coffee coffee, String flavor) {
super(coffee);
this.flavor = flavor;
}
@Override public String getDescription() { return coffee.getDescription() + " + " + flavor + " Syrup"; }
@Override public int getCost() { return coffee.getCost() + 300; }
}
class WhipDecorator extends CoffeeDecorator {
WhipDecorator(Coffee coffee) { super(coffee); }
@Override public String getDescription() { return coffee.getDescription() + " + Whipped Cream"; }
@Override public int getCost() { return coffee.getCost() + 700; }
}
// Usage — stack decorators like layers
Coffee order1 = new SimpleCoffee();
System.out.printf("%s: %,d%n", order1.getDescription(), order1.getCost());
// Americano: 3,000
Coffee order2 = new WhipDecorator(new MilkDecorator(new SimpleCoffee()));
System.out.printf("%s: %,d%n", order2.getDescription(), order2.getCost());
// Americano + Milk + Whipped Cream: 4,200
Coffee order3 = new SyrupDecorator(
new WhipDecorator(
new MilkDecorator(new SimpleCoffee())
), "Vanilla"
);
System.out.printf("%s: %,d%n", order3.getDescription(), order3.getCost());
// Americano + Milk + Whipped Cream + Vanilla Syrup: 4,500
2. Adapter — Bridging Incompatible Interfaces
"Converts one interface into another that clients expect." Like a travel power adapter — it lets two incompatible systems work together without modifying either.
// Legacy payment system (existing API, cannot be modified)
class LegacyPaymentSystem {
public String doTransaction(String userId, double amount) {
return String.format("LEGACY_OK: user=%s, amount=%.0f", userId, amount);
}
public boolean checkBalance(String userId) {
return true; // assume sufficient balance
}
}
// Interface expected by new code
interface ModernPaymentGateway {
PaymentResult processPayment(Payment payment);
}
record Payment(String userId, int amount, String currency) {}
record PaymentResult(boolean success, String transactionId, String message) {}
// Adapter: wraps LegacyPaymentSystem to implement ModernPaymentGateway
class LegacyPaymentAdapter implements ModernPaymentGateway {
private final LegacyPaymentSystem legacy;
LegacyPaymentAdapter(LegacyPaymentSystem legacy) {
this.legacy = legacy;
}
@Override
public PaymentResult processPayment(Payment payment) {
// Convert from new format to legacy format
if (!legacy.checkBalance(payment.userId())) {
return new PaymentResult(false, null, "Insufficient balance");
}
double amount = convertToBase(payment.amount(), payment.currency());
String result = legacy.doTransaction(payment.userId(), amount);
String txId = "TXN_" + System.currentTimeMillis();
return new PaymentResult(true, txId, result);
}
private double convertToBase(int amount, String currency) {
return switch (currency) {
case "USD" -> amount * 1300.0;
case "EUR" -> amount * 1420.0;
default -> amount;
};
}
}
// Usage — new code only depends on the ModernPaymentGateway interface
ModernPaymentGateway gateway = new LegacyPaymentAdapter(new LegacyPaymentSystem());
Payment payment = new Payment("user123", 100, "USD");
PaymentResult result = gateway.processPayment(payment);
System.out.println(result);
3. Proxy — Controlling Object Access
"A surrogate object that controls access to the real object." Used for lazy initialization, caching, logging, and access control.
// Image loading system (lazy initialization proxy)
interface Image {
void display();
int getWidth();
int getHeight();
}
// Real object: expensive to create (disk I/O, resizing, etc.)
class RealImage implements Image {
private final String filename;
private final int width, height;
RealImage(String filename) {
this.filename = filename;
System.out.println("[Expensive] Loading image from disk: " + filename);
this.width = 1920;
this.height = 1080;
}
@Override public void display() { System.out.println("Displaying: " + filename); }
@Override public int getWidth() { return width; }
@Override public int getHeight() { return height; }
}
// Proxy: stands in for the real image, creates it only when needed
class LazyImageProxy implements Image {
private final String filename;
private RealImage realImage; // null until first access
LazyImageProxy(String filename) {
this.filename = filename;
System.out.println("[Proxy created] Not loaded yet: " + filename);
}
@Override
public void display() {
if (realImage == null) realImage = new RealImage(filename); // load on first use
realImage.display();
}
@Override public int getWidth() {
if (realImage == null) realImage = new RealImage(filename);
return realImage.getWidth();
}
@Override public int getHeight() {
if (realImage == null) realImage = new RealImage(filename);
return realImage.getHeight();
}
}
// Caching proxy example
interface UserRepository {
String findById(int id);
}
class DatabaseUserRepository implements UserRepository {
@Override
public String findById(int id) {
System.out.println("DB query: SELECT * FROM users WHERE id=" + id);
return "User_" + id;
}
}
class CachedUserRepositoryProxy implements UserRepository {
private final UserRepository real;
private final Map<Integer, String> cache = new HashMap<>();
CachedUserRepositoryProxy(UserRepository real) { this.real = real; }
@Override
public String findById(int id) {
if (cache.containsKey(id)) {
System.out.println("Cache hit: id=" + id);
return cache.get(id);
}
String user = real.findById(id);
cache.put(id, user);
return user;
}
}
// Usage
UserRepository repo = new CachedUserRepositoryProxy(new DatabaseUserRepository());
repo.findById(1); // DB query
repo.findById(1); // Cache hit (no DB query)
repo.findById(2); // DB query
repo.findById(2); // Cache hit
Real-world usage of structural patterns:
-
Decorator: Spring Security's filter chain, Java I/O stream chaining.
// Java I/O uses the Decorator pattern
BufferedReader reader = new BufferedReader( // BufferedReader = decorator
new InputStreamReader( // InputStreamReader = decorator
new FileInputStream("file.txt") // FileInputStream = component
)
); -
Adapter: Wrapping third-party library interfaces to match your codebase's contract; converting legacy API responses to modern data models.
-
Proxy: Spring AOP (
@Transactional,@Cacheable,@Aspect) is implemented using either JDK Dynamic Proxies or CGLIB proxies. Every@Transactionalmethod call goes through a proxy that manages the transaction lifecycle.