Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 7 – Inheritance, interfaces and polymorphism

Classic design patterns in idiomatic C#

Write strategy, observer, decorator and builder in idiomatic C# with delegates, events, stream wrappers and required members, and spot what .NET ships.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: Strategy: pass the behaviour as a delegate or an interface · Iterator, observer and decorator come with .NET. With a pass: 3 in-depth parts and 3 interview answers, about 10 minutes.

What you will learn

  • Write the strategy pattern with a delegate and with an interface, and choose between them
  • Recognise the iterator, observer and decorator patterns that .NET already provides
  • Replace a builder class with object initializers, required members and with expressions
  • Remove event handlers when a subscriber's life ends, so that a long-lived publisher keeps none alive
  • Use Lazy<T>, static factory methods and wrapping for singleton, factory and decorator

Before you start

What you’ll learn · Before you start

What you will learn

  • Write the strategy pattern with a delegate and with an interface, and choose between them
  • Recognise the iterator, observer and decorator patterns that .NET already provides
  • Replace a builder class with object initializers, required members and with expressions
  • Remove event handlers when a subscriber's life ends, so that a long-lived publisher keeps none alive
  • Use Lazy<T>, static factory methods and wrapping for singleton, factory and decorator

Before you start

On this page

The pricing method in this lesson’s practice spends 28 lines of nested if statements on three discounts and a cap, and every new offer means one more branch in the middle of it. Design patterns name the shapes that cure such code, and C# writes most of them with less ceremony than their textbook diagrams: a delegate for a strategy, an event for an observer, a wrapping stream for a decorator.

You will write a strategy two ways, find the patterns that .NET already ships, and turn a nested-if pricing method into strategies and a decorator. The patterns themselves, language by language, belong to the design-patterns track; this lesson is about their C# forms.

Strategy: pass the behaviour as a delegate or an interface

A design pattern: A named, reusable shape of classes and objects for a problem that recurs in software design, such as strategy, observer or decorator. is a named, reusable shape for a recurring design problem. The strategy pattern: Passing the part of an algorithm that varies into the code that uses it, as a delegate or an interface, so that a new variant needs no change to that code. is the simplest: the part of an algorithm that varies, here the discount, is passed in instead of written inside. discount.cs gives one checkout its rule as a Func<Order, decimal> and another as an object that implements IDiscountRule:

discount.cs: one rule, as a delegate and as an interface C# · discount/discount.cs
// One discount rule, plugged into a checkout two ways: as a delegate and as an interface. Same output.
Order[] orders = [new(800m, IsMember: true), new(1200m, IsMember: false)];

// Strategy as a delegate: the behaviour is a Func<Order, decimal>, here a lambda.
Func<Order, decimal> memberDiscount = order => order.IsMember ? order.Subtotal * 0.10m : 0m;
var byDelegate = new Checkout(memberDiscount);

// Strategy as an interface: the behaviour is an object, with a name and settings of its own.
var byInterface = new RuleCheckout(new MemberDiscount(percent: 10));

foreach (Order order in orders)
{
    Console.WriteLine($"Rs {order.Subtotal:F2}: delegate Rs {byDelegate.Total(order):F2}, interface Rs {byInterface.Total(order):F2}");
}

Console.WriteLine(byInterface.Explain(orders[0]));

record Order(decimal Subtotal, bool IsMember);

class Checkout(Func<Order, decimal> discount)
{
    public decimal Total(Order order) => order.Subtotal - discount(order);
}

interface IDiscountRule
{
    string Name { get; }

    decimal For(Order order);
}

class MemberDiscount(int percent) : IDiscountRule
{
    public string Name => $"member {percent} %";

    public decimal For(Order order) => order.IsMember ? order.Subtotal * percent / 100m : 0m;
}

class RuleCheckout(IDiscountRule rule)
{
    public decimal Total(Order order) => order.Subtotal - rule.For(order);

    public string Explain(Order order) => $"{rule.Name}: Rs {rule.For(order):F2} off";
}

Output

