Structural Advanced 7 min read

Flyweight Pattern in C#: Share Immutable Instances Sanely

Flyweight pattern in C# / .NET 10: share immutable instances to cut allocations on a hot path, with examples from string interning to product badge caches.

Table of contents
  1. What problem does the Flyweight pattern solve in C#?
  2. How does the Flyweight structure look in .NET 10?
  3. What does string.Intern have to do with Flyweight?
  4. When should you skip Flyweight?
  5. How does Flyweight compare to Singleton and object pooling?
  6. What does a real .NET 10 example look like?
  7. Where should you read next in this series?

The shop's product listing page renders a million product cards over the course of a Black Friday sale. Each card displays one of three badges: "New", "Sale", or "Limited". The first version of the code treats each badge as a fresh allocation:

foreach (var product in products)
{
    var badge = new ProductBadge(product.Tag, product.BadgeColor, product.BadgeIcon);
    Render(product, badge);
}

Allocation profiling reveals the obvious: a million ProductBadge objects, three distinct values among them. The other 999,997 are identical clones. Cutting that to three would buy back enough memory to delay the next pod-scaling event.

The Flyweight pattern is the answer. Hold one immutable instance per distinct value in a shared cache; every caller asking for "Sale" gets a reference to the same object. The pattern is rarely the thing beginners reach for first — and rightly so, because in modern C# the common cases are already handled (string.Intern, Enum, boxed constants). When you do need it, recognising the shape is what matters.

What problem does the Flyweight pattern solve in C#?

The pattern earns its place when a hot path allocates many identical immutable objects. Three concrete shapes:

  1. Repeated value objects. Product badges, log severity tags, country codes, currency symbols. Most of the variation is exhausted by a handful of values.
  2. Glyph caches in rendering pipelines. Drawing a million tiles of a Unicode character means looking up its glyph metadata over and over. One canonical instance per code point saves both allocation and lookup work.
  3. Read-only domain primitives. A Currency("USD") with name, symbol, and decimal places. There are 180 currencies in the world; allocating one per transaction is wasteful.

What is not a Flyweight problem: "I want to reuse mutable objects" — that is an object pool. "I want one shared service across the app" — that is Singleton. Flyweight is specifically about many distinct values (still many objects, but a small number of them, deduplicated).

How does the Flyweight structure look in .NET 10?

Two patterns cover the practical cases. The first is static readonly instances for a closed set:

public sealed record ProductBadge(string Label, string ColorHex, string Icon)
{
    public static readonly ProductBadge New     = new("New",     "#16a34a", "✨");
    public static readonly ProductBadge Sale    = new("Sale",    "#dc2626", "%");
    public static readonly ProductBadge Limited = new("Limited", "#9333ea", "⏳");

    public static ProductBadge ForTag(string tag) => tag switch
    {
        "new"     => New,
        "sale"    => Sale,
        "limited" => Limited,
        _         => throw new ArgumentException($"Unknown tag: {tag}")
    };
}

Three instances total. ForTag("sale") returns the same reference every time. Callers that compare badges with == get a fast reference comparison; the record semantics still give value equality if anyone needs it.

The second pattern is content-keyed cache for an open set whose distinct values you discover at runtime:

public sealed record Currency(string Code, string Symbol, int DecimalDigits)
{
    private static readonly ConcurrentDictionary<string, Currency> _cache = new();

    public static Currency Of(string code)
    {
        return _cache.GetOrAdd(code.ToUpperInvariant(), c => new Currency(
            c,
            ResolveSymbol(c),
            ResolveDigits(c)));
    }
}

The first call for "USD" allocates one instance and stores it; every subsequent call returns the cached one. Allocation count drops from one per call to one per distinct currency.

The structural picture:

flowchart LR
    P1[Product 1] --> B1
    P2[Product 2] --> B1
    P3[Product 3] --> B2
    P4[Product 4] --> B1
    P5[Product 5] --> B3
    P6[Product 6] --> B2

    subgraph Cache[Flyweight cache]
        B1[Badge.Sale]
        B2[Badge.New]
        B3[Badge.Limited]
    end

Six products, three shared badge instances. The structural diagram makes a Flyweight visible the way a class diagram cannot — the value is in the shared edges, not the class hierarchy.

What does string.Intern have to do with Flyweight?

System.String.Intern is the BCL's built-in Flyweight for strings. The CLR keeps a process-wide table of unique strings; Intern looks up a string by its content and returns the canonical reference. The C# compiler interns string literals for you, which is why "a" == "a" is a reference comparison that returns true.

var s1 = string.Intern("hello world");
var s2 = string.Intern("hel" + "lo " + "world");
Console.WriteLine(ReferenceEquals(s1, s2)); // True — same canonical instance

You almost never call Intern directly because the compiler does it for literals and string.IsInterned already does the lookup transparently. But it is the same pattern: one canonical reference per distinct value.

