Flyweight Pattern Real-World Example in C#: Complete Implementation
Most flyweight pattern tutorials show you a trivial example with colored circles or chess pieces. Those examples explain the mechanics, but they won't prepare you for the moment your document rendering service is eating 800 MB of RAM because every text element carries its own copy of font and style data. This article builds a complete flyweight pattern real-world example in C# -- a document rendering system where thousands of text elements share a handful of immutable style objects, slashing memory consumption without sacrificing readability or thread safety.
By the end, you'll have a fully compilable implementation covering every layer: the flyweight interface, immutable concrete flyweights, a thread-safe factory with ConcurrentDictionary, extrinsic context objects, a document class that ties it all together, and a memory comparison that makes the savings concrete. If you want to see how the flyweight pattern complements other structural patterns like the composite, this is the article that connects those dots.
The Problem: Duplicated Style Data Across Thousands of Elements
Imagine you're building a document rendering engine. Each text element in a document has content, a position, a line number, and formatting -- font family, font size, color, bold, italic. A typical document might contain 10,000 text elements. Without the flyweight pattern, each element stores its own copy of the style data:
public sealed class NaiveTextElement
{
public string Content { get; init; } = "";
public int PositionX { get; init; }
public int PositionY { get; init; }
public int LineNumber { get; init; }
public string FontFamily { get; init; } = "Arial";
public int FontSize { get; init; } = 12;
public string Color { get; init; } = "#000000";
public bool Bold { get; init; }
public bool Italic { get; init; }
}
With 10,000 elements, you're storing 10,000 copies of FontFamily, FontSize, Color, Bold, and Italic. Most documents use only a handful of distinct style combinations -- maybe body text, headings, bold emphasis, and code snippets. That's four or five unique style objects duplicated thousands of times over.
The flyweight pattern solves this by splitting each object into intrinsic state (shared, immutable formatting data) and extrinsic state (unique per-element data like content and position). Shared style objects are created once and referenced by every element that uses them. The factory guarantees that identical style combinations always return the same instance.
Defining the Flyweight Interface
The flyweight interface defines what shared style data looks like and how it participates in rendering. The Render method accepts extrinsic state -- the content, position, and line number that vary per element -- so the flyweight itself stays stateless and shareable:
public interface ITextStyle
{
string FontFamily { get; }
int FontSize { get; }
string Color { get; }
bool Bold { get; }
bool Italic { get; }
void Render(
string content,
int positionX,
int positionY,
int lineNumber);
}
Notice that Render doesn't return anything in this example -- it represents the side effect of drawing text to a rendering surface. In a production system you might return a render command object or write to a graphics context. The key design decision is that the flyweight never stores the extrinsic data. It receives it, uses it, and discards it. This is what makes the flyweight pattern work -- the shared object remains safe to use from any context without mutation.
Building the Concrete Flyweight
The concrete flyweight is an immutable record that holds the shared formatting data. Records in C# give us value-based equality for free, which is useful when verifying that the factory returns the same instance for identical style combinations:
public sealed record TextStyle(
string FontFamily,
int FontSize,
string Color,
bool Bold,
bool Italic) : ITextStyle
{
public void Render(
string content,
int positionX,
int positionY,
int lineNumber)
{
var weight = Bold ? "Bold" : "Normal";
var style = Italic ? "Italic" : "Regular";
Console.WriteLine(
$"[Line {lineNumber}] ({positionX},{positionY}) " +
$"'{content}' -> {FontFamily} {FontSize}pt " +
$"{Color} {weight} {style}");
}
}
The TextStyle record is immutable by design. Once created, its properties cannot change. This immutability is critical -- it means multiple threads can share the same TextStyle instance without synchronization at the point of use. The flyweight pattern depends on this guarantee. If the shared object were mutable, you'd need locks around every render call and the entire pattern would collapse under its own complexity.
If you've worked with the decorator pattern for layering behavior onto existing objects, you'll appreciate the contrast here. Decorators wrap and extend; flyweights share and minimize. Both are structural patterns, but they solve opposite problems.
Creating the Thread-Safe Flyweight Factory
The factory is the heart of the flyweight pattern. It maintains a cache of existing flyweight instances and returns the cached version when a matching style already exists. Thread safety matters because document rendering pipelines often process elements concurrently:
using System.Collections.Concurrent;
public sealed class TextStyleFactory
{
private readonly ConcurrentDictionary<string, ITextStyle>
_cache = new();
public ITextStyle GetStyle(
string fontFamily,
int fontSize,
string color,
bool bold,
bool italic)
{
var key = BuildKey(
fontFamily,
fontSize,
color,
bold,
italic);
return _cache.GetOrAdd(
key,
_ => new TextStyle(
fontFamily,
fontSize,
color,
bold,
italic));
}
public int CachedStyleCount => _cache.Count;
private static string BuildKey(
string fontFamily,
int fontSize,
string color,
bool bold,
bool italic)
{
return $"{fontFamily}|{fontSize}|{color}|{bold}|{italic}";
}
}
The ConcurrentDictionary.GetOrAdd method handles the check-and-create atomically. If two threads request the same style combination simultaneously, only one TextStyle instance gets created. The string key approach keeps lookup simple and transparent. In performance-critical scenarios you could use a struct key with a custom IEqualityComparer<T> to avoid string allocations, but the string approach is clear and works well for most document-scale workloads.
The CachedStyleCount property exists for diagnostics -- it lets you verify at runtime that the flyweight pattern is doing its job. If your document has 10,000 text elements but CachedStyleCount shows 5, you know the factory created only 5 unique style objects.
This factory follows a pattern similar to what you'd see with a singleton registration in a DI container. The factory itself should be a singleton so that all parts of your application share the same cache. We'll wire that up in the DI section below.
Building the Context Class
The context class holds the extrinsic state -- everything unique to a specific text element. It references a shared ITextStyle flyweight for its formatting:
public sealed class TextElement
{
public string Content { get; }
public int PositionX { get; }
public int PositionY { get; }
public int LineNumber { get; }
private readonly ITextStyle _style;
public TextElement(
string content,
int positionX,
int positionY,
int lineNumber,
ITextStyle style)
{
Content = content;
PositionX = positionX;
PositionY = positionY;
LineNumber = lineNumber;
_style = style;
}
public void Render()
{
_style.Render(
Content,
PositionX,
PositionY,
LineNumber);
}
}
Each TextElement stores only its own content and position data. The formatting lives in the shared ITextStyle reference. When Render is called, the element passes its extrinsic state into the flyweight's Render method. This is the flyweight pattern's core mechanism -- the shared object does the work, but the context provides the data that makes each invocation unique.
Assembling the Document
The Document class pulls everything together. It holds a list of TextElement instances and demonstrates how thousands of elements share just a few flyweight styles:
public sealed class Document
{
private readonly List<TextElement> _elements = new();
private readonly TextStyleFactory _styleFactory;
public Document(TextStyleFactory styleFactory)
{
_styleFactory = styleFactory;
}
public int ElementCount => _elements.Count;
public void AddElement(
string content,
int positionX,
int positionY,
int lineNumber,
string fontFamily,
int fontSize,
string color,
bool bold,
bool italic)
{
var style = _styleFactory.GetStyle(
fontFamily,
fontSize,
color,
bold,
italic);
var element = new TextElement(
content,
positionX,
positionY,
lineNumber,
style);
_elements.Add(element);
}
public void RenderAll()
{
foreach (var element in _elements)
{
element.Render();
}
}
}
The document delegates style creation to the factory. Calling code never constructs TextStyle directly -- it always goes through the factory, which ensures the flyweight cache is used. This is how the flyweight pattern keeps memory under control even as the document grows to thousands of elements.
If you're building a rendering pipeline that needs to notify other components when the document changes, the observer pattern pairs well with this document structure.
Memory Comparison: With and Without the Flyweight Pattern
The most compelling argument for the flyweight pattern is concrete numbers. Here's a program that creates 10,000 text elements using four style combinations, then compares the object count:
public static class FlyweightDemo
{
public static void RunComparison()
{
var factory = new TextStyleFactory();
var document = new Document(factory);
var styleConfigs = new[]
{
("Arial", 12, "#000000", false, false),
("Arial", 16, "#333333", true, false),
("Courier New", 10, "#666666", false, false),
("Arial", 12, "#0000FF", false, true),
};
for (int i = 0; i < 10_000; i++)
{
var config = styleConfigs[i % styleConfigs.Length];
document.AddElement(
content: $"Word_{i}",
positionX: (i % 80) * 10,
positionY: (i / 80) * 20,
lineNumber: i / 80,
fontFamily: config.Item1,
fontSize: config.Item2,
color: config.Item3,
bold: config.Item4,
italic: config.Item5);
}
Console.WriteLine(
$"Total elements: {document.ElementCount}");
Console.WriteLine(
$"Unique styles (flyweight): " +
$"{factory.CachedStyleCount}");
Console.WriteLine(
$"Style objects WITHOUT flyweight: " +
$"{document.ElementCount}");
Console.WriteLine(
$"Style objects WITH flyweight: " +
$"{factory.CachedStyleCount}");
long naiveStyleBytes =
document.ElementCount * EstimateStyleSize();
long flyweightStyleBytes =
factory.CachedStyleCount * EstimateStyleSize();
long savedBytes = naiveStyleBytes - flyweightStyleBytes;
Console.WriteLine(
$"Approximate style memory without flyweight: " +
$"{naiveStyleBytes:N0} bytes");
Console.WriteLine(
$"Approximate style memory with flyweight: " +
$"{flyweightStyleBytes:N0} bytes");
Console.WriteLine(
$"Memory saved: {savedBytes:N0} bytes " +
$"({(double)savedBytes / naiveStyleBytes:P1})");
}
private static long EstimateStyleSize()
{
// Object header (16 bytes on x64) +
// FontFamily string ref (8) +
// FontSize int (4) +
// Color string ref (8) +
// Bold bool (1) + Italic bool (1) +
// padding (~10 bytes alignment)
// Plus string content on heap (~40-60 bytes each)
return 128;
}
}
Running this produces output like:
Total elements: 10000
Unique styles (flyweight): 4
Style objects WITHOUT flyweight: 10000
Style objects WITH flyweight: 4
Approximate style memory without flyweight: 1,280,000 bytes
Approximate style memory with flyweight: 512 bytes
Memory saved: 1,279,488 bytes (100.0%)
The flyweight pattern reduced 10,000 style objects down to 4. The per-element overhead drops to a single object reference (8 bytes on x64). The exact memory savings depend on object layout, GC overhead, and string interning, but the order-of-magnitude reduction is consistent. When you scale to 100,000 elements with 10 unique styles, the savings become even more dramatic.
Integrating with Dependency Injection
For production use, register the TextStyleFactory as a singleton in your DI container. The factory must be a singleton so all consumers share the same cache -- otherwise you lose the flyweight pattern's benefits entirely:
using Microsoft.Extensions.DependencyInjection;
public static class DocumentServiceRegistration
{
public static IServiceCollection AddDocumentRendering(
this IServiceCollection services)
{
services.AddSingleton<TextStyleFactory>();
services.AddTransient<Document>();
return services;
}
}
Then in your Program.cs or startup:
builder.Services.AddDocumentRendering();
The TextStyleFactory lives for the lifetime of the application. Every Document instance injected via DI receives the same factory and therefore the same flyweight cache. If you need to isolate caches per tenant or scope, you could register the factory as scoped instead, but singleton is the default choice for the flyweight pattern.
This is where the flyweight pattern's relationship with the singleton pattern becomes clear. The factory itself is a singleton, but the flyweights it manages are not -- they're cached instances keyed by their intrinsic state. If you need to vary the rendering behavior per style, consider combining the flyweight pattern with the strategy pattern for swappable render algorithms.
When the Flyweight Pattern Fits This Architecture
The document rendering example isn't arbitrary. It represents the exact scenario where the flyweight pattern delivers the most value: a large number of objects with significant shared state. The decision criteria are straightforward. If you have hundreds or thousands of objects, many of them share identical subsets of data, that shared data is immutable, and the unique per-object data is small relative to the shared data, then the flyweight pattern is a strong fit.
The pattern becomes less useful when every object has unique state, when the total object count is small enough that memory isn't a concern, or when the shared data is trivially small (a single integer, for example). In those cases the indirection through a factory adds complexity without meaningful benefit.
For systems where you need to translate between different rendering interfaces -- perhaps adapting a third-party font engine to your style abstraction -- the adapter pattern handles that boundary cleanly alongside the flyweight.
Frequently Asked Questions
What is the difference between intrinsic and extrinsic state in the flyweight pattern?
Intrinsic state is data that can be shared across multiple objects because it doesn't change per context. In our example, font family, size, color, bold, and italic are intrinsic -- a "12pt Arial black normal" style looks the same regardless of which word it formats. Extrinsic state is data unique to each usage context: the text content, position coordinates, and line number. The flyweight pattern works by extracting intrinsic state into shared objects and passing extrinsic state in at the point of use.
Is the flyweight pattern thread-safe by default?
Not automatically. Thread safety depends on two factors: immutability of the flyweight objects and thread-safe access to the factory's cache. In our implementation, TextStyle is an immutable record (safe to share across threads without locks), and TextStyleFactory uses ConcurrentDictionary for atomic cache operations. Both are necessary. If you used a regular Dictionary in the factory, concurrent access could corrupt the cache even though the flyweights themselves are safe.
How does the flyweight pattern differ from object pooling?
Object pooling reuses mutable objects by resetting their state between uses -- one consumer at a time. The flyweight pattern shares immutable objects simultaneously across many consumers. A pooled object is checked out, used, and returned. A flyweight is looked up, referenced, and never returned because it's never exclusively owned. The flyweight pattern works for the document rendering case because style data is read-only; object pooling would be wrong here because multiple elements reference the same style concurrently.
Can I use records as flyweights in C#?
Records are an excellent choice for flyweights in C#. They provide immutability by default (when using positional syntax), value-based equality for verification, and concise syntax. The TextStyle record in this article demonstrates the approach. One caveat: if you use record class (the default), instances are still reference types allocated on the heap. That's fine for the flyweight pattern because you want reference equality for the cached instances. Using record struct would defeat the purpose since struct copies would eliminate the shared-reference benefit.
How many flyweight objects should I expect in a typical application?
The number of flyweight instances should be dramatically smaller than the number of context objects using them. In the document rendering example, 10,000 text elements shared 4 style flyweights. A typical ratio depends on your domain: a word processor might have 5-20 unique styles across a 50-page document, a game might have 10-30 tile types across a 10,000-tile map, and a UI framework might have a dozen theme configurations applied to hundreds of controls. If the flyweight count approaches the context count, the pattern isn't providing enough sharing to justify the factory overhead.
Should I clear the flyweight cache to free memory?
Generally no. The flyweight factory's cache holds a small number of lightweight objects -- that's the whole point. Clearing the cache would force re-creation of flyweights that are still referenced by existing context objects, meaning you'd have duplicate instances that defeat the pattern. If you have a legitimate scenario where style configurations change between documents (say, switching themes), create a new factory instance scoped to the new document rather than clearing the cache of a shared factory.
How does the flyweight pattern work with serialization?
When serializing objects that reference flyweights, serialize the intrinsic state values (font family, size, color, etc.) alongside the extrinsic state. On deserialization, pass the intrinsic values through the factory to get back the shared instance. This means your serialization format stores the style data per element (flat), but your in-memory representation shares it (flyweight). The flyweight optimization is a runtime concern -- it doesn't affect your data format or persistence layer.
Wrapping Up This Flyweight Pattern Real-World Example
This implementation shows the flyweight pattern doing what it's designed for -- eliminating redundant object allocations by sharing immutable state across thousands of consumers. We started with a naive document renderer that duplicated style data per element and ended with a system that serves 10,000 elements from 4 shared TextStyle instances.
The pieces fit together in a predictable way. The ITextStyle interface defines the shared contract with a Render method that accepts extrinsic state. The TextStyle record holds immutable formatting data. The TextStyleFactory manages the cache with ConcurrentDictionary for thread safety. The TextElement context class carries only its unique data and delegates formatting to the shared flyweight. The Document class orchestrates creation and rendering.
Take this implementation, swap Console.WriteLine for your actual rendering pipeline, and you have a production-ready flyweight layer. The pattern scales linearly with unique style combinations, not with element count -- and in any document system, the styles will always be a small fraction of the elements.

