BrandGhost
Flyweight vs Singleton Pattern in C#: Key Differences Explained

Flyweight vs Singleton Pattern in C#: Key Differences Explained

Flyweight vs Singleton Pattern in C#: Key Differences Explained

When developers first encounter the flyweight and singleton patterns, a common reaction is to assume they're variations of the same idea -- both deal with controlling how many instances of a class exist. But these patterns come from very different motivations and solve very different problems. The flyweight vs singleton pattern in C# comparison regularly trips up developers building systems that need to manage object lifecycles carefully. Understanding when to reach for one over the other can save you from architectural headaches. In this article, we'll break down the structural differences, walk through side-by-side code examples, and give you a clear decision framework.

If you're still building your foundation with design patterns, explore related patterns like the Decorator Design Pattern in C#: Complete Guide or the Strategy Design Pattern in C#: Complete Guide to see how different patterns address different concerns.

Quick Refresher on Each Pattern

Before diving into the comparison, let's do a brief refresher so we're working from the same baseline.

The Singleton Pattern

The singleton pattern ensures that a class has exactly one instance throughout the lifetime of an application and provides a global access point to that instance. You achieve this with a private constructor, a static field to hold the single instance, and a public static property or method to access it. The singleton is a creational pattern -- its entire purpose is to control object creation so that only one instance ever exists.

For a thorough walkthrough with practical examples, check out the Singleton Design Pattern in C#: Complete Guide.

public sealed class AppConfiguration
{
    private static readonly Lazy<AppConfiguration> _instance =
        new(() => new AppConfiguration());

    private AppConfiguration()
    {
        // load configuration from file or environment
        ConnectionString = "Server=myserver;Database=mydb;";
        MaxRetries = 3;
    }

    public static AppConfiguration Instance => _instance.Value;

    public string ConnectionString { get; }
    public int MaxRetries { get; }
}

There's one AppConfiguration instance. Every caller that accesses AppConfiguration.Instance gets the same object. That's the whole point.

The Flyweight Pattern

The flyweight pattern is a structural pattern focused on memory optimization. Instead of creating a new object for every request, it shares objects that have identical intrinsic state. A flyweight factory manages a pool of these shared instances and hands out existing ones when a matching intrinsic state is requested. The extrinsic state -- context that varies per use -- is passed in at runtime rather than stored inside the flyweight.

public class TextFormatting
{
    // intrinsic state: shared across all uses
    public string FontFamily { get; }
    public int FontSize { get; }
    public bool IsBold { get; }

    public TextFormatting(
        string fontFamily,
        int fontSize,
        bool isBold)
    {
        FontFamily = fontFamily;
        FontSize = fontSize;
        IsBold = isBold;
    }

    // extrinsic state passed in at render time
    public void Render(string text, int x, int y)
    {
        Console.WriteLine(
            $"Rendering '{text}' at ({x},{y}) " +
            $"with {FontFamily} {FontSize}pt" +
            $"{(IsBold ? " Bold" : "")}");
    }
}

Multiple characters in a document can share the same TextFormatting flyweight if they use the same font, size, and bold setting. The actual character value and position are extrinsic -- they change per usage.

Core Intent: What Problem Does Each Solve?

This is the most important distinction in the flyweight vs singleton comparison. These patterns exist for completely different reasons.

The singleton answers the question: "How do I guarantee there is only one instance of this class?" It's about identity and uniqueness. You use it when having more than one instance would cause bugs, inconsistency, or resource conflicts -- things like configuration managers, connection pools, or application-wide caches.

The flyweight answers a different question: "How do I avoid creating thousands of nearly identical objects?" It's about memory efficiency. You use it when your system would otherwise allocate a massive number of objects that share common state -- things like characters in a text editor, particles in a game engine, or icons in a UI framework.

Aspect Flyweight Singleton
Pattern type Structural Creational
Core goal Share objects to save memory Ensure exactly one instance
Motivation Performance optimization Identity guarantee

Instance Count

A singleton creates exactly one instance. Period. That's the defining characteristic. Every consumer receives a reference to the same object.

A flyweight factory manages many instances -- one per unique combination of intrinsic state. If you have 500 text characters but only 12 unique font combinations, the flyweight factory holds 12 TextFormatting objects, not 500.

// Singleton: always one instance
var config1 = AppConfiguration.Instance;
var config2 = AppConfiguration.Instance;
Console.WriteLine(ReferenceEquals(config1, config2));
// Output: True

// Flyweight: one instance per unique intrinsic state
var factory = new TextFormattingFactory();
var arial12 = factory.GetFormatting("Arial", 12, false);
var arial12Again = factory.GetFormatting("Arial", 12, false);
var arial14Bold = factory.GetFormatting("Arial", 14, true);

