Flyweight Pattern #

A real-time game shows a forest with ten thousand trees on screen. Every tree is of the same type — identical bark texture, the same leaf mesh, the same color, similar average height. The only difference is each tree’s position on the map. Without optimization, the game would allocate ten thousand Tree objects, each storing a copy of 2MB of texture data — 20GB total just for trees. With the Flyweight Pattern, the identical data is stored once and shared by all ten thousand trees; only position coordinates are allocated per tree. Memory consumption drops from 20GB to 2MB plus ten thousand tiny coordinate structs. That is the power of Flyweight — not just a performance optimization, but what makes large-scale systems runnable at all.

What Is the Flyweight Pattern? #

The Flyweight Pattern is a structural design pattern that reduces memory consumption by sharing objects that have identical state, instead of creating a fresh copy for every use. It works by splitting an object’s data into two fundamentally different categories:

  • Intrinsic state — data that never changes and can be shared across many contexts: character type, font, texture, configuration, template
  • Extrinsic state — data that differs per context and must be supplied from outside: position, instance color, ID, timestamp

A flyweight object only stores intrinsic state — which is guaranteed immutable and shareable. Extrinsic state is passed in when an operation is called, not stored inside the object.

Three properties define the Flyweight:

  • Shared and immutable — flyweight objects are shared by many clients; no one may modify them
  • Factory as gatekeeper — clients never create flyweights directly; they always go through the factory that manages the pool
  • Extrinsic state is injected — per-context data is passed when a method is called, not stored in the flyweight
flowchart LR
    subgraph "Without Flyweight — 10,000 objects"
        direction TB
        T1["Tree 1\\n- texture: 2MB\\n- mesh: 1MB\\n- x: 100, y: 200"]
        T2["Tree 2\\n- texture: 2MB (copy!)\\n- mesh: 1MB (copy!)\\n- x: 350, y: 80"]
        T3["Tree N\\n- texture: 2MB (copy!)\\n- mesh: 1MB (copy!)\\n- x: ..., y: ..."]
        note1["Total: 10,000 × 3MB = ~30GB"]
    end

    subgraph "With Flyweight — 1 flyweight + 10,000 contexts"
        direction TB
        FW["TreeFlyweight\\n- texture: 2MB (shared)\\n- mesh: 1MB (shared)"]
        C1["TreeContext 1\\n- x: 100, y: 200"]
        C2["TreeContext 2\\n- x: 350, y: 80"]
        C3["TreeContext N\\n- x: ..., y: ..."]
        C1 & C2 & C3 -->|reference| FW
        note2["Total: 3MB + 10,000 × ~16B = ~3MB"]
    end

Intrinsic vs Extrinsic: How to Separate State #

Separating intrinsic and extrinsic state is the most important design decision in the Flyweight Pattern. Separating it wrong can make the pattern ineffective or even dangerous.

Question for every field:

"Is this value the SAME for all instances of the same 'type'?"

If YES  → Intrinsic state → store in the Flyweight (shared, immutable)
If NO   → Extrinsic state → supply it when the method is called

Examples of the split for several domains:

DomainIntrinsic (stored in Flyweight)Extrinsic (supplied from outside)
Text editorCharacter, font, size, default colorX/Y position, highlight color, selected
Game particleTexture, color, size, spritePosition, velocity, age, opacity
Tree mapTree type, mesh, texture, average heightX/Z position, rotation, instance scale
Icon systemImage data, formatDisplay size, position, overlay color
Email templateHTML template, CSS, variable placeholdersRecipient name, links, dynamic content
// Wrong way: all data in one struct — no sharing
type Bullet struct {
    // Intrinsic — the same for all bullets of the same type
    SpritePath string    // ← should be in the Flyweight
    Damage     int       // ← should be in the Flyweight
    Speed      float64   // ← should be in the Flyweight

    // Extrinsic — different for every bullet
    X, Y    float64
    VelX, VelY float64
    OwnerID string
}
// 1000 bullets = 1000 copies of SpritePath, Damage, Speed

// Correct way: separate intrinsic and extrinsic
type BulletType struct { // Flyweight — shared
    SpritePath string
    Damage     int
    Speed      float64
}

type BulletInstance struct { // Context — per instance
    bulletType *BulletType // reference to the flyweight
    X, Y       float64
    VelX, VelY float64
    OwnerID    string
}
// 1000 bullets = 1 BulletType + 1000 BulletInstance (much smaller)