When should you skip Flyweight?

Flyweight is a profiling-driven optimisation. Three signs that reaching for it is premature:

A pragmatic rule: if you cannot point at a dotnet-counters Microsoft.AspNetCore.Hosting requests-per-second line that drops when you add Flyweight, you do not need it.

How does Flyweight compare to Singleton and object pooling?

Pattern What is shared Mutability Number of instances
Flyweight One instance per distinct value Immutable Few (usually < 100)
Singleton One instance for the whole process Often immutable, sometimes coordinated mutable Exactly one
Object pool A pool of interchangeable mutable instances Mutable, reset on return Many; capped by pool size

The differentiating sentence: Singleton is one instance for one service; Flyweight is one instance per distinct value of one type; object pool is many interchangeable instances with check-out/return discipline.

What does a real .NET 10 example look like?

A complete shape — both styles of Flyweight, applied to the product catalog domain. Notice how the consumer code does not change between "naive allocation" and "Flyweight" — only the factory call does:

public sealed record ProductBadge(string Label, string ColorHex, string Icon)
{
    public static readonly ProductBadge New     = new("New",     "#16a34a", "✨");
    public static readonly ProductBadge Sale    = new("Sale",    "#dc2626", "%");
    public static readonly ProductBadge Limited = new("Limited", "#9333ea", "⏳");

    private static readonly IReadOnlyDictionary<string, ProductBadge> _byTag =
        new Dictionary<string, ProductBadge>(StringComparer.OrdinalIgnoreCase)
        {
            ["new"]     = New,
            ["sale"]    = Sale,
            ["limited"] = Limited,
        };

    public static ProductBadge ForTag(string tag) =>
        _byTag.TryGetValue(tag, out var b) ? b
            : throw new ArgumentException($"Unknown tag: {tag}");
}

public sealed record Currency(string Code, string Symbol, int DecimalDigits)
{
    private static readonly ConcurrentDictionary<string, Currency> _cache = new();

    public static Currency Of(string code)
    {
        var key = code.ToUpperInvariant();
        return _cache.GetOrAdd(key, c => c switch
        {
            "USD" => new Currency(c, "$", 2),
            "EUR" => new Currency(c, "€", 2),
            "JPY" => new Currency(c, "¥", 0),
            _     => new Currency(c, c, 2),
        });
    }
}

// Usage on a hot rendering path
public string RenderCard(Product p)
{
    var badge    = ProductBadge.ForTag(p.Tag);          // one of three references
    var currency = Currency.Of(p.PriceCurrency);         // one per distinct code
    return $"<span style='color:{badge.ColorHex}'>{badge.Icon} {badge.Label}</span> " +
           $"{currency.Symbol}{p.PriceAmount.ToString($"F{currency.DecimalDigits}")}";
}

// Test
[Fact]
public void Same_tag_returns_same_reference()
{
    var b1 = ProductBadge.ForTag("sale");
    var b2 = ProductBadge.ForTag("Sale");
    Assert.Same(b1, b2);                               // reference equality, not just value
}

[Fact]
public void Currencies_are_cached_per_code()
{
    var c1 = Currency.Of("usd");
    var c2 = Currency.Of("USD");
    Assert.Same(c1, c2);
}

The win in numbers: rendering a million product cards now allocates three ProductBadge instances and however many Currency instances the catalogue uses. Compared to the naive version's three million plus allocations, that is a measurable cut on a hot path.

A measurement reminder: Flyweight is one of the few patterns whose correctness is judged by dotnet-counters rather than by code review. The class structure is identical to a non-Flyweight version (same record, same fields). What changes is the factory — and the factory's effect is invisible without a profile. Always measure before and after.

Frequently asked questions

What is the difference between Flyweight and an object pool?
Flyweight shares one instance per distinct value — three products with the 'Sale' badge all reference the same Badge.Sale object. An object pool reuses mutable objects to avoid allocation cost; the same pooled instance is handed to one consumer at a time and reset between uses. Flyweight depends on immutability; a pool depends on disciplined check-out and return.
Does string.Intern do the same thing as Flyweight?
Yes — string.Intern is the BCL implementing Flyweight for strings. The CLR keeps a process-wide table of unique string values, and Intern returns the canonical instance. The pattern works for any immutable type; you just need to write the dictionary yourself.
When does Flyweight stop being worth the complexity?
When the saved memory is under a few megabytes, when the cache lookup itself becomes a hotspot, or when the shared instances accidentally outlive their usefulness and pin memory. Flyweight is a profiling-driven optimisation, not a default. Without measured savings, prefer plain record instances and let the GC do its job.
Are records better than Flyweight in modern C# because of value equality?
They are different tools. record gives you value equality but every new still allocates. Flyweight ensures only one allocation per distinct value and makes equality a reference comparison — cheaper than value equality. The two combine well: a Flyweight cache returning records means callers get equality semantics and allocation savings.