Console.WriteLine(ReferenceEquals(arial12, arial12Again));
// Output: True -- same intrinsic state, same instance

Console.WriteLine(ReferenceEquals(arial12, arial14Bold));
// Output: False -- different intrinsic state, different instance

The singleton gives you one object. The flyweight gives you one object per unique key.

State Handling: Intrinsic vs Extrinsic

One of the sharpest contrasts in the flyweight vs singleton pattern comparison is how each pattern handles state.

A singleton owns all of its state -- it doesn't distinguish between "shared" and "per-use" data. A flyweight deliberately separates state into two categories:

  • Intrinsic state: Stored inside the flyweight, shared across all uses. Immutable.
  • Extrinsic state: Supplied by the client at the point of use. Not stored in the flyweight.

This separation is what makes the flyweight pattern work. Without it, you couldn't share instances because each use would need different data baked into the object.

// Singleton: all state lives in the single instance
public sealed class Logger
{
    private static readonly Lazy<Logger> _instance =
        new(() => new Logger());

    private readonly List<string> _logEntries = new();

    private Logger() { }

    public static Logger Instance => _instance.Value;

    public void Log(string message)
    {
        string entry = $"[{DateTime.UtcNow:O}] {message}";
        _logEntries.Add(entry);
        Console.WriteLine(entry);
    }

    public IReadOnlyList<string> GetEntries() =>
        _logEntries.AsReadOnly();
}
// Flyweight: intrinsic state shared, extrinsic state passed in
public class LogFormat
{
    // intrinsic: shared across loggers using this format
    public string TimestampPattern { get; }
    public string Prefix { get; }
    public ConsoleColor Color { get; }

    public LogFormat(
        string timestampPattern,
        string prefix,
        ConsoleColor color)
    {
        TimestampPattern = timestampPattern;
        Prefix = prefix;
        Color = color;
    }

    // extrinsic: the actual message and category vary per call
    public void WriteEntry(string category, string message)
    {
        string timestamp = DateTime.UtcNow.ToString(
            TimestampPattern);
        Console.ForegroundColor = Color;
        Console.WriteLine(
            $"{Prefix} [{timestamp}] [{category}] {message}");
        Console.ResetColor();
    }
}

The singleton Logger accumulates state internally. The flyweight LogFormat holds only reusable formatting configuration and expects the caller to pass in per-call details.

Creation Pattern: Factory Pool vs Private Constructor

The mechanics of how each pattern creates and manages instances are structurally different.

A singleton uses a private constructor combined with a static accessor. The class itself controls instantiation. No external factory is needed -- the singleton is self-managing.

A flyweight relies on a separate factory class that maintains a dictionary (or similar structure) of existing instances keyed by their intrinsic state. When a client requests a flyweight, the factory either returns an existing match or creates a new one and caches it.

// Singleton creation: self-contained
public sealed class EventBus
{
    private static readonly Lazy<EventBus> _instance =
        new(() => new EventBus());

    private EventBus() { }

    public static EventBus Instance => _instance.Value;

    public void Publish(string eventName, object payload)
    {
        // routing logic
    }
}
// Flyweight creation: factory-managed pool
public class TextFormattingFactory
{
    private readonly Dictionary<string, TextFormatting> _cache =
        new();

    public TextFormatting GetFormatting(
        string fontFamily,
        int fontSize,
        bool isBold)
    {
        string key = $"{fontFamily}_{fontSize}_{isBold}";

        if (!_cache.TryGetValue(key, out var formatting))
        {
            formatting = new TextFormatting(
                fontFamily,
                fontSize,
                isBold);
            _cache[key] = formatting;
        }

        return formatting;
    }

    public int CachedCount => _cache.Count;
}

Notice the structural difference. The singleton has no external manager -- it governs itself. The flyweight needs a factory to handle the pooling logic, a direct consequence of managing many shared instances rather than just one.

If you're using dependency injection in your applications, both patterns integrate with IServiceCollection in C# -- singletons via AddSingleton<T>() and flyweight factories as singleton-registered services that manage their own internal pools.

Same Problem, Two Approaches: Logging

Let's make the flyweight vs singleton distinction concrete by solving the same problem -- logging -- with both patterns. This example highlights how the choice of pattern changes the design's shape.

Singleton Approach: One Logger

With a singleton, you get one logger for the entire application. Every component writes through the same instance.

public sealed class SingletonLogger
{
    private static readonly Lazy<SingletonLogger> _instance =
        new(() => new SingletonLogger());

    private readonly StreamWriter _writer;

    private SingletonLogger()
    {
        _writer = new StreamWriter(
            "app.log",
            append: true)
        {
            AutoFlush = true
        };
    }

    public static SingletonLogger Instance => _instance.Value;

    public void Log(
        string category,
        string level,
        string message)
    {
        string entry =
            $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] " +
            $"[{level}] [{category}] {message}";
        lock (_writer)
        {
            _writer.WriteLine(entry);
        }
    }
}

// Usage
SingletonLogger.Instance.Log("Auth", "INFO", "User logged in");
SingletonLogger.Instance.Log("Data", "ERROR", "Query failed");

This works well when you want a single output destination, a single format, and centralized control.

Flyweight Approach: Per-Category Loggers Sharing Format

With flyweights, you create per-category loggers that share common formatting configurations. Categories with the same severity level and output format share a flyweight instead of each allocating their own format object.

public class LoggerConfig
{
    // intrinsic: shared across categories with same settings
    public string TimestampFormat { get; }
    public string OutputTemplate { get; }
    public bool IncludeStackTrace { get; }

    public LoggerConfig(
        string timestampFormat,
        string outputTemplate,
        bool includeStackTrace)
    {
        TimestampFormat = timestampFormat;
        OutputTemplate = outputTemplate;
        IncludeStackTrace = includeStackTrace;
    }

    // extrinsic: category and message vary per call
    public string FormatEntry(
        string category,
        string message)
    {
        string timestamp = DateTime.UtcNow.ToString(
            TimestampFormat);
        return string.Format(
            OutputTemplate,
            timestamp,
            category,
            message);
    }
}

public class LoggerConfigFactory
{
    private readonly Dictionary<string, LoggerConfig> _configs =
        new();

    public LoggerConfig GetConfig(
        string timestampFormat,
        string outputTemplate,
        bool includeStackTrace)
    {
        string key =
            $"{timestampFormat}|{outputTemplate}|" +
            $"{includeStackTrace}";

        if (!_configs.TryGetValue(key, out var config))
        {
            config = new LoggerConfig(
                timestampFormat,
                outputTemplate,
                includeStackTrace);
            _configs[key] = config;
        }

        return config;
    }
}

// Usage
var factory = new LoggerConfigFactory();

// These two share the same flyweight
var authConfig = factory.GetConfig(
    "yyyy-MM-dd HH:mm:ss",
    "[{0}] [{1}] {2}",
    false);

var dataConfig = factory.GetConfig(
    "yyyy-MM-dd HH:mm:ss",
    "[{0}] [{1}] {2}",
    false);

// This one gets a different flyweight
var errorConfig = factory.GetConfig(
    "yyyy-MM-dd HH:mm:ss.fff",
    "[{0}] ERROR [{1}] {2}",
    true);

Console.WriteLine(authConfig.FormatEntry(
    "Auth", "User logged in"));
Console.WriteLine(errorConfig.FormatEntry(
    "Data", "Query failed"));

Console.WriteLine(ReferenceEquals(authConfig, dataConfig));
// Output: True -- same format settings, shared instance

The singleton gives you one logger. The flyweight gives you one config per unique format combination. If your system has 50 categories but only 4 distinct format configurations, the flyweight approach creates 4 objects instead of 50.

Can Flyweight and Singleton Work Together?

Yes -- and they do so naturally. A very common pattern is to make the flyweight factory itself a singleton. This ensures that the pool of shared flyweights is managed from a single location, preventing duplicate caches from forming across different parts of the application.

public sealed class IconFactory
{
    private static readonly Lazy<IconFactory> _instance =
        new(() => new IconFactory());

    private readonly Dictionary<string, Icon> _icons = new();

    private IconFactory() { }

    public static IconFactory Instance => _instance.Value;

    public Icon GetIcon(string name, int size)
    {
        string key = $"{name}_{size}";

        if (!_icons.TryGetValue(key, out var icon))
        {
            icon = new Icon(name, size);
            _icons[key] = icon;
        }

        return icon;
    }
}

public class Icon
{
    public string Name { get; }
    public int Size { get; }
    public byte[] PixelData { get; }

    public Icon(string name, int size)
    {
        Name = name;
        Size = size;
        // simulate loading pixel data
        PixelData = new byte[size * size * 4];
    }

    // extrinsic: position varies per usage
    public void Draw(int x, int y)
    {
        Console.WriteLine(
            $"Drawing {Name} ({Size}px) at ({x},{y})");
    }
}

// Usage
var saveIcon1 = IconFactory.Instance.GetIcon("save", 24);
var saveIcon2 = IconFactory.Instance.GetIcon("save", 24);
var deleteIcon = IconFactory.Instance.GetIcon("delete", 24);

saveIcon1.Draw(10, 20);
saveIcon2.Draw(100, 20);
// saveIcon1 and saveIcon2 are the same object

The singleton pattern ensures one factory. The flyweight pattern ensures shared icons within that factory. Each pattern handles its own concern. This combination works especially well with dependency injection -- register the factory as a singleton service and let it manage the flyweight pool internally. For more on how inversion of control containers manage object lifetimes, see What is Inversion of Control.

Decision Criteria: Flyweight vs Singleton

Use this table when deciding which pattern fits your situation.

Criteria Choose Singleton Choose Flyweight
Number of instances needed Exactly one Many, but shared by intrinsic state
Primary concern Unique identity / global access Memory savings from sharing
State model All state belongs to the instance Intrinsic (shared) + extrinsic (per-use)
Object creation Private constructor + static accessor Factory with cache/pool
Typical use cases Configuration, connection pools, caches Text formatting, icons, particle systems
Thread safety concern Instance creation Factory cache access
Scales with Nothing -- always one Number of unique intrinsic states
Works with DI AddSingleton<T>() Register factory as singleton

When They Overlap

Sometimes a problem could be solved either way. Ask yourself: Is the constraint "there must be only one" or "there should be as few as possible"? If duplicates would cause bugs, use a singleton. If duplicates would waste memory, use a flyweight.

Also consider how these patterns relate to other structural patterns. The Adapter Design Pattern in C#: Complete Guide solves interface translation problems, a completely different axis of design. Patterns don't compete -- they compose.

Frequently Asked Questions

What is the main difference between flyweight and singleton?

The flyweight vs singleton difference is about intent. The singleton guarantees exactly one instance of a class exists. The flyweight shares multiple instances that have identical intrinsic state to reduce memory consumption. A singleton controls identity. A flyweight controls duplication -- objects with the same shared data reuse a single instance instead of allocating new ones.

Can a flyweight factory be a singleton?

Yes, this is a common and practical combination. Making the flyweight factory a singleton ensures there is one central pool of shared instances. Without this, different parts of the application might create their own factory, leading to duplicate caches that defeat the purpose of sharing.

Is the flyweight pattern the same as caching?

Flyweight and caching share the concept of reusing objects, but they differ in scope. Caching stores results of expensive operations and typically involves invalidation and expiration. The flyweight pattern is a structural design choice where objects separate intrinsic from extrinsic state so that instances with identical intrinsic data can be shared. Caching is a runtime optimization. Flyweight is a design-time structural decision.

When should I use singleton instead of dependency injection?

In most modern C# applications, you should prefer registering services as singletons through dependency injection rather than implementing the classic singleton pattern with a static accessor. DI containers like IServiceCollection in C# give you singleton lifetime management while keeping your classes testable and loosely coupled. The classic singleton pattern is still appropriate for infrastructure-level concerns that need to exist before the DI container is built -- things like bootstrapping configuration or logging during startup.

Does the flyweight pattern make code harder to understand?

The flyweight pattern introduces indirection through the factory and the intrinsic/extrinsic state split. For developers unfamiliar with the pattern, this can be confusing. The tradeoff is worth it when dealing with thousands of objects that share common data -- the memory savings justify the complexity. For small-scale scenarios with only a handful of objects, the pattern adds overhead without meaningful benefit.

Can flyweight objects be mutable?

Flyweight objects should keep their intrinsic state immutable. Since multiple clients share the same flyweight instance, mutating intrinsic state would create unpredictable side effects across all consumers. The extrinsic state -- which is passed in by the caller -- can change freely because it is not stored in the flyweight. If you find yourself needing mutable shared state, you likely need a different pattern or a thread-safe wrapper around the mutation points.

How do flyweight and singleton handle thread safety differently?

With a singleton, the primary concern is ensuring the instance is created only once. Using Lazy<T> with its default thread-safety mode handles this cleanly. With a flyweight, the concern shifts to the factory's cache -- multiple threads might request the same intrinsic state simultaneously, so the dictionary operations need synchronization. You can use ConcurrentDictionary<TKey, TValue> or explicit locking. The flyweight has a broader concurrency surface because the cache is accessed more frequently than a singleton's one-time initialization.

Wrapping Up Flyweight vs Singleton in C#

The flyweight vs singleton pattern distinction boils down to one question: do you need one instance, or do you need fewer instances? The singleton enforces uniqueness. The flyweight enforces sharing -- objects with the same intrinsic state reuse a single instance rather than duplicating it. They serve different goals, use different creation mechanisms, and handle state differently. But they're not mutually exclusive. A singleton factory managing a pool of flyweights leverages both patterns for their individual strengths. Choose based on the problem you're solving, not on surface-level similarities.

When to Use Flyweight Pattern in C#: Decision Guide with Examples

Learn when to use flyweight pattern in C# with decision criteria, memory profiling examples, and guidance on choosing the right caching approach.

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

Step-by-step guide to implement flyweight pattern in C# with practical code examples covering shared state, flyweight factories, and memory optimization.

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