Skip to content
aipatterns.dev

Design patternStructural Patterns

Flyweight

Share one immutable object for state that many objects have in common, and pass the per-object rest in, to cut memory when instances number in the millions.

Complexity
High
Seen in the wild
Low
Also known as
Interning, Hash Consing

Intent

Fit far more objects in memory by splitting each object's state in two. The intrinsic part, identical across many objects, moves into a shared, immutable flyweight. The extrinsic part, unique to each object, stays with the object or is passed into the flyweight's methods. A factory hands out one flyweight per distinct intrinsic value.

Forces

  • Millions of fine-grained objects vs finite memory: a spreadsheet with 600,000 cells cannot afford 600,000 copies of the same font, size and number format.
  • Sharing vs mutability: a shared object that one owner changes silently changes for every other owner.
  • Object-oriented clarity vs memory layout: each cell "having a style" reads well; each cell owning a style wastes space.
  • Saving memory vs spending CPU: lookups in the flyweight pool and passing extrinsic state around cost time.

Therefore

Move the repeated, immutable state into a shared object, obtain instances only through a factory that returns an existing one when an equal value is requested, and keep or pass the varying state outside it.

The flyweight must be immutable. Without that, sharing is a bug. The factory is a map from the intrinsic value to the one shared instance. In many languages this is a few lines, and sometimes the runtime already does it for you (string interning, enums, small integer caches).

Structure

Diagram source (Mermaid)
classDiagram
  class CellStyle {
    -String font
    -int sizePt
    -boolean bold
    -String numberFormat
    +format(value) String
  }
  class StyleRegistry {
    -Map pool
    +get(font, sizePt, bold, numberFormat) CellStyle
    +size() int
  }
  class Cell {
    -int row
    -int col
    -double value
    -CellStyle style
  }
  class Sheet {
    -Cell[] cells
    +set(row, col, value, style) void
  }
  StyleRegistry o-- CellStyle : pool
  Cell --> CellStyle : shares
  Sheet *-- Cell
  Sheet ..> StyleRegistry

Participants

  • Flyweight (CellStyle): immutable intrinsic state. Methods take extrinsic state as arguments (format(value)).
  • Flyweight factory (StyleRegistry): returns the existing instance for an equal key, or creates and stores a new one.
  • Context (Cell): holds the extrinsic state (position, value) and a reference to a shared flyweight.
  • Client (Sheet): creates contexts and gets flyweights only through the factory, never with new.

Use it when

  • A profiler or heap dump shows millions of objects with duplicated field values dominating memory.
  • Most of each object's state can be made immutable and is shared by many objects: styles, glyph metrics, tile types, map-marker icons, parsed schema fragments.
  • The remaining per-object state is small or can be computed from context (position, index).
  • Object identity does not matter for the shared part; equal values may be the same instance.

Do not use it when

  • You have not measured memory pressure. Thousands, or even a few hundred thousand, small objects are cheap on modern runtimes. Profile first; flyweight without a measured problem is complexity for nothing.
  • The language or runtime already deduplicates the value. Use string interning (sys.intern, String.intern() with care), an enum, or a frozen constant table instead.
  • The shared part is mutable or would need per-owner changes. Copy-on-write or plain separate objects are safer.
  • A columnar layout fits better. Storing a uint16 style index per cell in a parallel array (struct of arrays) saves more memory than object references and needs no pattern vocabulary.
  • You only need to cache expensive results. That is memoization (functools.cache, a Map), not object sharing.

Application grill

Inspect first: the measured object counts and heap profile first - this pattern is for the millions, not the thousands; the state that repeats (immutable strings, geometry, icons, parsed configs); the language's built-in interning (string pools, Symbol.for, constant objects).

Missing pieces: none beyond the language: an intern map keyed by the shared state. Measure instance count and memory before and after; if the numbers are not in the millions, the map is overhead.

Ask before you build:

  • Scale: what is the measured instance count and per-instance memory today?
  • Performance: does the intern lookup (a hash on the hot path) cost more than the memory it saves?
  • Reliability: the shared state must stay immutable - who enforces that as the code evolves?
  • Budget: engineering time on a profile-driven optimization versus the memory it saves?
  • Implementation: what is the key that proves two instances can share (equality semantics), and where is it documented?