Structure and Components #

classDiagram
    class Flyweight {
        <<interface>>
        +Render(x, y int, extrinsic ExtrinsicState)
    }

    class ConcreteFlyweight {
        -char string
        -font string
        -color string
        +Render(x, y int, extrinsic ExtrinsicState)
        +GetIntrinsicState() IntrinsicState
    }

    class FlyweightFactory {
        -pool map[string]Flyweight
        -mu sync.RWMutex
        +Get(key string) Flyweight
        +Count() int
    }

    class Client {
        -factory FlyweightFactory
        -contexts []Context
        +AddChar(char, font string, x, y int)
        +Render()
    }

    Flyweight <|.. ConcreteFlyweight
    FlyweightFactory o-- Flyweight : manages pool
    Client --> FlyweightFactory : requests flyweight
    Client --> Flyweight : uses with extrinsic state
ComponentRoleThread-safety
Flyweight interfaceContract for all flyweightsN/A
ConcreteFlyweightStores intrinsic state; immutable after creationSafe — immutable
FlyweightFactoryManages the pool; creates or returns flyweightsNeeds a mutex
ClientStores extrinsic state; uses flyweights via the factoryDepends on implementation

Full Implementation: Text Editor #

The text editor is the classic Flyweight use case — a long document can have millions of characters, but only hundreds of unique combinations (character × font × size).

Flyweight: CharacterGlyph #

package texteditor

import "fmt"

// GlyphStyle defines a character's visual appearance.
// This is intrinsic state — the same for all instances of the same character.
type GlyphStyle struct {
    Font      string
    Size      int
    Bold      bool
    Italic    bool
    BaseColor string
}

// CharacterGlyph is the Flyweight — it stores data identical for all
// occurrences of the same character with the same style.
// IMPORTANT: this struct is immutable after creation; there are no setters.
type CharacterGlyph struct {
    char  rune
    style GlyphStyle
}

// ExtrinsicState stores data that differs for every character occurrence.
// This is NOT stored in the CharacterGlyph — it is supplied when Render is called.
type ExtrinsicState struct {
    X, Y            int
    SelectionColor  string
    IsSelected      bool
}

// Render displays the character at the given position.
// Extrinsic state (position, selection) comes from outside, not stored in the glyph.
func (g *CharacterGlyph) Render(state ExtrinsicState) {
    color := g.style.BaseColor
    if state.IsSelected && state.SelectionColor != "" {
        color = state.SelectionColor
    }

    style := ""
    if g.style.Bold {
        style += "bold "
    }
    if g.style.Italic {
        style += "italic"
    }

    fmt.Printf("Render '%c' [%s %s%dpt %s] at (%d,%d)\n",
        g.char,
        g.style.Font,
        style,
        g.style.Size,
        color,
        state.X, state.Y,
    )
}

// Char returns the character this glyph represents.
func (g *CharacterGlyph) Char() rune { return g.char }

// Style returns the glyph's style.
func (g *CharacterGlyph) Style() GlyphStyle { return g.style }

// MemorySize estimates this glyph's memory footprint in bytes (for demonstration).
func (g *CharacterGlyph) MemorySize() int {
    return 4 + // rune (char)
        len(g.style.Font) +
        4 + // int (size)
        1 + // bool (bold)
        1 + // bool (italic)
        len(g.style.BaseColor)
}

FlyweightFactory: GlyphPool #

package texteditor

import (
    "fmt"
    "sync"
)

// GlyphPool is the FlyweightFactory — it manages the CharacterGlyph pool.
// Thread-safe for use from multiple goroutines.
type GlyphPool struct {
    mu    sync.RWMutex
    glyphs map[string]*CharacterGlyph
}

// NewGlyphPool creates an empty, ready-to-use pool.
func NewGlyphPool() *GlyphPool {
    return &GlyphPool{
        glyphs: make(map[string]*CharacterGlyph),
    }
}

// Get returns the flyweight for the requested character + style combination.
// If it already exists in the pool, the old one is returned. If not, a new one is created.
func (p *GlyphPool) Get(char rune, style GlyphStyle) *CharacterGlyph {
    key := buildKey(char, style)

    // Optimistic read — most cases are cache hits
    p.mu.RLock()
    if glyph, ok := p.glyphs[key]; ok {
        p.mu.RUnlock()
        return glyph
    }
    p.mu.RUnlock()

    // Acquire the write lock to create a new glyph
    p.mu.Lock()
    defer p.mu.Unlock()

    // Double-check after acquiring the write lock — race condition prevention
    if glyph, ok := p.glyphs[key]; ok {
        return glyph
    }

    glyph := &CharacterGlyph{char: char, style: style}
    p.glyphs[key] = glyph
    return glyph
}

