BrandGhost
How to Implement Flyweight Pattern in C#: Step-by-Step Guide

How to Implement Flyweight Pattern in C#: Step-by-Step Guide

How to Implement Flyweight Pattern in C#: Step-by-Step Guide

Creating thousands or millions of objects that share overlapping data is one of the fastest ways to blow through memory in a C# application. The flyweight pattern in C# solves this by separating shared (intrinsic) state from unique (extrinsic) state -- so that objects sharing identical intrinsic data point to a single shared instance rather than duplicating it. If you've ever built a system with enormous collections of similar objects, this pattern can slash memory consumption without sacrificing clarity.

The flyweight pattern is a structural design pattern -- it sits alongside patterns like the decorator and adapter in the Gang of Four catalog. Where those patterns focus on adding behavior or bridging interfaces, the flyweight pattern focuses on efficient resource sharing. It's especially useful in game development, document rendering, and any domain where you need to represent large numbers of structurally similar objects.

In this step-by-step guide, we'll build a forest simulation that renders thousands of trees using the flyweight pattern. By the end, you'll understand how to identify shared state, build a flyweight factory, wire up client contexts, and measure the memory savings.

Prerequisites

Before diving in, make sure you're comfortable with these fundamentals:

  • C# basics: Classes, interfaces, dictionaries, and constructors. The examples below use standard C# constructs without any third-party dependencies.
  • Value vs reference semantics: Understanding when objects are allocated on the heap versus shared by reference is critical for grasping why the flyweight pattern saves memory.
  • Dependency injection awareness: The final steps show how to register a flyweight factory with IServiceCollection. Basic familiarity with DI registration helps.
  • .NET 8 or later: The code examples target modern C# syntax. Any recent .NET SDK will work.

Step 1: Identify Intrinsic vs Extrinsic State

The first and most important step when you implement the flyweight pattern is identifying which data is shared and which is unique. Get this wrong and the pattern either breaks or provides no benefit.

Intrinsic state is data that remains constant across many objects. It can be shared safely because it never changes per instance. Extrinsic state is data that varies between objects -- it must be supplied by the client at runtime.

For our forest simulation, imagine rendering 10,000 trees on a map. Each tree has properties like species name, color, and bark texture. Those don't change from tree to tree within the same species -- that's intrinsic state. But each tree has a unique position (X, Y coordinates) and a specific size -- that's extrinsic state.

Here's the breakdown:

Property State Type Why
Species name Intrinsic Same across all trees of a species
Color Intrinsic Defined by species, not per tree
Bark texture path Intrinsic Shared resource across same species
X coordinate Extrinsic Unique per tree placement
Y coordinate Extrinsic Unique per tree placement
Size Extrinsic Varies per individual tree

This separation is the foundation of the flyweight pattern. If you can't find a clean split between shared and unique data, the flyweight pattern might not be the right tool for the problem.

Step 2: Define the IFlyweight Interface

With the state separation clear, define an interface that represents the shared flyweight object. The key design decision here is that the Operation method accepts extrinsic state as parameters rather than storing it internally. This is what makes flyweight objects reusable across many contexts.

public interface IFlyweight
{
    void Operation(ExtrinsicTreeState state);
}

public sealed class ExtrinsicTreeState
{
    public double X { get; }

    public double Y { get; }

    public double Size { get; }

    public ExtrinsicTreeState(
        double x,
        double y,
        double size)
    {
        X = x;
        Y = y;
        Size = size;
    }
}

The ExtrinsicTreeState class bundles the per-instance data into a single object. This keeps the Operation signature clean -- you pass one parameter instead of three or four loose arguments. The flyweight itself never stores this data. It only receives it during the operation call.

This approach aligns with how other structural patterns handle context passing. Just as the strategy pattern separates algorithms from the objects that use them, the flyweight pattern separates shared data from the context that varies.

Step 3: Create the ConcreteFlyweight Class

The ConcreteFlyweight holds intrinsic state -- the data shared across many objects. This class is immutable. Once created, its internal state never changes, which makes it safe to share across threads and contexts.

public sealed class TreeTypeFlyweight : IFlyweight
{
    public string Species { get; }

