| Lesson 6 | Flyweight Suitability and Applicability |
| Objective | Determine when Flyweight is appropriate and when it is not. |
Flyweight is suitable when a measured resource problem is caused by numerous logical objects duplicating a much smaller set of reusable values. It is not a default way to implement every value object or cache. The pattern exchanges a simple self-contained object for a shared intrinsic object, a lookup mechanism, and extrinsic context supplied by the client. That exchange is worthwhile only when the saved allocation and memory exceed the new complexity and lookup cost.
The strongest case has all of the following characteristics:
Text rendering, map symbols, game terrain, repeated product descriptions, and the vehicle types in this course simulation can satisfy these conditions. Each case still requires measurements with realistic data. A familiar example does not prove that a particular implementation has a problem.
Do not apply Flyweight when object count is modest, state is mostly unique, or objects are already compact values. Avoid sharing when the candidate intrinsic state changes per logical object, because synchronization or copying will undermine the design. If clients depend on reference identity, canonical instances may also change observable behavior.
The pattern is a poor fit when externalizing state makes every operation accept a large context object that duplicates the original data elsewhere. A global factory with arbitrary keys can retain more memory than it saves. A high contention lookup can reduce throughput. In these situations, ordinary value objects, records, arrays, compact identifiers, or data-oriented storage may be clearer and faster.
Flyweight intentionally allows several logical occurrences to reference one
physical object. Domain equality must therefore be defined explicitly. Two
vehicle events are not the same event merely because they share one
VehicleType. Compare event identifiers for occurrence identity
and compare type values for description equality. Tests that use
== only to prove factory canonicalization should not leak that
assumption into unrelated domain rules.
Immutability is the safest sharing boundary. In Java, use final fields, defensive copies, and unmodifiable returned values. In C++, prefer const objects with explicit smart-pointer ownership. If a property can change during a run, either make it extrinsic or create a different versioned flyweight. Allowing a setter because it is convenient turns shared state into hidden global mutation.
The key space should be bounded or governed by an explicit eviction policy. A catalog of twelve vehicle types can safely live for one simulation. A catalog keyed by untrusted user strings may grow indefinitely. Decide whether the factory is owned by one request, simulation, tenant, application process, or class loader. Avoid a static global map unless that lifetime and isolation are truly required.
Concurrency changes factory implementation, not the pattern's applicability. A thread-safe map can prevent duplicate creation, but construction must remain safe and failures must not leave invalid entries. Track catalog size, hit rate, misses, and creation latency. These signals show whether sharing occurs as predicted and whether the lifecycle remains healthy.
Assume a traffic simulation retains 500,000 events. A heap profile shows that repeated vehicle descriptions account for 90 megabytes, while only sixteen distinct descriptions exist. Moving those descriptions into immutable flyweights is promising. Define a canonical key, build a process-local factory, and retain direction, lane, arrival time, and speed in each event. Run the same scenario before and after the change and compare retained heap, allocation rate, execution time, and result correctness.
Now change the facts. Suppose there are 2,000 events and nearly every vehicle
has unique configuration data. The distinct-value ratio is poor, and a map
lookup adds complexity without meaningful sharing. A compact immutable
VehicleDescription stored directly in each event may be the
better design. Applicability comes from the data distribution and workload,
not from the domain noun.
String interning or enum constants can solve small, closed cases without a general factory. Database normalization can avoid repeating persistent values, though loaded application objects may still duplicate them. Integer IDs can reference a compact table. Arrays or column-oriented layouts can improve memory locality for simulations and analytics. Object pooling addresses expensive reusable resources rather than immutable shared values.
Choose the least complicated alternative that satisfies the measured constraint. Document why it was selected, the expected cardinality, ownership, and the threshold at which the decision should be revisited. Include a test that confirms canonical reuse and a performance fixture that uses representative scale. If later measurements show negligible savings or unbounded catalog growth, the design should be changed rather than defended because it uses a recognized pattern. The final decision should remain understandable to a maintainer who sees the factory but did not participate in the original optimization work. Record the baseline and expected benefit beside the design. That record keeps later performance discussions tied to reproducible evidence.
Applicability should be evaluated with numbers rather than with the vague observation that the application creates "many objects." Estimate how many logical objects exist at peak load, how much intrinsic data is duplicated, how many distinct intrinsic values occur, and how much extrinsic state must remain per logical object. The potential saving is roughly the duplicated intrinsic state removed from each logical object, minus references, cache entries, and lookup overhead.
For example, suppose a map displays 500,000 symbols, but the symbols use only 40 immutable icon definitions. Sharing those 40 definitions can remove hundreds of thousands of duplicate color tables and vector paths. If the same map contains only 500 symbols or nearly every symbol has a unique definition, Flyweight adds indirection without producing a meaningful saving.
In Java, every logical object can hold a reference to its shared flyweight, and
garbage collection can reclaim flyweights that are no longer strongly
reachable. A plain HashMap may be sufficient during single-threaded
initialization. A concurrent application may need
ConcurrentHashMap.computeIfAbsent, careful key design, and limits
that prevent an unbounded cache.
In C++, ownership must be explicit. A factory may return
std::shared_ptr<const Flyweight>, keep values directly in a
container, or use another lifetime strategy appropriate to the application.
Stable references matter because container reallocation can invalidate
pointers for some container choices. Const correctness helps enforce the
promise that intrinsic state cannot be modified by one client and observed by
every other client.
Flyweight is not the only way to reduce memory. A compact immutable value object may already be inexpensive. Records, structs, primitive arrays, string interning, database normalization, and column-oriented storage can eliminate duplication with less behavioral complexity. Data-oriented designs can place frequently accessed values in contiguous arrays, improving cache locality without introducing a shared object for every concept.
Use Flyweight when clients benefit from a shared object that owns meaningful behavior as well as intrinsic state. Prefer a simpler data representation when the candidate is merely a small tuple of values. This keeps the object model honest and avoids applying a class-heavy pattern where a map or compact value would communicate the design more clearly.
An in-process flyweight cache is local to one service instance. Autoscaling creates additional caches, and a deployment or restart discards them. A distributed cache can share canonical values across instances, but it changes the problem into remote data access with timeouts, serialization, versioning, and eviction. Do not make a request path depend on a distributed flyweight without a failure policy.
Multi-tenant systems also require isolation. A cache key must include every dimension that affects intrinsic state, including tenant, locale, permissions, and version where appropriate. Accidentally sharing data that is only superficially equal can expose private information or apply the wrong policy. The safety test is stronger than equality: the value must be valid for every client that receives the shared instance.
Versioning is part of that validity rule. If a shared specification changes, decide whether existing contexts continue to use the old version or resolve a new key. Replacing a cached value in place can make one request observe two different definitions during its lifetime. Immutable versioned keys provide a clearer migration path and make rollback possible.
Track the active versions so obsolete flyweights can be retired safely.
Measure retirement behavior during rolling deployments too.