Implementation steps

  1. Measure. Capture a heap profile and identify the class whose instances and duplicated fields dominate.
  2. Split that class's fields into intrinsic (shared, never changes after creation) and extrinsic (per instance or computed from context).
  3. Create the flyweight class with only intrinsic fields, make it immutable, and give it value-based equality.
  4. Change methods that used extrinsic fields to take them as parameters.
  5. Write the factory: a map keyed by the intrinsic value. Make it thread-safe if it is shared across threads.
  6. Replace every new of the old class with a factory call, and measure again to confirm the saving.

Example

A spreadsheet engine loads a 200,000-row export. Every cell had its own style object, and the heap profile showed styles taking more memory than the values. Only four distinct styles exist.

type NumberFormat = "plain" | "currency" | "percent";

// Flyweight: intrinsic, shared, immutable.
class CellStyle {
  constructor(
    readonly font: string,
    readonly sizePt: number,
    readonly bold: boolean,
    readonly numberFormat: NumberFormat,
  ) {
    Object.freeze(this);
  }

  // Extrinsic state (the value) is passed in, never stored.
  format(value: number): string {
    switch (this.numberFormat) {
      case "currency": return `$${value.toFixed(2)}`;
      case "percent": return `${(value * 100).toFixed(1)}%`;
      default: return String(value);
    }
  }
}

class StyleRegistry {
  private readonly pool = new Map<string, CellStyle>();

  get(font: string, sizePt: number, bold: boolean, numberFormat: NumberFormat): CellStyle {
    const key = `${font}|${sizePt}|${bold}|${numberFormat}`;
    let style = this.pool.get(key);
    if (!style) {
      style = new CellStyle(font, sizePt, bold, numberFormat);
      this.pool.set(key, style);
    }
    return style;
  }

  get size(): number {
    return this.pool.size;
  }
}

// Context: per-cell state plus a reference to a shared style.
interface Cell { row: number; col: number; value: number; style: CellStyle }

const styles = new StyleRegistry();
const sheet: Cell[] = [];
for (let row = 0; row < 200_000; row++) {
  sheet.push({ row, col: 0, value: 19.99 + row, style: styles.get("Inter", 11, false, "currency") });
  sheet.push({ row, col: 1, value: 0.08, style: styles.get("Inter", 11, false, "percent") });
  sheet.push({ row, col: 2, value: row % 7, style: styles.get("Inter", 11, row % 7 === 0, "plain") });
}

console.log(`${sheet.length} cells share ${styles.size} style objects`); // 600000 cells share 4
const cell = sheet[3];
console.log(cell.style.format(cell.value), cell.style === sheet[0].style); // $20.99 true

In Go or any language with value types, consider going one step further: store a small style index in each cell and keep the styles in a slice. That is how spreadsheet file formats store styles on disk.

Trade-offs

Gains

  • Memory drops from one copy per object to one copy per distinct value.
  • Equality checks on flyweights can use identity, which is fast.
  • Fewer allocations reduce garbage-collector pressure in managed runtimes.

Costs

  • Immutability is mandatory. Editing a cell's style now means asking the factory for a different flyweight, not mutating a field.
  • Extrinsic state must be passed around, which makes method signatures longer and code harder to follow.
  • The factory's pool is a cache that never shrinks unless you add eviction, and it needs locking if shared across threads.
  • The savings are invisible in code review and easy to break by a later new CellStyle(...).

Relations

  • Composite trees with many identical leaves can share them as flyweights, as long as leaves hold no parent pointer.
  • Singleton has exactly one instance of a class. Flyweight has one instance per distinct intrinsic value, and many of them.
  • Factory Method is the natural home for the "return an existing instance" logic.
  • Proxy can make a heavy object cheap to hold by deferring it. Flyweight makes many objects cheap by sharing them.
  • State objects with no fields are often shared as flyweights across all contexts.

Agent checklist

  • A heap profile or measurement shows the duplicated state is a real memory problem.
  • The flyweight class is immutable and has value-based equality and hashing.
  • Every construction goes through the factory; a search for direct constructor calls returns only the factory.
  • The factory is thread-safe if used from more than one thread.
  • You compared against simpler options: built-in interning, an enum, or an index into a table.
  • Memory was measured again after the change and the saving is recorded.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.