| Lesson 8 | Flyweight Consequences |
| Objective | Evaluate the benefits, costs, and operational risks of Flyweight. |
The Flyweight Pattern: Consequences and Trade-offs in Software Design
Flyweight trades a self-contained object model for shared immutable state,
factory lookup, and client-managed context. Its headline benefit is reduced
duplication, but the complete consequence includes memory, CPU, encapsulation,
identity, concurrency, and lifecycle. The pattern is successful only when
measurements show that the resource savings justify those additional design
obligations.
Positive Consequences
When logical object count is high and intrinsic cardinality is low, one
flyweight can replace thousands of repeated descriptions. Retained heap and
allocation rate fall. Garbage collection may run less often, and validated or
precomputed intrinsic data is produced once. Canonical values can also improve
consistency because every client receives the same immutable representation.
The state split can clarify the domain. A VehicleType describes
reusable facts, while a VehicleEvent represents one arrival at an
intersection. Tests can exercise those roles independently. A factory provides
one location for key normalization, validation, and creation metrics.
Costs Introduced by Sharing
Every retrieval requires key construction and lookup. Under contention, a
shared map may become a throughput limit. Clients must carry extrinsic state
and provide it to operations, which can lengthen parameter lists or create
context objects. Developers must understand that one reference can represent
many logical occurrences, so reference identity is no longer occurrence
identity.
The factory itself retains keys, values, and map overhead. An unbounded key
space turns the optimization into a memory leak. Weak references or eviction
can limit retention, but they complicate identity and testing. These costs
should be included in benchmarks instead of comparing only the size of one
flyweight with one original object.
Encapsulation and Correctness
Moving context outside the object can weaken encapsulation if unrelated
callers must assemble a large collection of fields. Keep extrinsic state in a
cohesive domain object and pass only what the operation needs. If behavior
repeatedly requires nearly every field that was removed, the state split may
be wrong.
Shared intrinsic state should be deeply immutable. Final fields are
insufficient when they reference mutable collections. Defensive copies,
unmodifiable views, const-correct C++ interfaces, and restricted construction
protect the invariant. A mutation defect has a wide blast radius because every
logical object that uses the flyweight can observe it.
Equality and Identity Consequences
Canonical factories often return the same physical instance for equal keys.
That property is useful for testing the factory but should not become the
domain's general equality rule. Two queued cars can share one type and still
be different events. Conversely, an evicting factory may later construct an
equal flyweight at a different address. Domain code should compare explicit
event identifiers or value equality as appropriate.
Concurrency Consequences
Immutable flyweights support concurrent reads naturally, but creation and
catalog management still require a thread-safe policy. Duplicate construction
may be harmless for values but violates strict canonical identity. Holding a
global lock during expensive creation can serialize traffic. Atomic
map operations, side-effect-free constructors, and load tests help establish
the correct behavior.
Extrinsic state is not protected merely because the flyweight is immutable.
Each client remains responsible for synchronization of queues, positions, or
simulation clocks. Separating the two state categories makes this boundary
easier to reason about.
Lifecycle and Operational Consequences
A factory scoped to one simulation run has predictable cleanup and isolation.
A process-wide catalog maximizes reuse but survives deployments, tests, and
tenant activity for the life of the process. Distributed replicas each have
their own object identities and catalog statistics. Replacing the factory with
a remote cache changes the problem into distributed consistency and must be
designed separately.
Monitor entry count, hit ratio, misses, creation latency, retained memory, and
evictions. Alert on unexpected cardinality rather than waiting for an
out-of-memory failure. A performance optimization without operational evidence
is an assumption that production eventually has to test.
Evaluate the Net Result
Compare a Flyweight implementation with a straightforward baseline under the
same workload. Record total retained bytes, allocation rate, garbage
collection time, operation latency, and throughput. Include startup and warmup
because a factory may shift work earlier. Use realistic key distributions:
a test with ten repeated keys cannot expose behavior when production accepts
millions of user-defined values.
Correctness comparisons are equally important. Both versions must produce the
same simulation outcomes for the same seed and event stream. Run concurrency
tests that use one flyweight with different contexts. Verify that an invalid
key fails predictably, factory failure does not publish a partial object, and
cleanup releases a run-scoped catalog. Performance gains do not compensate for
changed domain results.
Document a threshold for retaining the pattern. For example, require a
substantial reduction in retained heap without a material increase in
high-percentile latency. The exact numbers depend on the system's constraint,
but writing them down prevents selective interpretation. Repeat the comparison
after runtime upgrades or major changes in object volume.
Maintenance Consequences
Future developers must know which fields are intrinsic, how keys are
normalized, and who owns the factory. Put those facts near the implementation
and enforce them with tests. Avoid exposing a public constructor that bypasses
canonical creation unless non-shared values are an intentional option.
Flyweight should remain removable. Clients that depend on a semantic
VehicleType contract rather than a particular map can switch to
ordinary values if measurements change. This separation protects the domain
from an optimization decision and keeps the consequence reversible over the
system lifetime.
Memory Savings Versus Lookup Cost
Flyweight exchanges duplicated storage for indirection. Each logical object
keeps a reference or key, and each operation may require a factory lookup or
an additional pointer traversal. The trade is favorable when the intrinsic
state is large, the number of distinct values is small, and a flyweight is
reused many times. It is unfavorable when most values are unique or when a
hot operation repeatedly performs an expensive lookup.
Measure both sides. Compare retained heap or resident memory, allocation rate,
garbage-collection pressure, cache hit rate, and request latency. A benchmark
should use the distribution expected in production. A demonstration with one
million identical keys can exaggerate the benefit if real users create a much
larger key space.
Encapsulation and API Complexity
Moving mutable state out of an object can weaken encapsulation. The caller may
need to carry position, color, tenant, locale, or current speed and pass those
values into every operation. If clients can easily mix the wrong extrinsic
state with a shared value, the API has shifted memory cost into correctness
risk. A Context class can restore a coherent abstraction by pairing one
flyweight reference with the extrinsic state for one logical occurrence.
The API should name the distinction. Fields such as
VehicleSpec and VehicleState communicate more than
generic names such as data. Constructors and type systems should
make invalid combinations difficult. Generics, records, value types, and const
correctness can keep the optimized representation explicit without exposing
raw cache mechanics to every client.
Concurrency and Cache Lifecycle
Immutable flyweights simplify concurrent reads, but the factory still manages
shared mutable infrastructure. Duplicate construction may be harmless for a
small value, yet publishing more than one canonical instance breaks identity
assumptions. Use atomic insertion or build the catalog before concurrent
access. Avoid holding a global lock while performing slow I/O or expensive
construction.
Cache lifetime is another consequence. Strong references keep every value
alive until the cache is cleared. Weak references allow reclamation but can
make performance unpredictable. Bounded caches need an eviction policy, which
means two requests separated in time may receive different instances with
equal state. Code should depend on value equality unless stable identity is an
explicit and enforceable part of the contract.
Operational Consequences in Cloud Systems
An in-memory factory is replicated with every service instance. This is often
desirable because lookup is local and failure is isolated, but the total
memory saving must be calculated across all replicas. A distributed cache can
deduplicate data across processes only by adding serialization, network
latency, authentication, eviction, and partial failure.
Observability should include instance count, distinct key count, hit and miss
rates, construction time, memory retained by the cache, and rejected or
evicted entries. Alerting on rapid key growth can reveal a missing
normalization rule or an input that accidentally became part of intrinsic
state. Operational evidence tells the team whether Flyweight remains useful
as traffic and data distributions evolve.
Decision Summary
Adopt Flyweight when measurements show significant duplicated intrinsic state, the shared values can be immutable, and the key space is bounded or managed. Reject it when identity matters for every logical object, most state is unique,
or a compact value representation already meets the memory target. Revisit the decision after profiling because runtime improvements, workload changes, and new data fields can change the original trade-off.
Also account for developer cost. A smaller heap is not automatically a better system if every feature requires manually coordinating extrinsic state. Reviewers should be able to explain the key, factory lifetime, ownership, and
failure behavior without reverse engineering the cache. When those rules become harder than the memory problem, a compact value object is often the better design.