Rs 800.00: delegate Rs 720.00, interface Rs 720.00
Rs 1200.00: delegate Rs 1200.00, interface Rs 1200.00
member 10 %: Rs 80.00 off

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run discount.cs

Both checkouts gave Rs 720.00 for the member’s Rs 800 order and charged the full Rs 1,200 to the other. The delegate needed one lambda and no new type; the interface needed a class, and in return its object carries a name and a setting, which Explain printed:

Use When the strategy
a delegate, Func<Order, decimal> is one operation with no state, written at the call site
an interface, IDiscountRule has several members, settings of its own or a name to show, or is registered in a container

Key idea

A strategy is a parameter of behaviour: pass a delegate when it is one operation, and an interface when it is an object with more than one member or state of its own.

Checkpoint

Check: Strategy as a delegate or an interface

  1. Question 1 of 2 Which constructor takes its discount strategy in delegate form?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Checkout(Func<Order, decimal> discount)

    In both forms the checkout receives the behaviour that varies; the delegate form needs no class of its own.

  2. Question 2 of 2 A delivery rule needs a name for the receipt, a minimum weight that each shop sets, and a Quote method. Which form fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An interface, with one class per rule

    Choose a delegate for one stateless operation and an interface when the strategy has more members or state.

Iterator, observer and decorator come with .NET

Several classic patterns are built into C# and its libraries, so the C# form is to use them, not to rebuild them:

Pattern The C# and .NET form Where you meet it
iterator yield return in a method that returns IEnumerable<T> every foreach over a collection
observer an event, with += and -=; IObservable<T> button clicks, PropertyChanged, diagnostics
decorator a class that wraps an object of its own interface GZipStream and BufferedStream around a Stream
strategy a delegate or an interface parameter IComparer<T> for List<T>.Sort
template method an abstract class with abstract steps Stream itself

The observer pattern is built into the language as the event. Subscribers add a handler with += and remove it with -=, and when the object that owns the event raises it, every handler still subscribed runs.

A decorator pattern: Adding behaviour to an object by wrapping it in another object of the same type, as a GZipStream wraps a Stream, so code that uses the type needs no change. is an object of the same type as the one it wraps, adding work on the way through. GZipStream is a Stream that wraps another Stream and compresses what passes through it, so the code that writes needs nothing but a Stream:

streams.cs: a GZipStream around a MemoryStream C# · streams/streams.cs
// Stream decorators: GZipStream wraps another stream and compresses what passes through it, and the code that
// writes sees only a Stream. The text is 200 copies of one invoice line.
using System.IO.Compression;
using System.Text;

string text = string.Concat(Enumerable.Repeat("INV-1001, Rice 5 kg, 320.00\n", 200));
byte[] original = Encoding.UTF8.GetBytes(text);

