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.
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:
// 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
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:
// 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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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:
// 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.
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.
-
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.
-
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?
-
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?
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 (GZipStreamaround aStream), 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
- Introduction to delegates and events (Microsoft Learn)
- Introduction to events (Microsoft Learn)
- Observer design pattern (Microsoft Learn)
- Iterators (Microsoft Learn)
- File and stream I/O (Microsoft Learn)
- GZipStream class (Microsoft Learn)
- DelegatingHandler class (Microsoft Learn)
- Lazy<T> class (Microsoft Learn)
- Service lifetimes (dependency injection) (Microsoft Learn)
- required modifier (Microsoft Learn)
- Records (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress