Skip to content

9.2 Composable Structures: Decorator, Composite, and Flyweight

A set of UI components must be able to compose like a tree, layer behaviors on demand, and avoid duplicating large amounts of shared state.

Decorator, Composite, and Flyweight all rely on shared abstractions, but they solve three distinct problems: how behaviors can be layered, how leaf and container nodes can be uniformly handled, and how many objects can share repeated state efficiently.

Decorator: Layering Responsibilities Around the Same Contract

A notification channel might need timing, tracing, and retrying. Instead of creating separate subclasses for every combination, responsibilities can be stacked layer by layer:

java
public final class TimedDeliveryChannel implements DeliveryChannel {
    private final DeliveryChannel target;
    private final io.micrometer.core.instrument.Timer timer;

    public TimedDeliveryChannel(
            DeliveryChannel target,
            io.micrometer.core.instrument.Timer timer) {
        this.target = target;
        this.timer = timer;
    }

    @Override
    public DeliveryResult deliver(Message message, Recipient recipient) {
        return timer.record(() -> target.deliver(message, recipient));
    }
}

The layering order is determined at assembly time:

java
DeliveryChannel channel = new TimedDeliveryChannel(
        new RetryingDeliveryChannel(
                new HttpDeliveryChannel(client), retryPolicy),
        timer);

Order carries semantic meaning: placing a timer outside the retry wrapper measures the entire attempt sequence, while placing it inside tracks each individual attempt. Exception mapping, caching, and transactional decorators are also affected by order; these should be assembled together and the full chain tested as a unit.

A decorator must preserve the fundamental contract of the wrapped interface. If the wrapping introduces additional initialization steps, rejects inputs that were previously valid, or alters identity or equality semantics, callers may no longer be able to use the system transparently.

Why Decorator and Proxy Are Easy to Confuse

Their structures are nearly identical, and the distinction depends on intent:

  • A proxy represents another object and controls access to it;
  • A decorator combines additional responsibilities on the same abstraction.

Caching, logging, and retry mechanisms can be referred to either as proxies or as decorators. Rather than arguing over terminology, it's more productive to clarify: who owns the target object, whether calls are transparent, whether order matters, and whether failures or lifecycles are altered.

In Java I/O, BufferedInputStream wraps another InputStream, a classic decorator pattern. Closing the outer wrapper typically also closes the inner one, demonstrating that resource ownership is part of the agreement.

Composite: Unified Handling of Leaf and Composite Nodes

Rewards in a competition can form a tree structure: individual rewards are leaves, while reward bundles contain other rewards.

java
public sealed interface RewardComponent
        permits CoinReward, RewardBundle {
    RewardValue total();
}

public record CoinReward(RewardValue value)
        implements RewardComponent {
    @Override
    public RewardValue total() { return value; }
}

public final class RewardBundle implements RewardComponent {
    private final java.util.List<RewardComponent> children;

    public RewardBundle(java.util.List<RewardComponent> children) {
        this.children = java.util.List.copyOf(children);
    }

    @Override
    public RewardValue total() {
        return children.stream()
                .map(RewardComponent::total)
                .reduce(RewardValue.ZERO, RewardValue::add);
    }
}

The caller only depends on total(), without needing to distinguish between a single reward and a nested reward bundle.

Safe vs. Transparent Interfaces

Placing add(child) into a common interface appears more uniform, but it forces leaf implementations to expose operations they don't support, resulting in exceptions. A safer design limits the composite node to expose only operations that modify child nodes; the shared interface retains only those operations that both leaf and composite nodes actually support.

The Composite pattern must also address several key questions:

  • Whether empty composite nodes are allowed;
  • Whether the same node can be shared among multiple parent nodes;
  • How to prevent infinite recursion caused by cycles;
  • Whether traversal order is stable;
  • How to handle concurrent reads during modifications;
  • Whether a failure in aggregation should result in a complete failure or allow partial results to be preserved.

If the structure might form an arbitrary graph rather than a tree, a simple Composite pattern is insufficient to express ownership and cycle semantics.

Flyweight: Share Internal State, Externalize Context State

When a system creates millions of similar objects, repeatedly storing identical fonts, rules, or resource descriptions can waste memory. Flyweight separates the internal state (data that can be shared and is independent of context) into immutable objects, while leaving external state (such as position or ownership) to be managed by the calling context.

java
public record BadgeStyle(
        String iconPath,
        String color,
        String label) {}

public record AwardedBadge(
        BadgeStyle style,
        PlayerId owner,
        java.time.Instant awardedAt) {}

BadgeStyle keys can be shared through normalization:

java
public final class BadgeStyleCatalog {
    private final java.util.concurrent.ConcurrentMap<String, BadgeStyle> styles =
            new java.util.concurrent.ConcurrentHashMap<>();

    public BadgeStyle getOrCreate(String key,
                                  java.util.function.Supplier<BadgeStyle> factory) {
        return styles.computeIfAbsent(key, ignored -> factory.get());
    }
}

In a single successful atomic computation, each key's mapping function is executed at most once. If the function throws an exception, the mapping is not established, and subsequent calls may reattempt. Therefore, factories should not rely on side effects that occur only once.

Measure Before Sharing

Flyweight introduces key normalization, caching lifecycle, and indirection. It should only be used when object counts, repetition rates, and memory profiling demonstrate actual benefit. Concepts like JVM string pools, database connection pools, and standard caches share similar ideas of reuse, but differ in lifecycle, mutability, and resource semantics, so "sharing" alone does not make them equivalent patterns.

Caches must also prevent unbounded growth: size limits, weak references, version-based replacement, or cleanup during tenant unloading are essential. Shared objects must be immutable or strictly synchronized; otherwise, modifications by one caller could corrupt or pollute all other users.

Failure Signals of Three Patterns

PatternFailure Signals
DecoratorDisjointed composition order, altered semantics, deep chains that are hard to diagnose
CompositeLeaf nodes forced to support meaningless operations, unclear ownership, formation of cycles
FlyweightOptimizing without measurement, sharing mutable state, unbounded cache growth

Exercise: First Describe the Structure, Then Name the Pattern

For a real-world requirement, write four sentences:

  1. Which objects must maintain the same interface?
  2. Is the change an addition of behavior, the formation of a recursive structure, or the sharing of repeated state?
  3. Who owns the state and resources?
  4. How are nesting order, tree ownership, or cache lifecycle validated?

Only after these questions have clear answers does the pattern move beyond mere class diagram mimicry.

References

Built with VitePress | Software Systems Atlas