    public string Color { get; }

    public string BarkTexturePath { get; }

    public TreeTypeFlyweight(
        string species,
        string color,
        string barkTexturePath)
    {
        Species = species;
        Color = color;
        BarkTexturePath = barkTexturePath;
    }

    public void Operation(ExtrinsicTreeState state)
    {
        Console.WriteLine(
            $"Rendering {Species} tree " +
            $"(Color: {Color}, " +
            $"Texture: {BarkTexturePath}) " +
            $"at ({state.X}, {state.Y}) " +
            $"with size {state.Size}");
    }
}

A few things to note:

  • All properties are read-only. Immutability is non-negotiable for the flyweight pattern -- mutable shared state introduces race conditions and logical errors.
  • The Operation method combines intrinsic state (stored in the object) with extrinsic state (passed in as a parameter) to produce the full behavior.
  • The class is sealed because there's no reason to extend it. Each tree type is a data holder, not a base for inheritance.

In a production scenario, the intrinsic state might include heavier objects like loaded textures, compiled shaders, or parsed configuration data. The more expensive the intrinsic state is to create and store, the more value the flyweight pattern delivers.

Step 4: Build the FlyweightFactory

The factory is the gatekeeper of the flyweight pattern. It ensures that only one ConcreteFlyweight exists per unique combination of intrinsic state. When a client requests a flyweight, the factory either returns an existing instance from its cache or creates a new one and stores it.

public sealed class FlyweightFactory
{
    private readonly Dictionary<string, IFlyweight> _flyweights
        = new();

    public IFlyweight GetFlyweight(
        string species,
        string color,
        string barkTexturePath)
    {
        string key = $"{species}_{color}_{barkTexturePath}";

        if (!_flyweights.TryGetValue(key, out IFlyweight? flyweight))
        {
            flyweight = new TreeTypeFlyweight(
                species,
                color,
                barkTexturePath);
            _flyweights[key] = flyweight;

            Console.WriteLine(
                $"[Factory] Created new flyweight " +
                $"for '{key}'");
        }

        return flyweight;
    }

    public int FlyweightCount => _flyweights.Count;
}

The factory uses a Dictionary<string, IFlyweight> as its internal cache. The key is a composite string built from the intrinsic properties. A few considerations:

  • Key generation: Using string concatenation works for simple cases. For production code with complex keys, consider implementing IEquatable<T> on a dedicated key struct to avoid string allocation overhead.
  • Thread safety: This implementation is not thread-safe. If multiple threads request flyweights simultaneously, wrap access in a lock or use ConcurrentDictionary<string, IFlyweight> instead.
  • Lifecycle: The factory should be a singleton in your application. If you're using dependency injection, register it accordingly -- more on that in the final step.

The factory pattern here is central to what makes the flyweight pattern work. Without it, there's no mechanism to enforce sharing. Clients could create their own TreeTypeFlyweight instances directly, defeating the purpose entirely.

Step 5: Create the Client Context

The client context is the object that stores extrinsic state and holds a reference to a shared flyweight. In our example, each PlantedTree represents a specific tree on the map. It stores its own position and size but delegates species-related data to its flyweight.

public sealed class PlantedTree
{
    private readonly IFlyweight _treeType;

    private readonly ExtrinsicTreeState _state;

    public PlantedTree(
        IFlyweight treeType,
        double x,
        double y,
        double size)
    {
        _treeType = treeType;
        _state = new ExtrinsicTreeState(x, y, size);
    }

    public void Render()
    {
        _treeType.Operation(_state);
    }
}

Each PlantedTree is lightweight. It holds a reference to a shared flyweight (not a copy) and its own extrinsic state. When Render is called, it passes its extrinsic state to the flyweight, which combines both to produce the result.

This separation means you can have 10,000 PlantedTree objects that share just a handful of TreeTypeFlyweight instances. The contexts are cheap to create and store, while the expensive shared data lives in a few flyweight objects managed by the factory.

Let's put it all together:

var factory = new FlyweightFactory();

var forest = new List<PlantedTree>();
var random = new Random(42);

