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
- What problem does the Flyweight pattern solve in C#?
- How does the Flyweight structure look in .NET 10?
- What does string.Intern have to do with Flyweight?
- When should you skip Flyweight?
- How does Flyweight compare to Singleton and object pooling?
- What does a real .NET 10 example look like?
- 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:
- Repeated value objects. Product badges, log severity tags, country codes, currency symbols. Most of the variation is exhausted by a handful of values.
- 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.
- 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:
- You have not measured. Without an allocation profiler showing a real hotspot, Flyweight is a guess. The cache lookup itself can be slower than allocating a small record. Always measure.
- The shared values mutate. Flyweight depends on immutability. A
shared
Badgewhose colour is changed by one caller breaks every other caller silently. Records with init-only properties are the safe starting point. - The cache becomes a memory leak. Caching every distinct
request-derived value forever is the opposite of an optimisation.
Use bounded caches (
MemoryCachewith a size limit) when the key space is unbounded.
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.
Where should you read next in this series?
- Previous: Facade — the most under-named structural pattern.
- Next: Proxy — the last structural pattern, about controlling access to an object.
- Cross-reference: Singleton — when the shared object is one instance for the whole process, not one per distinct value.
- Cross-reference: Prototype — when you want a near-copy of a template, not a shared reference.
- Decision tree: How to choose the right design pattern.
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?
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?
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?
record instances and let the GC do its job.Are records better than Flyweight in modern C# because of value equality?
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.