using var compressed = new MemoryStream();
using (var gzip = new GZipStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
{
    gzip.Write(original);
}

Console.WriteLine($"original:   {original.Length} bytes");
Console.WriteLine($"compressed: {compressed.Length} bytes");

// Reading back: another decorator, then a StreamReader that turns bytes into text.
compressed.Position = 0;
using var unzip = new GZipStream(compressed, CompressionMode.Decompress);
using var reader = new StreamReader(unzip, Encoding.UTF8);
Console.WriteLine($"round trip equal: {reader.ReadToEnd() == text}");

Output

original:   5600 bytes
compressed: 109 bytes
round trip equal: True

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run streams.cs

The 5,600 bytes of invoice text went into the memory stream as 109, and reading them back through a second GZipStream gave the same text. Follow one write through the two streams:

Interactive · message by message A write and a read through a stream decorator Step through streams.cs. Your code talks only to the GZipStream; watch what reaches the MemoryStream behind it.
A write and a read through a stream decorator, step 5 of 5. Messages so far: Your code to GZipStream: Write 5,600 bytes; GZipStream to MemoryStream: 109 bytes; MemoryStream to GZipStream: 109 bytes; GZipStream to Your code: 5,600 bytes of text.Your codesees a StreamGZipStreamthe decoratorMemoryStream109 bytes storedWrite 5,600 bytes109 bytes109 bytes5,600 bytes of textround trip equal: True

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Wrap the stream. new GZipStream(compressed, CompressionLevel.Optimal, leaveOpen: true) wraps the memory stream. The GZipStream is a Stream too, so code that writes needs nothing else.
  2. Write the text. gzip.Write(original) hands over 5,600 bytes of invoice text. Your code calls an ordinary Stream method and knows nothing about compression.
  3. Compressed bytes go on. The GZipStream compresses what it receives and writes the result to the stream it wraps. Disposing it writes the last of it and the gzip trailer: 109 bytes in all.
  4. Read through a second decorator. For reading, Position goes back to 0 and a GZipStream in Decompress mode wraps the same memory stream; a StreamReader reads text from it.
  5. The text comes back. The decorator turns the 109 bytes back into the 5,600 bytes of text, and the round trip compares equal: True.

Three more patterns have short C# forms too. A builder collects an object’s values step by step before it creates the object. In C# that job mostly falls to object initializers with required members and with expressions, which set every value by name in one expression. A singleton becomes Lazy<T> or a service the container creates once. And a factory becomes a static method with a name that says what it creates.

Checkpoint

Check: Iterator, observer and decorator in .NET

  1. Question 1 of 2 A method that returns IEnumerable<int> and uses yield return implements which pattern?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Iterator

    The iterator pattern is built into C# as yield return and foreach.

  2. Question 2 of 2 Why could the code in streams.cs write compressed data without knowing anything about compression?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: GZipStream is itself a Stream that wraps another Stream, so the code just calls Write on a Stream

    Wrapping lets behaviour be added to an object without changing it or the code that uses it.

Builder without a builder class: required members, init and with Pro: opens with a Pro, Premium or Ultimate pass

A builder class collects values step by step and checks them when it builds. C# can move that check into the compiler, and the run below builds the same order both ways.

In this part:

  • The same order built twice, with a builder and with required members
  • Where .NET itself keeps builders

1 runnable example (C#) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Observer with events, and the subscription that keeps 1,000 objects alive Pro: opens with a Pro, Premium or Ultimate pass

An event is C#’s observer pattern, and its common bug is a subscriber that never leaves. The run below measures it: a closed screen whose 1,000 tiles stay in memory and keep working.

In this part:

  • A closed screen whose tiles stay in memory, measured
  • Events, IObservable<T>, or neither

1 runnable example (C#) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Singleton, factory and decorator in modern C#, in one run Pro: opens with a Pro, Premium or Ultimate pass

The last three patterns change most in modern C#: a singleton needs no static field, a factory needs no factory class, and a decorator is any class that wraps its own interface. One run shows all three.

In this part:

  • One instance without a static field: Lazy<T> and the container
  • Static factory methods, and decorators that wrap an interface

1 runnable example (C#) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise: discounts as strategies and a decorator

pricing.cs keeps the old LegacyPricing, nested if statements that give the right totals, and asks for the same totals from three IDiscount strategies, added up by the AllDiscounts the file gives you, and from CappedDiscount, a decorator that wraps one IDiscount and never gives more than 25 % of the subtotal. All 8 checks fail on the starter:

pricing.cs: eight checks against the old engine C# · practice/pricing.cs
// Practice: replace the nested ifs of LegacyPricing with three IDiscount strategies, added up by AllDiscounts, and a
// decorator that caps what it wraps, composed in Discounts.Standard(). The rules and amounts are made up for this
// exercise. Run this file until all eight checks pass.
using System.Globalization;

Order[] orders =
[
    new(500m, Items: 2, IsMember: false, Festival: false),
    new(800m, Items: 3, IsMember: true, Festival: false),
    new(1200m, Items: 4, IsMember: false, Festival: true),
    new(900m, Items: 4, IsMember: false, Festival: true),
    new(600m, Items: 12, IsMember: false, Festival: false),
    new(2000m, Items: 15, IsMember: true, Festival: true),
    new(1000m, Items: 10, IsMember: true, Festival: true),
];
string[] names = ["no discount", "member 10 %", "festival, Rs 1,000 or more", "festival below Rs 1,000", "bulk, 10 items or more", "all three", "the 25 % cap"];
string[] expected = ["500.00", "720.00", "1050.00", "900.00", "570.00", "1550.00", "750.00"];

var checks = new List<(string Check, Func<string> Run, string Expected)>();
for (int i = 0; i < orders.Length; i++)
{
    Order order = orders[i];
    checks.Add((names[i], () => Money(new PricingEngine(Discounts.Standard()).Total(order)), expected[i]));
}

checks.Add(("same totals as LegacyPricing", () => orders.All(o => new PricingEngine(Discounts.Standard()).Total(o) == LegacyPricing.Total(o)).ToString(), "True"));

int passed = 0;
foreach (var (check, run, want) in checks)
{
    string got;
    try
    {
        got = run();
    }
    catch (Exception e)
    {
        got = $"throws {e.GetType().Name}";
    }

    bool ok = got == want;
    if (ok)
    {
        passed++;
    }

    Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {want}");
}

Console.WriteLine($"{passed} of {checks.Count} checks pass");
return passed == checks.Count ? 0 : 1;

static string Money(decimal amount) => amount.ToString("F2", CultureInfo.InvariantCulture);

record Order(decimal Subtotal, int Items, bool IsMember, bool Festival);

// The old engine: right answers, but every new discount means another branch in this one method.
static class LegacyPricing
{
    public static decimal Total(Order order)
    {
        decimal discount = 0m;
        if (order.IsMember)
        {
            discount += order.Subtotal * 10 / 100;
        }

        if (order.Festival)
        {
            if (order.Subtotal >= 1000m)
            {
                discount += 150m;
            }
        }

        if (order.Items >= 10)
        {
            discount += order.Subtotal * 5 / 100;
        }

        if (discount > order.Subtotal * 25 / 100)
        {
            discount = order.Subtotal * 25 / 100;
        }

        return order.Subtotal - discount;
    }
}

interface IDiscount
{
    decimal For(Order order);
}

class PricingEngine(IDiscount discount)
{
    public decimal Total(Order order) => order.Subtotal - discount.For(order);
}

// Members get 10 % off.
class MemberDiscount : IDiscount
{
    public decimal For(Order order) => throw new NotImplementedException();
}

// During the festival, Rs 150 off orders of Rs 1,000 or more.
class FestivalDiscount : IDiscount
{
    public decimal For(Order order) => throw new NotImplementedException();
}

// 5 % off orders of 10 items or more.
class BulkDiscount : IDiscount
{
    public decimal For(Order order) => throw new NotImplementedException();
}

// Given: an IDiscount that adds up the discounts it holds.
class AllDiscounts(params IDiscount[] discounts) : IDiscount
{
    public decimal For(Order order) => discounts.Sum(discount => discount.For(order));
}

// The decorator: an IDiscount that wraps one IDiscount and never gives more than maxShare of the subtotal.
class CappedDiscount(decimal maxShare, IDiscount inner) : IDiscount
{
    public decimal For(Order order) => throw new NotImplementedException();
}

// The standard discounts: the three strategies, added up, then capped at 25 % of the subtotal.
static class Discounts
{
    public static IDiscount Standard() => throw new NotImplementedException();
}

Output (exit status 1)

no discount: FAIL, throws NotImplementedException instead of 500.00
member 10 %: FAIL, throws NotImplementedException instead of 720.00
festival, Rs 1,000 or more: FAIL, throws NotImplementedException instead of 1050.00
festival below Rs 1,000: FAIL, throws NotImplementedException instead of 900.00
bulk, 10 items or more: FAIL, throws NotImplementedException instead of 570.00
all three: FAIL, throws NotImplementedException instead of 1550.00
the 25 % cap: FAIL, throws NotImplementedException instead of 750.00
same totals as LegacyPricing: FAIL, throws NotImplementedException instead of True
0 of 8 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run pricing.cs

Write the strategies and the cap

Copy pricing.cs. Each discount’s For returns its amount or 0. CappedDiscount.For asks the discount it wraps and returns the smaller of that and order.Subtotal * maxShare. Discounts.Standard() returns a CappedDiscount of 0.25m around an AllDiscounts of the three. Run dotnet run pricing.cs until it prints 8 of 8 checks pass.

Then practise on strategies and the patterns .NET ships:

Practice that adapts to you

Practise this lesson

7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 What does the strategy pattern do?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: It passes the part of an algorithm that varies into the code, instead of writing it inside

    A strategy turns a varying rule into a parameter: a delegate or an object.

  2. Question 2 of 7 Which line gives the delegate-based Checkout a 5 % discount on every order?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: new Checkout(order => order.Subtotal * 0.05m)

    A delegate strategy is usually a lambda written where the checkout is created.

  3. Question 3 of 7 List<T>.Sort accepts an IComparer<T> or a Comparison<T> delegate. Why does .NET offer both forms of the same strategy?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: A delegate suits a one-off comparison written in place; an IComparer<T> suits a reusable comparer with state or a name

    .NET offers both forms of the strategy so callers can choose by the size of the behaviour they pass.

  4. Question 4 of 7 What makes an object a decorator?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: It has the same type as the object it wraps and adds behaviour around it

    Same type, wrapped object, added behaviour: that is why callers cannot tell a decorator is there.

  5. Question 5 of 7 Inside one application, what is the usual C# form of the observer pattern?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: An event, with subscribers adding handlers with += and removing them with -=

    Events are C#'s built-in observer pattern; IObservable<T> is the library's form of the same idea.

  6. Question 6 of 7 What does foreach over a List<T> use to visit the items one at a time?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: The enumerator that the list returns from GetEnumerator, the iterator pattern

    The iterator pattern is built into foreach, and yield return lets your own methods produce iterators.

  7. Question 7 of 7 Stream declares abstract Read and Write, and builds methods such as CopyTo on top of them. Which pattern is that?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: Template method: the base class fixes the algorithm and leaves the steps to derived classes

    Abstract classes with abstract steps and concrete methods built on them are C#'s template method.

Interview questions

Fresher · How do you write the strategy pattern in modern C#?

Pass the part that varies as a parameter. When it is a single operation without state, the parameter is a delegate, such as Func<Order, decimal>, and callers pass a lambda: no interface and no class are needed. When the behaviour has several members, settings of its own or a name, or should be registered in a dependency injection container, the parameter is an interface, such as IDiscountRule, with one class per strategy. .NET uses both forms itself: List<T>.Sort takes an IComparer<T> or a Comparison<T> delegate. Either way, the code that uses the strategy never changes when a new one is added.

  1. Mid to senior

    A desktop app's memory grows all day. A memory snapshot shows thousands of closed OrderView objects, each reachable from a static AppEvents.PriceChanged event. Explain why they are not collected, how you fix it, and how you keep the problem from coming back.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A service uses a hand-written singleton, ConfigCache.Instance, with double-checked locking around a static field. Tests interfere with each other and none can give the code a different configuration. What would you replace it with, and how do you migrate the callers?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    You must add caching, logging and retries around an IExchangeRates service without changing it or its callers. Design it with decorators: in what order do you stack them around the real service, and why does the order matter?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A strategy is behaviour passed in: a delegate for one stateless operation, an interface for an object with several members or state.
  • .NET ships iterator (yield return), observer (events, IObservable<T>) and decorator (GZipStream around a Stream), so the C# form is to use them.
  • A decorator wraps an object of its own type and adds behaviour, so code that holds the type cannot tell it is there.

Terms in this lesson

decorator pattern
Adding behaviour to an object by wrapping it in another object of the same type, as a GZipStream wraps a Stream, so code that uses the type needs no change.
design pattern
A named, reusable shape of classes and objects for a problem that recurs in software design, such as strategy, observer or decorator.
strategy pattern
Passing the part of an algorithm that varies into the code that uses it, as a delegate or an interface, so that a new variant needs no change to that code.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.