Structural Patterns «Prev Next»

Lesson 7Flyweight Pattern Structure
ObjectiveImplement Flyweight participants with explicit state and ownership.

Flyweight Pattern Structure

A Flyweight implementation has four responsibilities: an immutable shared value, a canonical key, a factory that owns or locates shared instances, and a client that retains extrinsic state. Keeping these roles visible prevents the factory from becoming a general cache and prevents changing per-occurrence data from leaking into the shared object.

The client asks the factory for a flyweight by value, not by an arbitrary object identity. The factory normalizes and validates the key, then returns the canonical instance. When behavior depends on one logical occurrence, the client passes its extrinsic context to an operation. The flyweight reads its intrinsic fields and the supplied context but does not retain or mutate that context.

Structure and Call Sequence

Flyweight structure with a factory, shared instances, and client-supplied extrinsic state.
The factory controls canonical creation; clients provide state that varies per use.
  1. The client builds or selects a canonical key for the intrinsic value.
  2. The factory looks up the key and creates an immutable flyweight only on a miss.
  3. The client stores the returned reference beside its own extrinsic state.
  4. Each operation combines intrinsic fields with the context supplied for that call.

Make Invalid Sharing Difficult

Construction should establish every invariant. Java fields should be final, mutable inputs should be copied, and internal collections should not be exposed. In C++, const operations and explicit smart-pointer ownership make the same intent visible. Avoid setters and avoid giving callers direct access to the factory's map.

The key type deserves design attention. Normalize case, whitespace, units, and default values before lookup. Implement value equality and hashing from exactly the properties that define the intrinsic object. A key that omits a meaningful property returns the wrong shared value; a key that includes extrinsic data creates one entry per occurrence and defeats the pattern.

Ownership and Scope

Place the factory at the narrowest lifetime that supports sharing. A catalog owned by one simulation run gives deterministic cleanup and isolation. A process-wide static factory retains values longer and can mix tests or tenants. If the key space is unbounded, define eviction and accept that reference identity may change after an entry is reclaimed. The structural diagram is complete only when these lifecycle rules are documented.

Expose observability without exposing mutation. The factory can report entry count, hits, misses, and creation failures through metrics or a read-only diagnostic interface. Those signals confirm whether callers are reusing instances as expected. They also reveal malformed keys and unexpected cardinality before memory pressure becomes an outage. Do not make application correctness depend on a cache hit, because an empty factory after restart must still produce the same semantic value.

Tests should create two equal keys and assert that the factory returns the same instance, then use different extrinsic contexts concurrently and verify that the results remain independent. Additional tests cover unequal keys, invalid input, construction failure, and factory cleanup. These checks enforce the structure's purpose instead of only confirming that its classes compile. Run them under representative parallel load when the factory will serve multiple simulation workers.

Participants and Responsibilities

A practical Flyweight design normally contains four responsibilities. The Flyweight stores immutable intrinsic state and implements the operation clients need. The FlyweightFactory maps a canonical key to one shared instance. The Context represents each logical occurrence and stores its extrinsic state. The Client creates contexts, obtains shared values from the factory, and supplies extrinsic state when invoking behavior.

The factory is common but not mandatory. A fixed set of flyweights can be created during application startup, supplied through dependency injection, and stored in an immutable map. A factory becomes valuable when values are created lazily, keys need normalization, or creation rules must be centralized. Its essential responsibility is canonicalization: equivalent keys must resolve to the same safe shared value.

Java Structure with a Canonical Key

The following example shares immutable vehicle specifications. Position and current speed remain extrinsic because each simulated vehicle has its own values. A record supplies value-based equality for the cache key.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

record VehicleKey(String type, int maxSpeed) {}

final class VehicleSpec {
    private final String type;
    private final int maxSpeed;

    VehicleSpec(String type, int maxSpeed) {
        this.type = type;
        this.maxSpeed = maxSpeed;
    }

    String describe(int lane, int currentSpeed) {
        return type + " lane=" + lane
            + " speed=" + Math.min(currentSpeed, maxSpeed);
    }
}

final class VehicleSpecFactory {
    private final ConcurrentMap<VehicleKey, VehicleSpec> cache =
        new ConcurrentHashMap<>();

    VehicleSpec get(String type, int maxSpeed) {
        VehicleKey key = new VehicleKey(type, maxSpeed);
        return cache.computeIfAbsent(
            key, k -> new VehicleSpec(k.type(), k.maxSpeed()));
    }

    int sharedCount() {
        return cache.size();
    }
}

The context would hold a VehicleSpec reference plus lane, position, and current speed. The shared count should approach the number of distinct specifications, not the number of simulated vehicles. Tests should confirm that equal keys return the same instance and different keys do not.

C++ Ownership and Const Correctness

C++ adds an explicit ownership decision. Returning a shared pointer to a const object communicates that clients may share the object but may not mutate its intrinsic state. A production factory also needs a key type with equality and hashing that cover every intrinsic field.

class VehicleSpec {
public:
    VehicleSpec(std::string type, int maxSpeed)
        : type_(std::move(type)), maxSpeed_(maxSpeed) {}

    std::string describe(int lane, int speed) const {
        return type_ + " lane=" + std::to_string(lane)
             + " speed=" + std::to_string(std::min(speed, maxSpeed_));
    }
private:
    std::string type_;
    int maxSpeed_;
};

class VehicleSpecFactory {
public:
    std::shared_ptr<const VehicleSpec>
    get(const VehicleKey& key) {
        auto found = cache_.find(key);
        if (found != cache_.end()) {
            return found->second;
        }
        auto value = std::make_shared<const VehicleSpec>(
            key.type, key.maxSpeed);
        cache_.emplace(key, value);
        return value;
    }
private:
    std::unordered_map<VehicleKey,
        std::shared_ptr<const VehicleSpec>, VehicleKeyHash> cache_;
};

This version is not thread-safe. A concurrent program must protect the map or construct all specifications before threads begin. Locking policy belongs to the factory, not to each immutable flyweight.

Key Design, Lifecycle, and Observability

Key design determines correctness. Normalization should be deliberate: case-folding a vehicle type may be valid, while rounding a monetary value may merge objects that must remain distinct. The key should be immutable and should not contain extrinsic fields such as position. If two keys compare equal, their flyweights must be safely interchangeable for every supported operation.

An unbounded factory can become a memory leak when keys come from users or remote data. Set a limit, use eviction, or restrict creation to a known catalog when the key space is not naturally bounded. Export metrics for shared instance count, lookup volume, hit rate, creation failures, and eviction. These measurements show whether the pattern is delivering the expected benefit and help diagnose accidental key explosion.

Testing the Structure

  1. Request the same canonical key twice and assert reference identity.
  2. Request different keys and assert that their instances are distinct.
  3. Invoke one flyweight with different extrinsic values and verify independent results.
  4. Run concurrent creation tests if the factory is shared by threads.
  5. Measure retained memory against a non-shared baseline at realistic scale.

These tests connect the class structure to the pattern's intent. A cache that returns correct values but creates a new object for every request is not serving as a Flyweight factory. A cache that shares mutable state may save memory while introducing a more serious correctness defect.

Integration tests should also cover serialization. Persist the canonical key and extrinsic state, not an assumption about in-memory identity. When data is loaded in another process, resolve the key through that process's factory and verify the reconstructed context. This rule is important for web sessions, message consumers, batch jobs, and cloud services that scale horizontally.

Dependency injection can supply the factory to clients without turning the cache into a global singleton. A test can inject a small deterministic catalog, while production can inject a bounded concurrent implementation. The interface should expose lookup behavior, not cache administration, so business code does not become coupled to eviction or monitoring details.

If creation can fail, return or throw an error that identifies the invalid key without exposing internal cache types. Do not insert partially constructed objects. For expensive asynchronous creation, coordinate concurrent requests so callers either share one completed value or receive a consistent failure. These details are implementation concerns, but they determine whether the structural promise remains reliable under load.

Keep monitoring outside the core flyweight interface. A metrics decorator or factory-owned instrumentation hook can count hits and creations without forcing every client to know how values are cached. This preserves the separation between domain behavior and operational concerns.

Code review should verify that intrinsic fields never change after construction, that extrinsic fields are absent from the cache key, and that equality agrees with canonicalization. Reviewers should also check that cache growth is bounded by design or by policy. A factory that accepts arbitrary untrusted keys without limits can convert a memory optimization into a denial of service risk.

Finally, document whether reference identity is meaningful. Prefer value equality when cache eviction or process restarts can replace an instance.

Apply Flyweight to Vehicles - Exercise

In this exercise, you will be converting vehicles to Flyweights.
Flyweight Pattern - Exercise

SEMrush Software 7 SEMrush Banner 7