Structural Patterns: Compose Objects and Interfaces
Compare Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy with Java examples, costs, failure modes, and guidance for choosing a fit.
Structural patterns help shape object boundaries when interfaces, variation, object trees, optional behavior, subsystem complexity, repeated state, or access controls create pressure. The examples show how each pattern works and where it can add costs, from decorator order and remote proxy failures to shared-state leaks. A decision path, trade-off table, and production scenarios help you choose a pattern only when it makes a real change easier to manage.
Structural Patterns: Compose Objects and Interfaces
Introduction
Structural patterns describe how objects and interfaces fit together. They help when a useful behavior is trapped behind the wrong API, one type is growing too many combinations, or callers know more about a subsystem than they should.
The seven GoF structural patterns solve different design pressures. An Adapter translates a contract; a Bridge separates two dimensions of variation; Composite gives leaves and containers one interface; Decorator adds behavior around an object; Facade narrows a subsystem’s public surface; Flyweight shares repeatable state; Proxy controls access to another object. The names are less useful than the pressure that made you reach for one.
The examples use Java-like interfaces and small classes. A real implementation may use language features such as closures, modules, or framework proxies, but the object boundary should still be visible in the design.
Suppose application code expects Notifier.send, while a vendor library exposes LegacyMailer.deliver:
// Direct calls spread the vendor's API through application code.
mailer.deliver(address, message);
// An Adapter translates once at the boundary.
notifier.send(address, message);
That translation is useful when the caller should depend on a stable contract instead of the vendor’s API.
A Decision Path
Start with the change you need to absorb. This diagram treats the patterns as candidate responses, not steps that every design should follow.
flowchart TD
Start[Which structural pressure is present?] --> Contract{Does an existing contract not fit?}
Contract -->|Yes| Adapter[Adapter translates the contract]
Contract -->|No| Variation{Must two dimensions vary independently?}
Variation -->|Yes| Bridge[Bridge separates abstraction and implementation]
Variation -->|No| Tree{Do callers need one interface for leaves and groups?}
Tree -->|Yes| Composite[Composite models a part-whole tree]
Tree -->|No| Behavior{Must behavior wrap one object dynamically?}
Behavior -->|Yes| Decorator[Decorator adds behavior through the same interface]
Behavior -->|No| Surface{Should callers use a smaller subsystem API?}
Surface -->|Yes| Facade[Facade coordinates subsystem work]
Surface -->|No| Shared{Can repeated intrinsic state be shared?}
Shared -->|Yes| Flyweight[Flyweight separates shared and per-use state]
Shared -->|No| Access[Proxy controls access to a subject]
The last branch is a prompt to inspect access and lifecycle requirements. If none exist, plain composition is probably enough.
The Seven Structural Patterns
Adapter: translate an interface
Use Adapter when a dependency already does the needed work but speaks a different contract. The adapter translates at the boundary; it should not quietly become the home for business policy.
interface Notifier { void send(String recipient, String message); }
interface LegacyMailer { void deliver(String address, String body); }
final class MailerAdapter implements Notifier {
private final LegacyMailer mailer;
MailerAdapter(LegacyMailer mailer) { this.mailer = mailer; }
public void send(String recipient, String message) {
mailer.deliver(recipient, message);
}
}
The caller can depend on Notifier while the adapter deals with the vendor’s names and calling convention. This resembles an adapter at a hexagonal boundary; see Ports, Adapters, and Testable Cores for the larger architecture context.
The cost is translation code and another place to preserve error and data semantics. If two APIs already agree, adding an adapter just renames calls and adds indirection.
Bridge: let two dimensions vary independently
Bridge separates an abstraction from the implementation it delegates to. It helps when both axes change: for example, several alert types must support several delivery channels.
interface Channel { void deliver(String text); }
final class EmailChannel implements Channel {
public void deliver(String text) { /* send email */ }
}
final class Alert {
private final Channel channel;
Alert(Channel channel) { this.channel = channel; }
void raise(String text) { channel.deliver(text); }
}
CriticalAlert and DigestAlert can extend or compose Alert, while email and SMS provide Channel implementations. This avoids a class for every alert-channel pair. The trade is a more indirect call path and more types to understand. If only one axis varies, a simple strategy or one conditional may be clearer.
Composite: treat leaves and groups alike
Composite models a tree where a leaf and a group support the same operation. A group delegates to its children; a leaf performs the work.
interface Job { void run(); }
final class Task implements Job {
public void run() { /* perform one task */ }
}
final class JobGroup implements Job {
private final List<Job> jobs;
JobGroup(List<Job> jobs) { this.jobs = List.copyOf(jobs); }
public void run() { jobs.forEach(Job::run); }
}
The caller can run one task or a group without checking its concrete type. Composite fits menus, scene graphs, file trees, and batches. It costs you explicit rules for empty groups, cycles, partial failure, and whether a child can have multiple parents. Do not use a tree abstraction for a flat collection that callers already handle cleanly.
Decorator: add behavior around one object
Decorator implements the same interface as the wrapped object and delegates after adding a focused behavior. Because decorators can be stacked, behavior can be selected at runtime.
interface Store { String get(String key); }
final class CachedStore implements Store {
private final Store next;
CachedStore(Store next) { this.next = next; }
public String get(String key) {
String value = cache.get(key);
if (value != null) return value;
value = next.get(key);
cache.put(key, value);
return value;
}
}
Real cache code needs a defined expiry and concurrency policy. The pattern is useful for compression, metrics, retries, or authorization when the wrapper preserves the contract. It gets hard to debug when many decorators are assembled invisibly or their order changes behavior.
Facade: give callers a simpler entry point
Facade gathers a common task across several subsystem APIs behind one smaller interface. It does not need to hide every capability or replace the subsystem for specialized callers.
final class ReportFacade {
private final QueryEngine query;
private final Renderer renderer;
byte[] create(String reportId) {
return renderer.render(query.load(reportId));
}
}
This can make a workflow easier to call and test. The cost appears when the facade becomes a “god object” or accumulates policy for every client. Keep it close to a coherent use case, and leave specialized subsystem access available where it is appropriate.
Flyweight: share state that repeats
Flyweight separates reusable intrinsic state from per-use extrinsic state. A text renderer can share glyph metrics and font data while each character occurrence carries its position.
record Glyph(char symbol, FontMetrics metrics) {}
final class GlyphFactory {
private final Map<Character, Glyph> pool = new HashMap<>();
Glyph get(char symbol) {
return pool.computeIfAbsent(symbol,
ch -> new Glyph(ch, loadMetrics(ch)));
}
}
Sharing can reduce memory when many objects repeat the same expensive immutable state. It also introduces a pool, lookup cost, and lifecycle questions. Keep request-specific or mutable state outside the shared object; otherwise one caller can leak data into another.
Proxy: control access to a subject
Proxy implements the same contract as the object it represents and controls how a call reaches it. A proxy may defer loading, enforce a local access rule, or communicate with a remote service.
interface Document { String read(); }
final class LazyDocumentProxy implements Document {
private final Supplier<Document> loader;
private Document target;
LazyDocumentProxy(Supplier<Document> loader) { this.loader = loader; }
public String read() {
if (target == null) target = loader.get();
return target.read();
}
}
This lazy example needs synchronization if multiple threads can call it concurrently. A remote proxy has larger costs: network latency, partial failure, serialization, and retry semantics. A method call that looks local may no longer be cheap or reliable, so make that difference visible in the API or documentation.
When to Use
Reach for a structural pattern when you can name the pressure it removes and the resulting boundary has a stable purpose.
- Choose Adapter when an existing collaborator’s contract differs from the one your code should consume.
- Choose Bridge when abstraction and implementation are both expected to gain independent variants.
- Choose Composite when callers need a common operation over a tree of individual items and groups.
- Choose Decorator when optional behavior should wrap one object and can be composed at runtime.
- Choose Facade when a common workflow currently requires clients to coordinate several subsystem calls.
- Choose Flyweight when profiling shows repeated immutable state is consuming meaningful memory.
- Choose Proxy when access, creation, or communication needs an explicit control point.
Patterns are a vocabulary for discussing a design, not a checklist. The SOLID principles in practice can help assess whether a new boundary has a coherent responsibility and whether its dependencies are manageable.
When NOT to Use
Skip the pattern when the direct code is small, stable, and easy to change. One if can be clearer than a Bridge hierarchy if there is one implementation and no likely second axis. A direct library call can be clearer than a one-line Adapter that adds no translation. A list may be easier than a Composite when callers need different operations on leaves and groups anyway.
Be especially wary of patterns added to anticipate hypothetical variation. Every interface, wrapper, factory, and shared pool becomes a concept a maintainer has to trace. Wait for a concrete seam: a dependency that changes independently, a policy that must be selected at runtime, or a measured resource constraint.
Production Failure Scenarios
An adapter translates values but loses meaning
A payment SDK reports a declined charge with a typed error. An adapter catches every exception and returns false. The application can no longer distinguish a decline from a timeout, so it may retry a payment that already succeeded remotely. Preserve meaningful outcomes and test the translation against provider behavior.
Decorator order changes behavior
An application composes Retry(Timeout(RemoteStore)). Each retry gets a fresh timeout, so the total request may last far longer than the caller’s deadline. Set an end-to-end deadline and test the assembled chain, not only each wrapper in isolation.
A composite tree loops forever
A mutable group is accidentally added as its own descendant. Recursive traversal never terminates, consuming CPU and eventually exhausting the stack. Make ownership rules explicit, reject cycles when mutating the tree, or use an iterative traversal with visited-node tracking when graphs are valid.
A flyweight leaks state across tenants
A cached shared object includes a tenant-specific permission or locale. Reusing it for another request can expose the wrong data. Shared flyweight state must be immutable and independent of the current user; keep tenant context in the per-use input.
A proxy hides remote failure
A remote proxy presents a synchronous method that blocks on an unavailable service. Callers may create thread exhaustion while waiting, then blindly retry. Surface timeout and failure semantics, cap concurrency, and use bounded retries only for operations safe to repeat.
Trade-Off Table
| Pattern | Pressure it addresses | Main benefit | Main cost or risk | Use the simpler fit when |
|---|---|---|---|---|
| Adapter | Incompatible interfaces | Reuses an existing collaborator behind a suitable contract | Translation can lose errors, units, or semantics | A direct call is already clear and stable |
| Bridge | Two independent variation axes | Avoids a class for every combination | Adds indirection and more types | Only one axis has real variation |
| Composite | Tree of parts and groups | Uniform operation over a hierarchy | Traversal, ownership, and partial-failure rules | The structure is just a flat collection |
| Decorator | Optional behavior around an object | Runtime composition without subclass combinations | Wrapper order and debugging complexity | One fixed behavior belongs directly in the class |
| Facade | Complex subsystem call sequence | Gives common work a narrow entry point | Can become a central god object | Clients need different subsystem operations |
| Flyweight | Repeated expensive intrinsic state | Reduces memory for shared immutable data | Pooling and state-separation complexity | Profiling shows no material memory pressure |
| Proxy | Controlled or deferred access | Makes access control, lazy load, or remote call composable | Hidden latency, lifecycle, and failure behavior | The real object is already cheap and local |
Observability Checklist
- Record which concrete adapter, decorator chain, or proxy implementation is active at startup.
- Measure latency and failures at boundaries that call external or remote systems.
- Track cache hit rate, eviction, and stale reads when a decorator or flyweight pool caches state.
- Emit a stable operation identifier across nested wrappers; avoid logging secrets or full payloads.
- Monitor tree size and traversal duration for large Composite structures.
- Include timeout, retry, and fallback outcomes in metrics so hidden wrapper behavior is diagnosable.
- Keep labels bounded. A class name or operation type is usually useful; a user ID is usually too high-cardinality.
Security and Compliance Notes
Patterns do not grant trust. A Proxy can centralize an authorization check, but every entry path still needs the same policy; a second implementation that bypasses the proxy can reopen access. Put authorization at a boundary all relevant callers use, then test bypass paths.
Adapters should validate and normalize untrusted data rather than forwarding it blindly. Facades should not broaden a client’s privilege just because they combine subsystem calls. Decorators that log or cache data must redact secrets, define retention, and respect tenant boundaries. Flyweight pools must never contain mutable user-specific state. For remote proxies, authenticate the peer, protect transport, constrain retries, and treat serialized input as untrusted.
Common Pitfalls / Anti-Patterns
- Calling every wrapper an Adapter. Adapter translates a contract; Decorator adds behavior; Proxy controls access; Facade presents a simpler subsystem entry point.
- Building Bridge before a second variation axis exists. A strategy or direct dependency may be enough.
- Making a Facade responsible for every workflow, policy, and subsystem. Split by coherent use case before it becomes the only place that knows how the system works.
- Stacking decorators without documenting composition order or exposing the assembled chain in diagnostics.
- Sharing mutable Flyweight data. Shared intrinsic state should be immutable; per-request context belongs outside it.
- Treating a remote Proxy call as equivalent to a local method call. Latency and partial failure change the contract’s operational meaning.
- Exposing every Composite child mutation to every caller. Centralize ownership checks so invalid cycles or orphaned nodes cannot form.
- Adding a pattern to satisfy a diagram rather than a real change pressure. The pattern is useful only if it makes a likely change cheaper to understand and safer to make.
Quick Recap Checklist
- Can I state the change pressure in one sentence?
- Does the selected pattern address that pressure directly?
- Is the new interface owned by the side that needs the contract?
- Can callers still see important costs such as remote latency, retries, or partial failure?
- Have I defined state ownership, concurrency, and lifecycle for shared or lazy objects?
- Did I test the composed design, including wrapper order and boundary translations?
- Would a direct function, collection, or conditional be easier to maintain here?
Interview Questions
They wrap for different reasons. Adapter changes the interface a client sees. Decorator keeps the same interface and adds behavior. Proxy keeps the subject's interface while controlling access, creation, or communication. The constructor shape can look similar; the intent and resulting contract decide the name.
A strategy usually varies one algorithm behind a stable caller. Bridge is useful when both the higher-level abstraction and its implementation have independent variants. If there is only one abstraction and one algorithm choice, Strategy is usually simpler; Bridge earns its extra structure when combinations would otherwise multiply.
Measure whether repeated object state is a real memory cost, then identify exactly which fields are intrinsic and safe to share. Keep request-specific data outside the shared value, define pool lifetime and concurrency behavior, and test that one caller cannot mutate state observed by another.
Further Reading
- SOLID Principles in Practice — evaluate whether a new boundary has a focused responsibility.
- Choosing, Combining, and Removing Patterns — compare pattern selection with simpler alternatives.
- Composition Over Inheritance in Java — use composition to assemble behavior without a subclass for every combination.
- Dependency Injection and Application State — manage collaborators and their lifetimes at explicit boundaries.
- Refactoring.Guru: Structural Design Patterns — overview of the structural pattern family.
- Refactoring.Guru: Adapter — interface translation and object/class variants.
- Refactoring.Guru: Decorator — wrapping objects to add behavior.
- Refactoring.Guru: Proxy — controlling access to a subject.
Conclusion
Structural patterns make object relationships deliberate. Pick Adapter for translation, Bridge for independent variation, Composite for part-whole trees, Decorator for additive behavior, Facade for a simpler subsystem entry, Flyweight for measured sharing, and Proxy for controlled access. When the reason for the indirection is hard to explain, keep the simpler design and revisit it when the pressure becomes real.
Category
Related Posts
Clean and Onion Architecture: Keeping Policy at the Center
Compare clean and onion architecture, learn how concentric boundaries keep policy independent, and apply dependency rules with practical code and trade-offs.
Hexagonal Architecture: Ports, Adapters, and Testable Cores
Learn hexagonal architecture through ports, adapters, and inward dependencies, with practical code, trade-offs, failure modes, and security guidance.
Layered Architecture: A Guide to Responsibility Tiers
Learn layered architecture's responsibility tiers, dependency rules, code examples, and trade-offs for separating presentation, domain, and infrastructure.