// Count returns the number of unique flyweights stored in the pool.
func (p *GlyphPool) Count() int {
    p.mu.RLock()
    defer p.mu.RUnlock()
    return len(p.glyphs)
}

// TotalMemory returns an estimate of the total memory used by the pool (bytes).
func (p *GlyphPool) TotalMemory() int {
    p.mu.RLock()
    defer p.mu.RUnlock()
    total := 0
    for _, g := range p.glyphs {
        total += g.MemorySize()
    }
    return total
}

// buildKey creates a unique key for a character + style combination.
func buildKey(char rune, style GlyphStyle) string {
    boldStr := "0"
    if style.Bold {
        boldStr = "1"
    }
    italicStr := "0"
    if style.Italic {
        italicStr = "1"
    }
    return fmt.Sprintf("%d|%s|%d|%s|%s|%s",
        char, style.Font, style.Size, boldStr, italicStr, style.BaseColor)
}

Client: Document #

package texteditor

// CharacterEntry links a flyweight with its extrinsic state.
// This is what is stored per character — much smaller than storing all the data.
type CharacterEntry struct {
    glyph *CharacterGlyph // reference to the flyweight (shared)
    state ExtrinsicState  // extrinsic state (per instance)
}

// Document is the client that manages many characters using Flyweight.
type Document struct {
    pool       *GlyphPool
    characters []CharacterEntry
    cursorX    int
    cursorY    int
    lineHeight int
    charWidth  int
}

// NewDocument creates a new document with the configured glyph pool.
func NewDocument(pool *GlyphPool, lineHeight, charWidth int) *Document {
    return &Document{
        pool:       pool,
        characters: make([]CharacterEntry, 0),
        lineHeight: lineHeight,
        charWidth:  charWidth,
    }
}

// AddChar adds a character to the document with the given style.
// The position is calculated automatically based on typing order.
func (d *Document) AddChar(char rune, style GlyphStyle) {
    // Get the flyweight from the pool — create a new one only if it does not exist
    glyph := d.pool.Get(char, style)

    entry := CharacterEntry{
        glyph: glyph,
        state: ExtrinsicState{
            X: d.cursorX,
            Y: d.cursorY,
        },
    }
    d.characters = append(d.characters, entry)

    // Update the cursor position
    if char == '\n' {
        d.cursorX = 0
        d.cursorY += d.lineHeight
    } else {
        d.cursorX += d.charWidth
    }
}

// AddText adds a string of text with the same style for all characters.
func (d *Document) AddText(text string, style GlyphStyle) {
    for _, char := range text {
        d.AddChar(char, style)
    }
}

// SetSelection marks a range of characters as selected.
func (d *Document) SetSelection(start, end int, selectionColor string) {
    for i := start; i < end && i < len(d.characters); i++ {
        d.characters[i].state.IsSelected = true
        d.characters[i].state.SelectionColor = selectionColor
    }
}

// Render displays the entire document on screen.
func (d *Document) Render() {
    for _, entry := range d.characters {
        entry.glyph.Render(entry.state)
    }
}

// CharCount returns the number of characters in the document.
func (d *Document) CharCount() int { return len(d.characters) }

// MemoryReport displays a memory usage report.
func (d *Document) MemoryReport() {
    uniqueGlyphs := d.pool.Count()
    poolMemory := d.pool.TotalMemory()
    // Each CharacterEntry: pointer (8B) + ExtrinsicState (~32B) ≈ 40B
    contextMemory := len(d.characters) * 40

    fmt.Printf("\n=== Memory Report ===\n")
    fmt.Printf("Total characters: %d\n", len(d.characters))
    fmt.Printf("Unique glyphs in pool: %d\n", uniqueGlyphs)
    fmt.Printf("Pool memory: %d bytes\n", poolMemory)
    fmt.Printf("Context memory: %d bytes\n", contextMemory)
    fmt.Printf("Total memory: %d bytes\n", poolMemory+contextMemory)
    fmt.Printf("Without Flyweight (est): %d bytes\n", len(d.characters)*(poolMemory/uniqueGlyphs+40))
}

Usage: Demonstrating the Savings #

func main() {
    pool := texteditor.NewGlyphPool()
    doc := texteditor.NewDocument(pool, 20, 10)

    // Commonly used styles
    normalStyle := texteditor.GlyphStyle{Font: "Arial", Size: 12, BaseColor: "#000000"}
    boldStyle   := texteditor.GlyphStyle{Font: "Arial", Size: 12, Bold: true, BaseColor: "#000000"}
    headerStyle := texteditor.GlyphStyle{Font: "Arial", Size: 18, Bold: true, BaseColor: "#1a1a2e"}

    // Add a heading
    doc.AddText("Financial Report Q1 2024\n", headerStyle)

    // Add thousands of lines of text with normal and bold styles
    for i := 0; i < 500; i++ {
        doc.AddText("Revenue: ", boldStyle)
        doc.AddText("Rp 1,500,000,000\n", normalStyle)
    }

    // Despite thousands of characters, the pool only stores unique glyphs
    fmt.Printf("Total characters: %d\n", doc.CharCount())
    fmt.Printf("Unique glyphs in pool: %d\n", pool.Count())

    // Mark some text as selected
    doc.SetSelection(0, 24, "#4a90d9")

    // Show the memory report
    doc.MemoryReport()
}

The output shows the efficiency:

Total characters: 8024
Unique glyphs in pool: 31  ← only 31 unique glyphs out of 8024 characters!

=== Memory Report ===
Total characters: 8024
Unique glyphs in pool: 31
Pool memory: ~1,200 bytes
Context memory: ~320,960 bytes
Total memory: ~322,160 bytes
Without Flyweight (est): ~2,816,424 bytes  ← ~8.7x bigger!

Second Case Study: Game Particle System #

The particle system is the most dramatic Flyweight use case in terms of memory savings — an explosion effect can produce 50,000 particles per second.

package particle

import (
    "fmt"
    "sync"
    "time"
)

// ParticleType is the Flyweight — it stores data identical for all particles
// of the same type (fire, smoke, spark, snow).
type ParticleType struct {
    Name        string
    SpritePath  string  // path to the texture file — can be several MB
    FrameCount  int     // number of animation frames
    BlendMode   string  // "additive", "alpha", "multiply"
    DefaultSize float64
}

// Render draws one particle with the state supplied from outside.
func (pt *ParticleType) Render(x, y, size, opacity, rotation float64) {
    fmt.Printf("[%s] pos=(%.0f,%.0f) size=%.1f opacity=%.2f rot=%.0f°\n",
        pt.Name, x, y, size, opacity, rotation)
}

// ParticleTypeRegistry is the FlyweightFactory for particle types.
type ParticleTypeRegistry struct {
    mu    sync.RWMutex
    types map[string]*ParticleType
}

func NewParticleTypeRegistry() *ParticleTypeRegistry {
    return &ParticleTypeRegistry{
        types: make(map[string]*ParticleType),
    }
}

// Register adds a new particle type to the registry.
func (r *ParticleTypeRegistry) Register(name, spritePath, blendMode string, frameCount int, defaultSize float64) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.types[name] = &ParticleType{
        Name:        name,
        SpritePath:  spritePath,
        FrameCount:  frameCount,
        BlendMode:   blendMode,
        DefaultSize: defaultSize,
    }
}

// Get fetches a particle type from the registry.
func (r *ParticleTypeRegistry) Get(name string) (*ParticleType, bool) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    pt, ok := r.types[name]
    return pt, ok
}

// ParticleInstance is the Context — it stores per-particle extrinsic state.
// This struct is very small because all heavy data lives in ParticleType (shared).
type ParticleInstance struct {
    particleType *ParticleType // reference to the flyweight — only 8 bytes (pointer)
    X, Y         float64       // position
    VelX, VelY   float64       // velocity
    Size         float64       // current size (can differ from the default)
    Opacity      float64       // transparency (0-1)
    Rotation     float64       // rotation in degrees
    BornAt       time.Time     // birth time — to compute age
    TTL          time.Duration // time to live
}

// IsAlive checks whether the particle is still active.
func (p *ParticleInstance) IsAlive() bool {
    return time.Since(p.BornAt) < p.TTL
}

