| Lesson 7 | Flyweight Pattern Structure |
| Objective | Implement Flyweight participants with explicit state and ownership. |
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.
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.
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.
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++ 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 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.
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.