Skip to content

VOL 06 / CH 09 / LESSON 02

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 may need timing, tracing, and retries. Wrappers avoid a subclass for every combination. This Java 21 fragment uses the Timer API from Micrometer’s micrometer-core; the project supplies the previous section’s channel and message types and injects a timer during assembly:

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 = java.util.Objects.requireNonNull(target, "target");
        this.timer = java.util.Objects.requireNonNull(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);

Assume deliver returns synchronously and the project supplies the retry wrapper, HTTP channel, and policy. If it returns a CompletionStage, this code measures only submission time; stop a sample in the completion callback instead. The Micrometer Timer documentation covers synchronous wrappers and Timer.Sample.

Order changes the measurement: one failed attempt followed by success produces one outer-timer recording or two inner-timer recordings. The outer duration includes all attempts and backoff waits; individual inner measurements generally exclude waits between attempts. 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 ​

Tournament rewards can form a tree: individual rewards are leaves, while bundles contain other rewards. These are complete Java 21 types; save each public type separately. RewardValue represents nonnegative integer game coins, with no currency conversion:

java
public record RewardValue(long coins) {
    public static final RewardValue ZERO = new RewardValue(0);
    public RewardValue {
        if (coins < 0) throw new IllegalArgumentException("negative coins");
    }
    public RewardValue add(RewardValue other) {
        java.util.Objects.requireNonNull(other, "other");
        return new RewardValue(Math.addExact(coins, other.coins));
    }
}

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

public record CoinReward(RewardValue value)
        implements RewardComponent {
    public CoinReward {
        java.util.Objects.requireNonNull(value, "value");
    }
    @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 depends only on total(), without distinguishing a single reward from a nested bundle. Put these statements in main:

java
var leaf = new CoinReward(new RewardValue(10));
var pair = new RewardBundle(java.util.List.of(leaf, leaf));
var root = new RewardBundle(java.util.List.of(leaf, pair));
System.out.println(root.total().coins()); // 30
System.out.println(new RewardBundle(java.util.List.of()).total().coins()); // 0

An empty bundle totals zero. A repeated leaf counts once per occurrence; object identity does not deduplicate rewards. Immutable nodes are built from the leaves upward, so ordinary construction cannot create cycles, though subtrees may be shared. Overflow throws ArithmeticException instead of wrapping around to a negative “discount.” Very deep nesting can still exhaust the call stack.

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 exposes child-modification operations only on composite 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 Intrinsic State, Externalize Context ​

When a system creates millions of similar objects, repeatedly storing identical fonts, rules, or resource descriptions can waste memory. Flyweight separates the intrinsic state (data that can be shared and is independent of context) into immutable objects, while leaving extrinsic 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) {}

PlayerId is a project type, and the strings in BadgeStyle are immutable. A normalized key allows style instances to be shared:

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) {
        java.util.Objects.requireNonNull(key, "key");
        java.util.Objects.requireNonNull(factory, "factory");
        return styles.computeIfAbsent(key, ignored ->
                java.util.Objects.requireNonNull(factory.get(), "style"));
    }
}

A key must identify the same icon, color, label, and any relevant version or tenant scope. If callers supply different factories for the same key, the stored value wins; the cache cannot decide which color was intended.

The concrete implementation is ConcurrentHashMap: an absent key is computed and inserted atomically; an existing value bypasses the factory. This example rejects null keys, factories, and results. A factory exception or rejected null result leaves no mapping, so later calls can retry. Some updates may block during computation. Keep the factory short, avoid slow network calls, and do not modify this map during computation. This is neither once-per-process execution nor exactly-once external side effects.

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.

This example has no eviction policy and requires a bounded set of styles; do not expose it directly to unlimited user-generated keys. Real caches must control retention through capacity limits, appropriate weak-reference policies, version replacement, or tenant-unload cleanup. ConcurrentHashMap supplies none of those policies automatically. 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