string[] species = { "Oak", "Pine", "Birch", "Maple" };
string[] colors = { "Green", "DarkGreen", "YellowGreen", "ForestGreen" };
string[] textures = { "oak_bark.png", "pine_bark.png", "birch_bark.png", "maple_bark.png" };

for (int i = 0; i < 10_000; i++)
{
    int typeIndex = random.Next(species.Length);

    IFlyweight treeType = factory.GetFlyweight(
        species[typeIndex],
        colors[typeIndex],
        textures[typeIndex]);

    var tree = new PlantedTree(
        treeType,
        x: random.NextDouble() * 1000,
        y: random.NextDouble() * 1000,
        size: 0.5 + random.NextDouble() * 2.0);

    forest.Add(tree);
}

Console.WriteLine(
    $"Total trees in forest: {forest.Count}");
Console.WriteLine(
    $"Unique flyweight objects: " +
    $"{factory.FlyweightCount}");

forest[0].Render();
forest[5000].Render();

Running this produces:

[Factory] Created new flyweight for 'Oak_Green_oak_bark.png'
[Factory] Created new flyweight for 'Pine_DarkGreen_pine_bark.png'
[Factory] Created new flyweight for 'Birch_YellowGreen_birch_bark.png'
[Factory] Created new flyweight for 'Maple_ForestGreen_maple_bark.png'
Total trees in forest: 10000
Unique flyweight objects: 4
Rendering Oak tree (Color: Green, Texture: oak_bark.png) at (255.47, 812.33) with size 1.82
Rendering Birch tree (Color: YellowGreen, Texture: birch_bark.png) at (409.18, 653.91) with size 2.14

Ten thousand trees, four flyweight objects. That's the flyweight pattern doing its job.

Step 6: Measure Memory Savings

Understanding the theoretical memory savings helps you decide when the flyweight pattern is worth the added complexity. Let's compare the before-and-after.

Without the Flyweight Pattern

Without the flyweight pattern, every tree object stores all its data -- both shared and unique properties:

public sealed class FullTree
{
    public string Species { get; }

    public string Color { get; }

    public string BarkTexturePath { get; }

    public double X { get; }

    public double Y { get; }

    public double Size { get; }

    public FullTree(
        string species,
        string color,
        string barkTexturePath,
        double x,
        double y,
        double size)
    {
        Species = species;
        Color = color;
        BarkTexturePath = barkTexturePath;
        X = x;
        Y = y;
        Size = size;
    }
}

For N trees, you allocate N full objects. Each FullTree stores its own copy of species, color, and texture path strings (or at least its own reference plus the object header overhead). With 10,000 trees and 4 species, that's 10,000 objects each carrying redundant string references and intrinsic data.

With the Flyweight Pattern

With the flyweight pattern, you allocate:

  • M flyweight objects (one per unique tree type -- in our case, 4)
  • N context objects (one PlantedTree per tree -- 10,000)

Each context holds a reference to its flyweight (8 bytes on a 64-bit system) plus its extrinsic state (three double fields = 24 bytes). The intrinsic data exists only M times instead of N times.

Comparison

Metric Without Flyweight With Flyweight
Full objects 10,000 0
Flyweight objects 0 4
Context objects 0 10,000
Intrinsic data copies 10,000 4
Extrinsic data copies 10,000 10,000

The savings scale linearly with the ratio of total objects to unique types. If you have 1,000,000 particles in a game with 20 particle types, the flyweight pattern stores 20 shared objects instead of duplicating particle definitions across a million instances.

Registering with Dependency Injection

For production applications, register the FlyweightFactory as a singleton through IServiceCollection so it's shared across your application. This aligns with inversion of control principles:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddSingleton<FlyweightFactory>();

var provider = services.BuildServiceProvider();

var factory = provider
    .GetRequiredService<FlyweightFactory>();

IFlyweight oak = factory.GetFlyweight(
    "Oak", "Green", "oak_bark.png");

The singleton lifetime ensures one factory instance manages the cache for the entire application. This prevents duplicate caches from forming in different scopes.

Common Mistakes to Avoid

Several mistakes frequently surface when developers first implement the flyweight pattern in C#.

Storing extrinsic state in the flyweight: This is the most fundamental mistake. If the flyweight holds per-instance data, it can't be shared. Every piece of data inside a flyweight object must be intrinsic -- constant and identical across all contexts that use it.

Making flyweights mutable: Shared mutable state is a recipe for bugs. If one consumer modifies a flyweight, every other consumer sees the change. Make flyweight classes immutable. Use readonly fields or init-only properties and sealed classes.

Skipping the factory: Without a factory to enforce caching, clients create their own instances and the sharing mechanism breaks down entirely. Always channel flyweight creation through a factory.

Over-engineering the key: Complex key generation with heavy string formatting can introduce its own performance overhead. For hot paths, consider using value types or hash-based lookups instead of string concatenation.

Applying the flyweight pattern to small collections: If you only have a dozen objects, the overhead of the factory and the interface indirection may cost more than it saves. The flyweight pattern shines when N (total objects) is orders of magnitude larger than M (unique types). It's similar to how the composite pattern is best suited for genuinely recursive tree structures rather than flat lists.

Frequently Asked Questions

What is the flyweight pattern and when should I use it in C#?

The flyweight pattern is a structural design pattern that minimizes memory usage by sharing common state across multiple objects. Use it in C# when you have large numbers of objects that share significant overlapping data -- such as game entities, document characters, or UI elements. The pattern is most effective when the ratio of total objects to unique intrinsic configurations is high.

How do I decide what counts as intrinsic vs extrinsic state?

Ask two questions for each piece of data. First, is this value the same across many instances? Second, can this value be supplied externally at the time of use? If the answer to both is yes, it's extrinsic. If the value is constant across a group and defines the "type" of the object, it's intrinsic. When in doubt, start by listing all properties and marking which ones repeat across instances.

Is the flyweight pattern thread-safe?

The flyweight objects themselves are thread-safe if they're immutable -- and they should be. The factory is a different story. A basic Dictionary-based factory needs synchronization. Use ConcurrentDictionary or wrap access in a lock if multiple threads request flyweights concurrently. The extrinsic state is owned by each context individually, so it has no shared-access concerns.

Can I combine the flyweight pattern with other design patterns?

Absolutely. The flyweight pattern pairs well with the composite pattern when building large tree structures where nodes share data. It also works alongside factory patterns (the flyweight factory itself is a factory) and can complement the strategy pattern when you want to share strategy instances across many consumers rather than creating new ones each time.

How does the flyweight pattern differ from object pooling?

Object pooling reuses mutable objects to avoid allocation costs -- objects are checked out, used, and returned. The flyweight pattern shares immutable objects simultaneously across many consumers. Pooled objects are exclusive to one consumer at a time; flyweight objects are shared concurrently. The goals overlap (reduce allocations) but the mechanisms are fundamentally different.

Does the flyweight pattern work with dependency injection in .NET?

Yes. Register the FlyweightFactory as a singleton in your DI container. Consumers inject the factory and request flyweights by intrinsic state parameters. The singleton lifetime ensures a single cache exists application-wide. You can also register individual flyweights if the set of types is known at startup, though the factory approach is more flexible for dynamic creation.

What are the drawbacks of the flyweight pattern?

The flyweight pattern adds complexity. You split your data model into two pieces (intrinsic and extrinsic), introduce a factory, and force clients to supply extrinsic state on every operation. For small object counts, this overhead outweighs the savings. The pattern also makes debugging harder because multiple contexts reference the same flyweight instance -- you can't inspect a single context and see all its data in one place. Use it when memory pressure justifies the trade-off, not as a default approach.

How to Implement Strategy Pattern in C#: Step-by-Step Guide

How to implement Strategy pattern in C#: step-by-step guide with code examples, best practices, and common pitfalls for behavioral design patterns.

How to Implement State Pattern in C#: Step-by-Step Guide

Learn how to implement state pattern in C# with a step-by-step guide covering state interfaces, concrete states, context classes, and transition logic.

Flyweight Design Pattern in C#: Complete Guide with Examples

Master the flyweight design pattern in C# with practical examples showing shared state optimization, memory reduction, and object pool management.

An error has occurred. This application may no longer respond until reloaded. Reload