// Update updates the particle's position and opacity based on elapsed time.
func (p *ParticleInstance) Update(dt float64) {
    p.X += p.VelX * dt
    p.Y += p.VelY * dt
    // Fade out over time
    age := time.Since(p.BornAt).Seconds()
    totalLife := p.TTL.Seconds()
    p.Opacity = 1.0 - (age / totalLife)
    if p.Opacity < 0 {
        p.Opacity = 0
    }
}

// Render displays the particle using the flyweight type.
func (p *ParticleInstance) Render() {
    p.particleType.Render(p.X, p.Y, p.Size, p.Opacity, p.Rotation)
}

// ParticleSystem manages thousands of active particles.
type ParticleSystem struct {
    registry  *ParticleTypeRegistry
    particles []*ParticleInstance
}

func NewParticleSystem(registry *ParticleTypeRegistry) *ParticleSystem {
    return &ParticleSystem{
        registry:  registry,
        particles: make([]*ParticleInstance, 0, 10000),
    }
}

// Emit releases a number of particles from a given position.
func (ps *ParticleSystem) Emit(typeName string, x, y float64, count int) error {
    pt, ok := ps.registry.Get(typeName)
    if !ok {
        return fmt.Errorf("particle type %q not registered", typeName)
    }

    for i := 0; i < count; i++ {
        // Only a ParticleInstance is created per particle — ParticleType is shared
        p := &ParticleInstance{
            particleType: pt,                         // pointer to the flyweight
            X:            x + randomOffset(),
            Y:            y + randomOffset(),
            VelX:         randomVelocity(),
            VelY:         randomVelocity() - 2,       // slight upward bias
            Size:         pt.DefaultSize * randomScale(),
            Opacity:      1.0,
            Rotation:     randomRotation(),
            BornAt:       time.Now(),
            TTL:          randomTTL(),
        }
        ps.particles = append(ps.particles, p)
    }
    return nil
}

// Update updates all particles and removes the dead ones.
func (ps *ParticleSystem) Update(dt float64) {
    alive := ps.particles[:0] // reuse the slice without new allocation
    for _, p := range ps.particles {
        if p.IsAlive() {
            p.Update(dt)
            alive = append(alive, p)
        }
    }
    ps.particles = alive
}

// Render displays all active particles.
func (ps *ParticleSystem) Render() {
    for _, p := range ps.particles {
        p.Render()
    }
}

// Stats returns particle system statistics.
func (ps *ParticleSystem) Stats() (active, uniqueTypes int) {
    return len(ps.particles), ps.registry.Count()
}

func randomOffset() float64   { return float64(time.Now().UnixNano()%20 - 10) }
func randomVelocity() float64 { return float64(time.Now().UnixNano()%10-5) * 0.5 }
func randomScale() float64    { return 0.8 + float64(time.Now().UnixNano()%40)*0.01 }
func randomRotation() float64 { return float64(time.Now().UnixNano() % 360) }
func randomTTL() time.Duration { return time.Duration(500+time.Now().UnixNano()%1500) * time.Millisecond }

func (r *ParticleTypeRegistry) Count() int {
    r.mu.RLock()
    defer r.mu.RUnlock()
    return len(r.types)
}

Using the particle system:

func main() {
    registry := particle.NewParticleTypeRegistry()

    // Register particle types — done once at startup
    registry.Register("fire",   "assets/fire_sprite.png",   "additive", 16, 24.0)
    registry.Register("smoke",  "assets/smoke_sprite.png",  "alpha",    8,  32.0)
    registry.Register("spark",  "assets/spark_sprite.png",  "additive", 1,  4.0)
    registry.Register("debris", "assets/debris_sprite.png", "alpha",    1,  8.0)

    ps := particle.NewParticleSystem(registry)

    // Simulate an explosion — emit thousands of particles
    _ = ps.Emit("fire",   400, 300, 200)
    _ = ps.Emit("smoke",  400, 300, 100)
    _ = ps.Emit("spark",  400, 300, 500)
    _ = ps.Emit("debris", 400, 300, 50)

    active, uniqueTypes := ps.Stats()
    fmt.Printf("Active particles: %d, Unique types: %d\n", active, uniqueTypes)
    // "Active particles: 850, Unique types: 4"
    // 850 small ParticleInstances + 4 shared ParticleTypes

    // Game loop simulation
    for frame := 0; frame < 60; frame++ {
        ps.Update(1.0 / 60.0) // 60fps
    }
}

Thread Safety in the Factory #

A factory accessed from multiple goroutines must be thread-safe. There are two approaches in Go.

// Approach 1: sync.RWMutex — more granular control
type ThreadSafePool struct {
    mu   sync.RWMutex
    pool map[string]*CharacterGlyph
}

func (p *ThreadSafePool) Get(key string, createFn func() *CharacterGlyph) *CharacterGlyph {
    // Optimistic read — most requests are cache hits
    p.mu.RLock()
    if glyph, ok := p.pool[key]; ok {
        p.mu.RUnlock()
        return glyph
    }
    p.mu.RUnlock()

    // Write lock to create a new one — with double-check
    p.mu.Lock()
    defer p.mu.Unlock()
    if glyph, ok := p.pool[key]; ok { // re-check after locking
        return glyph
    }
    glyph := createFn()
    p.pool[key] = glyph
    return glyph
}


// Approach 2: sync.Map — simpler, suitable for read-heavy workloads
type SyncMapPool struct {
    pool sync.Map
}

func (p *SyncMapPool) Get(key string, createFn func() *CharacterGlyph) *CharacterGlyph {
    // LoadOrStore: atomic check-and-store
    actual, loaded := p.pool.LoadOrStore(key, createFn())
    if loaded {
        // Already exists — discard the one we just created
    }
    return actual.(*CharacterGlyph)
}
// Note: sync.Map.LoadOrStore still calls createFn() before the check
// For expensive creation, use sync.RWMutex with double-check


// Approach 3: singleflight — prevents multiple goroutines from creating the same thing
import "golang.org/x/sync/singleflight"

type SingleflightPool struct {
    group singleflight.Group
    mu    sync.RWMutex
    pool  map[string]*CharacterGlyph
}

func (p *SingleflightPool) Get(key string, createFn func() (*CharacterGlyph, error)) (*CharacterGlyph, error) {
    p.mu.RLock()
    if glyph, ok := p.pool[key]; ok {
        p.mu.RUnlock()
        return glyph, nil
    }
    p.mu.RUnlock()

    // singleflight ensures createFn is called only ONCE even when many
    // goroutines request the same key at the same time
    result, err, _ := p.group.Do(key, func() (interface{}, error) {
        return createFn()
    })
    if err != nil {
        return nil, err
    }
    glyph := result.(*CharacterGlyph)

    p.mu.Lock()
    p.pool[key] = glyph
    p.mu.Unlock()

    return glyph, nil
}

Testing Flyweight #

func TestGlyphPool_ReturnsSameInstanceForSameKey(t *testing.T) {
    pool := texteditor.NewGlyphPool()
    style := texteditor.GlyphStyle{Font: "Arial", Size: 12, BaseColor: "#000"}

    glyph1 := pool.Get('A', style)
    glyph2 := pool.Get('A', style)

    // Must be the exact same pointer — not just equal values
    if glyph1 != glyph2 {
        t.Error("expected same pointer for same key, got different instances")
    }
    if pool.Count() != 1 {
        t.Errorf("expected 1 glyph in pool, got %d", pool.Count())
    }
}

func TestGlyphPool_DifferentStylesCreateDifferentGlyphs(t *testing.T) {
    pool := texteditor.NewGlyphPool()

    normalStyle := texteditor.GlyphStyle{Font: "Arial", Size: 12}
    boldStyle   := texteditor.GlyphStyle{Font: "Arial", Size: 12, Bold: true}

    glyph1 := pool.Get('A', normalStyle)
    glyph2 := pool.Get('A', boldStyle)

    if glyph1 == glyph2 {
        t.Error("expected different instances for different styles")
    }
    if pool.Count() != 2 {
        t.Errorf("expected 2 glyphs in pool, got %d", pool.Count())
    }
}

func TestGlyphPool_ThreadSafety(t *testing.T) {
    pool := texteditor.NewGlyphPool()
    style := texteditor.GlyphStyle{Font: "Arial", Size: 12}
    chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")

    var wg sync.WaitGroup
    for goroutine := 0; goroutine < 100; goroutine++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for _, char := range chars {
                _ = pool.Get(char, style)
            }
        }()
    }
    wg.Wait()

    // After 100 goroutines finish, the pool must only hold len(chars) glyphs
    if pool.Count() != len(chars) {
        t.Errorf("expected %d unique glyphs, got %d (possible duplicate creation)", len(chars), pool.Count())
    }
}

func TestDocument_FlyweightReducesUniqueObjects(t *testing.T) {
    pool := texteditor.NewGlyphPool()
    doc := texteditor.NewDocument(pool, 20, 10)
    style := texteditor.GlyphStyle{Font: "Arial", Size: 12}

    // Add 1000 'A' characters
    for i := 0; i < 1000; i++ {
        doc.AddChar('A', style)
    }

    // Despite 1000 characters, the pool only has 1 glyph
    if pool.Count() != 1 {
        t.Errorf("expected 1 flyweight for 1000 identical chars, got %d", pool.Count())
    }
    if doc.CharCount() != 1000 {
        t.Errorf("expected 1000 characters in document, got %d", doc.CharCount())
    }
}

Flyweight vs Object Pool #

These two patterns are often confused because both involve “sharing objects”, but their purposes and mechanics are fundamentally different.

AspectFlyweightObject Pool
Main goalSave memory by sharing intrinsic stateSave creation cost by reusing resources
Object stateImmutable — never changesMutable — reset after being returned to the pool
Extrinsic stateSupplied from outside when a method is calledNone — all state lives in the object
LifecycleObjects live as long as the pool lives (very long)Objects are borrowed, used, returned
Best forLarge shareable data (texture, font, template)Expensive resources frequently created and destroyed (DB connections, workers)
Concurrent accessMultiple clients can use the same flyweight at onceOne client has exclusive access while using it

When to Use and When Not to #

USE Flyweight if:
  ✓ The application creates thousands to millions of objects of the same type
  ✓ Many objects carry identical data (intrinsic state)
  ✓ Memory usage is a measurable bottleneck
  ✓ Objects can be safely shared between clients (no shared mutable state)
  ✓ Heavy data (texture, template, config) can be separated from light data (position, ID)

AVOID Flyweight if:
  ✗ The number of objects is small — the complexity overhead is not justified
  ✗ There is no state that can be shared between objects
  ✗ The intrinsic/extrinsic split feels forced — an awkward design
  ✗ There is no evidence memory is a real problem (premature optimization)

Flyweight and Mutable State Cannot Coexist

If even one client modifies a flyweight object, every other client using the same flyweight is affected without realizing it. This is a race condition that is very hard to detect. Make sure flyweight fields are completely immutable — no setters, no pointers to slices or maps that could be modified.


Flyweight Review Checklist #

DESIGN:
  □ Intrinsic and extrinsic state are clearly identified and separated
  □ The flyweight object is immutable after creation — no setters, unexported fields
  □ Extrinsic state is supplied when a method is called, not stored in the flyweight
  □ Clients cannot create flyweights directly — only through the factory

FACTORY:
  □ The factory is thread-safe for use from multiple goroutines
  □ Double-check locking or singleflight prevents duplicate creation
  □ The factory provides pool inspection methods (Count, TotalMemory)

MEASUREMENT:
  □ There is a memory baseline measured before and after applying Flyweight
  □ The memory savings are proven significant — not premature optimization

TESTING:
  □ Test that the same key returns an identical pointer
  □ Test that different keys return different instances
  □ Test thread-safety with multiple goroutines
  □ Test that flyweights are never modified after creation (immutability)

Summary #

  • Flyweight reduces memory by sharing intrinsic state — identical data is stored once and referenced by thousands of contexts, not copied over and over.
  • Two kinds of state: intrinsic (immutable, shared, stored in the flyweight) and extrinsic (per-context, differs per instance, supplied when a method is called).
  • How to split: ask “is this value the same for all instances of the same type?” — if yes, it is intrinsic; if no, it is extrinsic.
  • The factory is a mandatory gatekeeper — clients must not create flyweights directly; the factory ensures one flyweight per key and manages the pool lifecycle.
  • Thread safety in the factory is critical — use sync.RWMutex with double-check or singleflight to prevent duplicate creation when multiple goroutines request the same key.
  • Immutability is an absolute requirement — one modification to a flyweight affects every client using it; this can become a race condition that is extremely hard to detect.
  • Flyweight vs Object Pool: Flyweight is for large data shared simultaneously; Object Pool is for resources borrowed exclusively and then returned.
  • Use it only when there is a real need — measure memory consumption first; Flyweight adds design complexity that is only worth it when the memory savings are significant.

← Previous: Facade   Next: Proxy →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact