Skip to main content

C# Design Patterns: Factory, Singleton, Observer, Strategy

Learn C# design patterns every developer must know—Factory, Singleton, Observer, and Strategy—with runnable .NET code examples. Start writing cleaner C# today.

If you have been writing C# for more than a few months, you have almost certainly hit the same wall every developer hits: a class that grows to 800 lines, a giant switch statement that needs editing every time a new feature ships, or a service that quietly creates its own dependencies so nothing can be unit tested. C# design patterns exist to solve exactly these problems. They are not academic theory—they are battle-tested solutions to recurring design problems, and the .NET ecosystem uses them everywhere, from HttpClientFactory to ASP.NET Core dependency injection to IObservable<T>.

In this tutorial we will cover the four design patterns in C# that every developer must know: Factory, Singleton, Observer, and Strategy. For each one you will get a runnable example targeting modern .NET (C# 12/13), an explanation of why it works, the best practices that separate clean code from cargo-cult code, and the pitfalls that trip up even experienced teams.

What Are Design Patterns in C# and Why Should You Care?

A design pattern is a reusable, named solution to a common software design problem. The term comes from the 1994 "Gang of Four" book Design Patterns: Elements of Reusable Object-Oriented Software, which catalogued 23 patterns in three groups:

  • Creational – how objects are created (Factory, Singleton, Builder)
  • Structural – how objects are composed (Adapter, Decorator, Facade)
  • Behavioral – how objects communicate (Observer, Strategy, Command)

Why care? Three practical reasons:

  • Shared vocabulary. Saying "let's use a Strategy here" in a code review communicates a full design in three words.
  • Testability. Almost every pattern below pushes you toward interfaces and composition, which makes mocking trivial.
  • Interview readiness. "Explain the Singleton pattern in C#" and "when would you use Factory vs. Strategy?" are among the most common .NET interview questions in the US, UK, and India.

Let's start with the pattern you will use most often.

1. Factory Pattern in C#

The problem it solves

Your code needs to create objects, but the concrete type depends on runtime data—a config value, a user choice, a file extension. Sprinkling new PdfExporter() and new CsvExporter() all over the codebase couples every caller to every concrete class. Add a third format and you edit ten files.

The solution

Centralize creation behind a factory. Callers ask for an abstraction (IExporter) and never learn which concrete class they got.

public interface IExporter
{
    string Export(IEnumerable<Order> orders);
}

public sealed class CsvExporter : IExporter
{
    public string Export(IEnumerable<Order> orders) =>
        string.Join('\n', orders.Select(o => $"{o.Id},{o.Total}"));
}

public sealed class JsonExporter : IExporter
{
    public string Export(IEnumerable<Order> orders) =>
        System.Text.Json.JsonSerializer.Serialize(orders);
}

public record Order(int Id, decimal Total);

// The factory: the ONLY place that knows about concrete exporters
public static class ExporterFactory
{
    public static IExporter Create(string format) => format.ToLowerInvariant() switch
    {
        "csv"  => new CsvExporter(),
        "json" => new JsonExporter(),
        _      => throw new NotSupportedException($"Format '{format}' is not supported.")
    };
}

// Usage
var orders = new[] { new Order(1, 99.50m), new Order(2, 250m) };
IExporter exporter = ExporterFactory.Create("json");
Console.WriteLine(exporter.Export(orders));

Factory Method vs. Abstract Factory

The example above is technically a Simple Factory. The GoF Factory Method pattern moves creation into a virtual method that subclasses override, and Abstract Factory creates families of related objects (for example, a IUiFactory that produces matching buttons and text boxes for Windows vs. macOS). In day-to-day .NET work, the simple factory plus dependency injection covers 90% of cases.

Best practices

  • Register the factory with DI. In ASP.NET Core, inject Func<string, IExporter> or a dedicated IExporterFactory so the factory itself can resolve dependencies from the container.
  • Use keyed services (.NET 8+). services.AddKeyedScoped<IExporter, CsvExporter>("csv") and [FromKeyedServices("csv")] give you a built-in factory without writing a switch.
  • Return interfaces, not concrete types. The whole point is decoupling.

Common pitfall

A factory that grows a switch with 30 cases is a sign you should switch to a registry: a Dictionary<string, Func<IExporter>> that plugins can add to. That keeps the factory open for extension and closed for modification (the "O" in SOLID).

2. Singleton Pattern in C#

The problem it solves

Some objects should exist exactly once per process: a configuration cache, a logger, a connection pool, an expensive in-memory index. Creating multiple copies wastes memory or, worse, causes inconsistent state.

The solution (the right way)

Older tutorials show double-checked locking with volatile fields. Don't. Modern C# has Lazy<T>, which is thread-safe by default and far easier to read:

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

    public static AppSettings Instance => _instance.Value;

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

    // Private constructor: nobody else can call new AppSettings()
    private AppSettings()
    {
        // Imagine reading from appsettings.json or environment variables
        ConnectionString = Environment.GetEnvironmentVariable("DB_CONN") ?? "Server=localhost;";
        MaxRetries = 3;
        Console.WriteLine("AppSettings initialized once.");
    }
}

// Usage - both references point to the same object
var a = AppSettings.Instance;
var b = AppSettings.Instance;
Console.WriteLine(ReferenceEquals(a, b)); // True

Why Lazy<T>? Its default LazyThreadSafetyMode.ExecutionAndPublication guarantees the factory delegate runs exactly once even if a hundred threads hit Instance simultaneously. The sealed keyword prevents subclasses from creating additional instances.

The modern alternative: DI-managed singletons

In any ASP.NET Core or Worker Service app, you rarely need the classic Singleton class at all. Let the container own the lifetime:

builder.Services.AddSingleton<IPricingCache, PricingCache>();

This gives you the "one instance" guarantee and keeps the class testable, because consumers depend on IPricingCache rather than a static Instance property.

Best practices

  • Prefer AddSingleton in DI over hand-rolled static singletons.
  • Keep singletons stateless or immutable where possible; mutable shared state is the root of most concurrency bugs.
  • Never inject scoped services (like DbContext) into a singleton—this is the notorious "captive dependency" bug that causes cross-request data leaks.

Common pitfall

Singleton is the most abused pattern. A static Instance is a global variable in disguise: it hides dependencies, makes tests order-dependent, and is nearly impossible to mock. If you find yourself reaching for it to avoid passing a parameter, use constructor injection instead.

3. Observer Pattern in C#

The problem it solves

One object changes state and many others need to react—an order is placed, so inventory, email, and analytics all need to know. Hard-wiring OrderService to call each of them creates a dependency on every downstream system.

The solution

The subject keeps a list of observers and notifies them without knowing who they are. C# has two idiomatic implementations: events (the everyday choice) and IObservable<T> (for streams and reactive code).

Using C# events

public record OrderPlacedEventArgs(int OrderId, decimal Total);

public class OrderService
{
    // The subject exposes an event; observers subscribe
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public void PlaceOrder(int orderId, decimal total)
    {
        Console.WriteLine($"Order {orderId} placed for {total:C}");
        // Null-conditional invoke is thread-safe against unsubscribe races
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId, total));
    }
}

public class EmailNotifier
{
    public void OnOrderPlaced(object? sender, OrderPlacedEventArgs e) =>
        Console.WriteLine($"[Email] Receipt sent for order {e.OrderId}");
}

public class InventoryService
{
    public void OnOrderPlaced(object? sender, OrderPlacedEventArgs e) =>
        Console.WriteLine($"[Inventory] Stock reserved for order {e.OrderId}");
}

// Usage
var service = new OrderService();
var email = new EmailNotifier();
var inventory = new InventoryService();

service.OrderPlaced += email.OnOrderPlaced;
service.OrderPlaced += inventory.OnOrderPlaced;

service.PlaceOrder(1001, 149.99m);

service.OrderPlaced -= email.OnOrderPlaced; // always unsubscribe when done

Using IObservable<T> with Reactive Extensions

When notifications form a stream—sensor readings, stock ticks, UI input—System.Reactive (Rx.NET) gives you the Observer pattern plus LINQ-style operators like Throttle, Buffer, and DistinctUntilChanged:

using System.Reactive.Linq;
using System.Reactive.Subjects;

var prices = new Subject<decimal>();

using var subscription = prices
    .Where(p => p > 100)
    .Subscribe(p => Console.WriteLine($"Alert: price hit {p:C}"));

prices.OnNext(95m);   // ignored
prices.OnNext(120m);  // Alert: price hit $120.00

Best practices

  • Use EventHandler<T> and a dedicated args type rather than custom delegates.
  • Always invoke with ?.Invoke—copying to a local first is what older code did to avoid a race.
  • Pair every += with a -=, or use IDisposable subscriptions.

Common pitfall

Event subscriptions are the number-one source of memory leaks in .NET. The subject holds a strong reference to every subscriber, so a long-lived service that a short-lived view subscribes to will keep that view (and everything it references) alive forever. Use WeakEventManager in WPF or explicit unsubscription elsewhere.

4. Strategy Pattern in C#

The problem it solves

You have several interchangeable algorithms—shipping cost calculators, discount rules, compression formats—and you're selecting between them with if/switch inside the business logic. Every new algorithm means editing (and re-testing) the same method.

The solution

Extract each algorithm into its own class behind a shared interface and inject the one you need. The business logic never changes when you add a strategy.

public interface IShippingStrategy
{
    decimal Calculate(decimal orderTotal, double weightKg);
}

public sealed class StandardShipping : IShippingStrategy
{
    public decimal Calculate(decimal orderTotal, double weightKg) =>
        orderTotal >= 50 ? 0m : 5.99m;
}

public sealed class ExpressShipping : IShippingStrategy
{
    public decimal Calculate(decimal orderTotal, double weightKg) =>
        12.99m + (decimal)weightKg * 1.50m;
}

public sealed class InternationalShipping : IShippingStrategy
{
    public decimal Calculate(decimal orderTotal, double weightKg) =>
        25m + (decimal)weightKg * 4m;
}

// The context: depends only on the interface
public class CheckoutService
{
    private readonly IShippingStrategy _shipping;

    public CheckoutService(IShippingStrategy shipping) => _shipping = shipping;

    public decimal GetGrandTotal(decimal subtotal, double weightKg) =>
        subtotal + _shipping.Calculate(subtotal, weightKg);
}

// Usage - swap the algorithm without touching CheckoutService
var standard = new CheckoutService(new StandardShipping());
var express  = new CheckoutService(new ExpressShipping());

Console.WriteLine(standard.GetGrandTotal(80m, 2.0)); // 80.00
Console.WriteLine(express.GetGrandTotal(80m, 2.0));  // 95.99

Strategy with delegates

When a strategy is a single method with no state, a Func<> is lighter than an interface:

Func<decimal, decimal> tenPercentOff = price => price * 0.9m;
Func<decimal, decimal> flatFiveOff   = price => Math.Max(0, price - 5);

decimal ApplyDiscount(decimal price, Func<decimal, decimal> strategy) => strategy(price);

Console.WriteLine(ApplyDiscount(100m, tenPercentOff)); // 90
Console.WriteLine(ApplyDiscount(100m, flatFiveOff));   // 95

Use an interface when the strategy has dependencies, configuration, or more than one method; use a delegate when it is a pure function.

Strategy vs. Factory: how they work together

Developers often confuse these two. Factory decides which object to create; Strategy decides how an object behaves. In practice you combine them: a ShippingStrategyFactory maps a customer's region to the right IShippingStrategy, and CheckoutService uses whatever it gets.

Common pitfall

Over-engineering. If you have exactly two branches and they will never grow, a plain if is fine. Reach for Strategy when you have three or more algorithms, when they change independently, or when you want to unit test each one in isolation.

Choosing the Right C# Design Pattern: Quick Reference

  • "I need to create objects but the type varies at runtime." → Factory
  • "There must be exactly one of these." → Singleton (preferably via AddSingleton)
  • "When X happens, many things should react." → Observer (events or Rx)
  • "I have multiple interchangeable ways to do something." → Strategy

Notice a theme: all four push you toward programming to interfaces and dependency injection. Master those two ideas and most of the remaining GoF patterns—Decorator, Command, Adapter—will feel like natural extensions.

Conclusion: Key Takeaways on C# Design Patterns

The C# design patterns covered here—Factory, Singleton, Observer, and Strategy—show up in virtually every production .NET codebase, whether or not the team calls them by name. Here is what to remember:

  • Factory centralizes object creation so callers depend on abstractions. Use keyed DI services in .NET 8+ to avoid hand-written switches.
  • Singleton guarantees a single instance. Use Lazy<T> if you must hand-roll it, but prefer AddSingleton and beware captive dependencies.
  • Observer decouples publishers from subscribers via events or IObservable<T>. Always unsubscribe to avoid memory leaks.
  • Strategy replaces conditional logic with interchangeable classes or delegates, making each algorithm independently testable.
  • Patterns are tools, not goals. Apply them when they remove real pain—duplication, tight coupling, untestable code—not because a diagram looks impressive.

Pick one pattern from this list and refactor a piece of code you own this week. The moment you replace a 40-line switch with a Strategy and watch the unit tests shrink, design patterns in C# stop being theory and become the way you naturally write software.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...