Refactoring Toward Structural Patterns — Tasks¶
Source: Joshua Kerievsky, Refactoring to Patterns (Addison-Wesley, 2004); refactoring.guru/design-patterns/structural-patterns
Eight hands-on exercises. Each gives you smelly structural code, names the target, and asks for the mechanical, behavior-preserving refactoring. Try each before opening the solution. A solution is "correct" only if behavior is identical at every step and the named smell is gone.
Recommended order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 (rising difficulty).
Task 1 — Move Embellishment to Decorator¶
Smell: flag soup. Refactor the optional behaviors into stackable decorators.
public class Notifier {
private final String recipient;
private boolean alsoSlack;
private boolean alsoSms;
public Notifier(String recipient) { this.recipient = recipient; }
public void setAlsoSlack(boolean b) { this.alsoSlack = b; }
public void setAlsoSms(boolean b) { this.alsoSms = b; }
public void send(String msg) {
System.out.println("email -> " + recipient + ": " + msg);
if (alsoSlack) System.out.println("slack -> " + recipient + ": " + msg);
if (alsoSms) System.out.println("sms -> " + recipient + ": " + msg);
}
}
Solution
**Steps:** (1) extract `Notifier` interface with `send(String)`; (2) make the email-only class the concrete component; (3) abstract decorator delegates `send`; (4) one decorator per channel; (5) replace setters with wrapping at construction.public interface Notifier { void send(String msg); }
public class EmailNotifier implements Notifier {
private final String recipient;
public EmailNotifier(String recipient) { this.recipient = recipient; }
@Override public void send(String msg) {
System.out.println("email -> " + recipient + ": " + msg);
}
}
public abstract class NotifierDecorator implements Notifier {
protected final Notifier wrappee;
protected NotifierDecorator(Notifier wrappee) { this.wrappee = wrappee; }
@Override public void send(String msg) { wrappee.send(msg); }
}
public class SlackNotifier extends NotifierDecorator {
private final String who;
public SlackNotifier(Notifier w, String who) { super(w); this.who = who; }
@Override public void send(String msg) {
super.send(msg);
System.out.println("slack -> " + who + ": " + msg);
}
}
// SmsNotifier analogous.
Notifier n = new SmsNotifier(new SlackNotifier(new EmailNotifier("ann"), "ann"), "ann");
n.send("deploy done"); // email, then slack, then sms — same as both flags true
Task 2 — Replace Implicit Tree with Composite¶
Smell: hand-rolled tree with a type flag. Replace with leaf + branch sharing one interface.
public class MenuNode {
private final String label;
private final boolean isItem;
private final Runnable action; // only when isItem
private final List<MenuNode> children; // only when !isItem
public MenuNode(String label, Runnable action) { // item
this.label = label; this.isItem = true;
this.action = action; this.children = null;
}
public MenuNode(String label) { // submenu
this.label = label; this.isItem = false;
this.action = null; this.children = new ArrayList<>();
}
public void add(MenuNode n) {
if (isItem) throw new IllegalStateException();
children.add(n);
}
public int leafCount() {
if (isItem) return 1;
int c = 0;
for (MenuNode n : children) c += n.leafCount();
return c;
}
}
Solution
public interface MenuComponent {
int leafCount();
default void add(MenuComponent c) { throw new UnsupportedOperationException(); }
}
public class MenuItem implements MenuComponent {
private final String label; private final Runnable action;
public MenuItem(String label, Runnable action) { this.label = label; this.action = action; }
@Override public int leafCount() { return 1; }
}
public class Menu implements MenuComponent {
private final String label;
private final List<MenuComponent> children = new ArrayList<>();
public Menu(String label) { this.label = label; }
@Override public void add(MenuComponent c) { children.add(c); }
@Override public int leafCount() {
int c = 0;
for (MenuComponent ch : children) c += ch.leafCount();
return c;
}
}
Task 3 — Replace One/Many Distinctions with Composite¶
Smell: every method forks on single-vs-collection.
public class Invoice {
private Charge single; // exclusive with...
private List<Charge> many; // ...this
public BigDecimal total() {
if (many != null) {
BigDecimal s = BigDecimal.ZERO;
for (Charge c : many) s = s.add(c.amount());
return s;
}
return single.amount();
}
}
Solution
Make a single `Charge` and a `ChargeGroup` both implement `Billable`; `Invoice` holds one `Billable root`.public interface Billable { BigDecimal amount(); }
public class Charge implements Billable { /* returns own amount */ }
public class ChargeGroup implements Billable {
private final List<Billable> items = new ArrayList<>();
public void add(Billable b) { items.add(b); }
@Override public BigDecimal amount() {
return items.stream().map(Billable::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
public class Invoice {
private final Billable root;
public Invoice(Billable root) { this.root = root; }
public BigDecimal total() { return root.amount(); } // no fork
}
Task 4 — Extract Composite¶
Smell: two siblings duplicate tree plumbing.
class FolderA {
List<FolderA> subs = new ArrayList<>();
void add(FolderA f) { subs.add(f); }
int countLeaves() { int n = ownLeaves(); for (FolderA f : subs) n += f.countLeaves(); return n; }
int ownLeaves() { return 1; }
}
class GroupB {
List<GroupB> subs = new ArrayList<>();
void add(GroupB g) { subs.add(g); }
int countLeaves() { int n = ownLeaves(); for (GroupB g : subs) n += g.countLeaves(); return n; }
int ownLeaves() { return 2; }
}
Solution
Pull the list, `add`, and the recursive count into a parameterized base; subclasses keep only `ownLeaves()`.abstract class TreeNode<T extends TreeNode<T>> {
private final List<T> subs = new ArrayList<>();
void add(T child) { subs.add(child); }
int countLeaves() {
int n = ownLeaves();
for (T child : subs) n += child.countLeaves();
return n;
}
protected abstract int ownLeaves();
}
class FolderA extends TreeNode<FolderA> { @Override protected int ownLeaves() { return 1; } }
class GroupB extends TreeNode<GroupB> { @Override protected int ownLeaves() { return 2; } }
Task 5 — Extract Adapter¶
Smell: one class switches on a provider flag in every method.
public class Storage {
private final String kind; // "local" or "s3"
private final FileSystem fs;
private final S3 s3;
public byte[] load(String id) {
if (kind.equals("local")) return fs.readFile("/data/" + id);
else return s3.getObject("bucket", id);
}
public void save(String id, byte[] data) {
if (kind.equals("local")) fs.writeFile("/data/" + id, data);
else s3.putObject("bucket", id, data);
}
}
Solution
One adapter per backend behind a `Blobs` interface; `Storage` holds a single `Blobs`.public interface Blobs { byte[] load(String id); void save(String id, byte[] data); }
public class LocalBlobs implements Blobs {
private final FileSystem fs;
public LocalBlobs(FileSystem fs) { this.fs = fs; }
@Override public byte[] load(String id) { return fs.readFile("/data/" + id); }
@Override public void save(String id, byte[] d) { fs.writeFile("/data/" + id, d); }
}
public class S3Blobs implements Blobs {
private final S3 s3;
public S3Blobs(S3 s3) { this.s3 = s3; }
@Override public byte[] load(String id) { return s3.getObject("bucket", id); }
@Override public void save(String id, byte[] d) { s3.putObject("bucket", id, d); }
}
public class Storage {
private final Blobs blobs;
public Storage(Blobs blobs) { this.blobs = blobs; }
public byte[] load(String id) { return blobs.load(id); }
public void save(String id, byte[] d) { blobs.save(id, d); }
}
Task 6 — Encapsulate Decorator chain with Builder¶
Smell: callers hand-nest decorators and can build illegal orderings.
// Compression must happen INSIDE encryption (encrypted data won't compress).
// But nothing stops a caller writing it backwards:
Stream s = new CompressStream(new EncryptStream(raw, key)); // WRONG order, compiles fine
Solution
Give a builder that owns the legal order and exposes intent-named steps.public final class StreamBuilder {
private Stream stream;
private boolean compressed = false;
private StreamBuilder(Stream base) { this.stream = base; }
public static StreamBuilder from(Stream base) { return new StreamBuilder(base); }
public StreamBuilder compress() {
if (stream instanceof EncryptStream)
throw new IllegalStateException("compress before encrypt");
stream = new CompressStream(stream);
compressed = true;
return this;
}
public StreamBuilder encrypt(byte[] key) {
stream = new EncryptStream(stream, key);
return this;
}
public Stream build() { return stream; }
}
Stream s = StreamBuilder.from(raw).compress().encrypt(key).build(); // legal order enforced
Task 7 — Unify Interfaces with Adapter¶
Smell: the client wants Iterator-shaped access; the class offers a cursor API you don't own.
interface Rows { boolean hasNext(); Map<String,Object> next(); } // client expects this
class JdbcResult { // third-party, can't change
boolean advance() { /* ... */ return false; }
Object column(String name) { /* ... */ return null; }
String[] columnNames() { /* ... */ return new String[0]; }
}
Solution
public class JdbcRowsAdapter implements Rows {
private final JdbcResult rs;
public JdbcRowsAdapter(JdbcResult rs) { this.rs = rs; }
@Override public boolean hasNext() { return rs.advance(); }
@Override public Map<String,Object> next() {
Map<String,Object> row = new HashMap<>();
for (String c : rs.columnNames()) row.put(c, rs.column(c));
return row;
}
}
Task 8 — Introduce Facade¶
Smell: every caller wires up a subsystem of collaborators in the right order.
// Repeated at every call site:
RiskEngine risk = new RiskEngine(config);
FraudCheck fraud = new FraudCheck(risk);
Ledger ledger = new Ledger(db);
Notifier notifier = new Notifier(smtp);
if (fraud.passes(order) && risk.score(order) < 0.8) {
ledger.record(order);
notifier.confirm(order);
}
Solution
One facade owns the wiring and the orchestration; clients call `placeOrder`.public class OrderFacade {
private final FraudCheck fraud;
private final RiskEngine risk;
private final Ledger ledger;
private final Notifier notifier;
public OrderFacade(Config config, Db db, Smtp smtp) {
this.risk = new RiskEngine(config);
this.fraud = new FraudCheck(risk);
this.ledger = new Ledger(db);
this.notifier = new Notifier(smtp);
}
public boolean placeOrder(Order order) {
if (!fraud.passes(order) || risk.score(order) >= 0.8) return false;
ledger.record(order);
notifier.confirm(order);
return true;
}
}
// Client: orderFacade.placeOrder(order);
Next¶
- find-bug.md — Diagnose broken structural patterns.
- optimize.md — Propose (or reject) structural refactorings.
- Back to junior.md · middle.md · senior.md · professional.md · interview.md
In this topic