Strategy pattern
· Last updated: 2026-10-17 · 42 min read
The Strategy pattern is a behavioral pattern that puts alternative algorithms for the same task behind a common contract, separating the code that uses the task from the way it is carried out.
The caller usually knows what needs doing. The implementation behind the contract decides how to do it.
Personal note: In everyday terms, the main contractor sets the contract and the subcontractor does the implementation. A good subcontractor does not need to know the main contractor's internal affairs. A good programmer is rather like a good subcontractor.
An order can delegate discount calculations to IDiscountPolicy, or diagnostic-output code can leave string conversion to a separate formatter.
It can be hard to picture a component that knows the promised task and result while leaving the method to another implementation. Most modern languages give us ways to express this: interfaces, function types, delegates, or traits.
Typical uses include discounts, sorting rules, and compression, where the purpose stays the same but the algorithm varies.
Building a class hierarchy from the outset can easily become an anti-pattern. In practice, I find it easier to write down the requirements first, identify what needs to be replaceable, and agree on what every implementation at that point must promise.
The GoF book Design Patterns describes Strategy as encapsulating a family of related algorithms so they can be interchanged. It also gives Policy as another name for the pattern.1
There is no reason to start with several classes. A conditional may be enough when there are only two algorithms. The situation changes when the same conditional appears in several places and every algorithm change requires updating all of them.
Suppose we are building a compiler.
Personal note: pgy is the file extension for my language.
When the compiler finds an error in source code, it prints a diagnostic for the user.
main.pgy:12: E001: Name not found.JSON can be more convenient when a development tool or another program needs to process that diagnostic.
A person can read the error sentence, but a program needs to extract the file name, line number, and error code separately. Named JSON fields make those values easier to distinguish than splitting and interpreting the sentence again.
With AI tools so common now, suppose the developer wants to pass the data to one in a convenient JSON format.
At first, a conditional can choose the output format.
{
"file": "main.pgy",
"line": 12,
"code": "E001",
"message": "Name not found."
}This role of converting the same data into a chosen representation is called a formatter.
What is the data here? The error location, code, and message. A formatter turns those values into a readable sentence or a JSON string that another program can process.
The formatter has produced only a string so far. An output component decides whether to display it or write it to a file, so the representation and the output destination can change independently.
Report(diagnostic, format, output)
├─ human → build human-readable text
└─ json → build a JSON string
↓
write to outputNow imagine more formats. The human-readable format may gain color and newline rules; JSON may need new fields and a schema version. If the same format selection is repeated for file output and batch runs, changing one format means checking several output paths. That is how these things tend to go: a small task starts revealing its complications once you implement it.
The original job of Report() was to write a diagnostic. It now also manages the internal rules of every output format.
Give each format the same Format(diagnostic) contract, then let separate implementations create the human-readable sentence and JSON.
A contract is a promise the caller can rely on. If an interface says that every formatter accepts a Diagnostic and returns a string, the output component does not need a different calling convention for each implementation.
The diagnostic reporter now needs to know only which formatter it has been given, without knowing how each format works internally.
To add a format, implement the common contract and connect it at the selection point. The reporter's output flow can stay as it is.
The point is to separate interchangeable algorithms with different reasons to change from the component that uses them, not simply to eliminate conditionals.
Consider it when alternatives perform the same role and their implementations need to be explained or replaced independently.
Even in the examples below, a lookup table may suffice if only a few labels differ. Look at what actually changes rather than starting from the feature's name.
Use case | Replaceable part |
|---|---|
Diagnostics and reports | Human-readable text, JSON, or another output format |
Product sorting | Price order, newest first, or priority products |
Price calculation | Percentage discounts, fixed discounts, or other calculation rules |
Data compression | Compression methods that satisfy a common input and result contract |
Part of a retry policy | Calculating the delay from the attempt count |
Diagnostics and reports
Human-readable text, JSON, or another output format
Product sorting
Price order, newest first, or priority products
Price calculation
Percentage discounts, fixed discounts, or other calculation rules
Data compression
Compression methods that satisfy a common input and result contract
Part of a retry policy
Calculating the delay from the attempt count
Game damage formulas are another common use, though different games calculate damage in very different ways. Think of this as something you can apply to a game's damage calculation, rather than one universal formula.
The diagnostic-output example maps to the pattern's roles as follows.
Role | Name in this example | Responsibility |
|---|---|---|
Context |
| Requests formatting and writes the result |
Strategy |
| Defines the common contract for converting a diagnostic into a string |
Concrete Strategy |
| Implements the conversion for each format |
Client | Startup code or request-handling code | Chooses a format and connects the reporter to its strategy |
Context
DiagnosticReporter
Requests formatting and writes the result
Strategy
IDiagnosticFormatter
Defines the common contract for converting a diagnostic into a string
Concrete Strategy
HumanDiagnosticFormatter, JsonDiagnosticFormatter
Implements the conversion for each format
Client
Startup code or request-handling code
Chooses a format and connects the reporter to its strategy
Context is the code that uses the strategy; Concrete Strategy is the implementation that does the work. Client chooses the implementation and connects them. In this example, the program's startup code takes that role.
If format selection grows, it can go in a factory. Here, a factory takes a name such as human or json and creates the corresponding formatter, as shown in Appendix A.
Startup or request-handling code
-> validate the format name and select a formatter
-> pass the formatter to DiagnosticReporter
DiagnosticReporter.Report(diagnostic, output)
-> IDiagnosticFormatter.Format(diagnostic)
-> HumanDiagnosticFormatter or JsonDiagnosticFormatter
-> write the returned string to outputSelection stays outside DiagnosticReporter, so the reporter does not need to know the format names. A component can select its own strategy, too. Here, letting the assembly code make that choice makes it easier to change selection and execution rules independently.
Start with a valid, single-line diagnostic and concentrate on replacing the formatter. The three C# blocks in this section form one program when combined in order. Appendix A adds input validation.
Set the contract: convert one diagnostic into a string.
What do we need for that?
The data, and a place to supply the conversion.
using System;
using System.IO;
using System.Text.Json;
public sealed record Diagnostic(string File, int Line, string Code, string Message);
public interface IDiagnosticFormatter
{
string Format(Diagnostic diagnostic);
}Human-readable text and JSON are the two implementations. They receive the same diagnostic and each supplies its own string-building rules.
public sealed class HumanDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) =>
$"{diagnostic.File}:{diagnostic.Line}: {diagnostic.Code}: {diagnostic.Message}";
}
public sealed class JsonDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) =>
JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = diagnostic.File,
line = diagnostic.Line,
code = diagnostic.Code,
message = diagnostic.Message
});
}The reporter calls the formatter it was given and writes the result to the writer.
The format is chosen when assembling new DiagnosticReporter(...), so the reporter's method stays unchanged between the two calls below.
If the reporter calls new HumanDiagnosticFormatter() internally, it has chosen the human-readable format itself. Passing a formatter in from outside lets us switch to JSON while leaving the reporter unchanged. Supplying a needed object from outside in this way is dependency injection.
public sealed class DiagnosticReporter
{
private readonly IDiagnosticFormatter formatter;
public DiagnosticReporter(IDiagnosticFormatter formatter) =>
this.formatter = formatter;
public void Report(Diagnostic diagnostic, TextWriter output) =>
output.WriteLine(formatter.Format(diagnostic));
}
public static class Program
{
public static void Main()
{
var diagnostic = new Diagnostic(
"main.pgy", 12, "E001", "Name not found.");
var human = new DiagnosticReporter(new HumanDiagnosticFormatter());
var json = new DiagnosticReporter(new JsonDiagnosticFormatter());
human.Report(diagnostic, Console.Out);
json.Report(diagnostic, Console.Out);
}
}This example stores the strategy supplied through the constructor in a readonly field.
For a CLI whose format stays fixed during the process, select it at startup. For a server where requests choose different formats, assemble the reporter with the appropriate strategy when handling each request.
A common misconception is that dependency injection requires a DI container. Passing an object with new is dependency injection even without a container.2
The GoF text also shows fixing a strategy through C++ template arguments. This static selection fits an algorithm chosen per build or deployment; provide runtime replacement only when it is a separate requirement.1
Selection time | Suitable situation | Question to check |
|---|---|---|
Build or compile time | The algorithm is fixed per deployment | Does changing strategy require rebuilding? |
Startup | CLI options or process settings stay fixed | Are configuration errors detected at startup? |
Start of a request or job | Formats or customer policies vary per request | Can selection or state leak between requests? |
During execution | An editor changes its tool or processing mode | Does work already in progress use the old strategy or the new one? |
Build or compile time
The algorithm is fixed per deployment
Does changing strategy require rebuilding?
Startup
CLI options or process settings stay fixed
Are configuration errors detected at startup?
Start of a request or job
Formats or customer policies vary per request
Can selection or state leak between requests?
During execution
An editor changes its tool or processing mode
Does work already in progress use the old strategy or the new one?
When runtime replacement is unnecessary, keep the strategy supplied through the constructor. An editor that needs replacement should define when a strategy may change during a running operation. readonly prevents reference reassignment, not changes to the strategy object's internal state.
A function parameter can be easier to read when the contract is one calculation and the strategies need no separate lifetime management.
In C#, use a delegate or Func<...>. Microsoft's documentation describes calling a method supplied by the caller without the calling code knowing the concrete implementation.3
For a conversion this small, passing a function can replace the formatter object.
The following is a standalone program, separate from the class example. Report accepts a Func<Diagnostic, string>, and the caller chooses Human or Json.
Func<Diagnostic, string> is a type for a function that takes a diagnostic and returns a string. Previously we called an object's Format method; here we receive and call a function that does the same job.
using System;
using System.Text.Json;
var diagnostic = new Diagnostic("main.pgy", 12, "E001", "Name not found.");
string Human(Diagnostic item) =>
$"{item.File}:{item.Line}: {item.Code}: {item.Message}";
string Json(Diagnostic item) => JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = item.File,
line = item.Line,
code = item.Code,
message = item.Message
});
void Report(Diagnostic item, Func<Diagnostic, string> format) =>
Console.WriteLine(format(item));
Report(diagnostic, Human);
Report(diagnostic, Json);
public sealed record Diagnostic(string File, int Line, string Code, string Message);The object's formatter.Format(diagnostic) has become format(diagnostic). This is shorter when there is no per-instance configuration or resource to hold. Appendix B applies it to several diagnostics together.
Personal note: You might wonder why this works. C# can pass a compatible method as a delegate value. In programming, we decide what stays fixed and what may vary. Strategy lets us swap algorithms that accept the same kind of input and produce results serving the same role. An object-oriented implementation can use an interface; in C#, a method can also be supplied through a delegate variable.
The code that receives a list does not have to know whether the order is by price or the reverse. With a valid product list, the part that passes in the ordering function looks like this. This is another standalone program.
In the formatter example, we changed the rule for turning a diagnostic into a string. Here we change the rule for ordering products, while keeping the common flow that accepts a list and returns a result.
using System;
using System.Collections.Generic;
using System.Linq;
var products = new[]
{
new Product(3, 2000m),
new Product(2, 1000m),
new Product(1, 1000m)
};
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> cheapestFirst =
items => items.OrderBy(item => item.PriceWon).ThenBy(item => item.Id);
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> expensiveFirst =
items => items.OrderByDescending(item => item.PriceWon).ThenBy(item => item.Id);
void Print(Func<IEnumerable<Product>, IOrderedEnumerable<Product>> order) =>
Console.WriteLine(string.Join(",", order(products).Select(item => item.Id)));
Print(cheapestFirst); // 1,2,3
Print(expensiveFirst); // 3,1,2
Console.WriteLine(string.Join(",", products.Select(item => item.Id))); // 3,2,1
public sealed record Product(int Id, decimal PriceWon);The two functions return the same elements in different orders, and the caller sorts with the function it receives. Use ThenBy to compare IDs when prices tie; another OrderBy establishes a new primary sort key. Appendix B extends the example.4
TypeScript can receive a comparison rule in the same way. This version assumes valid safe-integer prices and IDs, copies the input array, and sorts it. Appendix B adds input validation.
The comparison function receives two products and decides which comes first. A negative result puts the first product before the second, a positive result puts it after, and zero treats them as tied. The sorting code does not need to know the product-pricing rule itself.
type Product = Readonly<{ id: number; priceWon: number }>;
type ProductComparison = (left: Product, right: Product) => number;
// A comparison leaves inputs unchanged and expresses order with a negative value, zero, or a positive value.
const byPriceAscending: ProductComparison = (left, right) =>
left.priceWon - right.priceWon || left.id - right.id;
const byPriceDescending: ProductComparison = (left, right) =>
right.priceWon - left.priceWon || left.id - right.id;
function sortProducts(
products: readonly Product[],
compare: ProductComparison,
): Product[] {
// sort mutates the array, so copy the array before sorting.
// The product objects themselves are not deep-copied.
return [...products].sort(compare);
}
const products: readonly Product[] = [
{ id: 3, priceWon: 2000 },
{ id: 2, priceWon: 1000 },
{ id: 1, priceWon: 1000 },
];
const cheapFirst = sortProducts(products, byPriceAscending);
const expensiveFirst = sortProducts(products, byPriceDescending);
console.log(cheapFirst.map((product) => product.id)); // [1, 2, 3]
console.log(expensiveFirst.map((product) => product.id)); // [3, 1, 2]
console.log(products.map((product) => product.id)); // [3, 2, 1]Both strategies put the smaller ID first when prices tie, so their tie-breaking rule is the same. The array is copied, but the product objects inside it are not deep-copied. Even this minimal example requires a comparison function that leaves its inputs unchanged and defines a consistent order.5
Checking that a formatter produces the right string is different from checking that the program selected the right formatter.
Personal note: Working hard at exercise does not tell me whether I am putting the right effort into programming, either.
Even a thoroughly tested JSON implementation gives the wrong output if "json" is accidentally connected to the human-readable formatter. Test the implementations and the selection code.
What to verify | Example check |
|---|---|
Input contract | Reject nonpositive line numbers, empty messages, and CR/LF in file names |
Common contract | Return a nonempty, single-line string and the same result for the same input |
Format-specific meaning | Keep the human-readable location correct and preserve JSON fields and the original message |
Selection boundary | Connect |
Reporter output | Write one line separator after the string and leave the caller-owned writer open |
Failure propagation | Reject contract violations and expose failures of the output device |
Input contract
Reject nonpositive line numbers, empty messages, and CR/LF in file names
Common contract
Return a nonempty, single-line string and the same result for the same input
Format-specific meaning
Keep the human-readable location correct and preserve JSON fields and the original message
Selection boundary
Connect human and json correctly and reject unknown names
Reporter output
Write one line separator after the string and leave the caller-owned writer open
Failure propagation
Reject contract violations and expose failures of the output device
Appendix C contains executable tests for the implementations and their selection.
Passing an object or function can look similar even when the reason for separating it is different.
Personal note: More programming experience means seeing more detail. As a beginner, you might use pattern Z whenever situation C appears. With experience, you have more options for C, and may notice that C is really a combination of A and B that can be separated further.
Replacing a formatter, wrapping it to add a heading, and storing output as a request to execute later have bits of similar code, but differ in small ways. I am not quite sure how to put that subtlety into words.
Similar concept | Example | Distinction from Strategy |
|---|---|---|
State pattern | Delegate cancellation and dispatch to the current order-state object | Represent state-specific behavior and transitions, beyond a state value |
Template Method | Override a particular stage of an import operation | Use the parent's execution skeleton and stable inheritance hooks |
Command | Queue an output request for later execution | Store the request to execute rather than an alternative algorithm |
Adapter | Adapt an external library's API to the internal formatter contract | Make calling conventions and data representations compatible |
Decorator | Wrap an existing formatter to add a heading | Add functionality while retaining the existing behavior |
DI and composition | Supply a formatter to the component and delegate conversion to it | Describe dependency assembly, with a different intent and scope from algorithm replacement |
Function composition and pipelines | Read, parse, validate, and save in sequence | Execute several stages rather than choose one alternative |
Configuration and lookup tables | Keep the calculation while varying a rate or name | Different values alone do not require more strategy classes |
State pattern
Delegate cancellation and dispatch to the current order-state object
Represent state-specific behavior and transitions, beyond a state value
Template Method
Override a particular stage of an import operation
Use the parent's execution skeleton and stable inheritance hooks
Command
Queue an output request for later execution
Store the request to execute rather than an alternative algorithm
Adapter
Adapt an external library's API to the internal formatter contract
Make calling conventions and data representations compatible
Decorator
Wrap an existing formatter to add a heading
Add functionality while retaining the existing behavior
DI and composition
Supply a formatter to the component and delegate conversion to it
Describe dependency assembly, with a different intent and scope from algorithm replacement
Function composition and pipelines
Read, parse, validate, and save in sequence
Execute several stages rather than choose one alternative
Configuration and lookup tables
Keep the calculation while varying a rate or name
Different values alone do not require more strategy classes
In programming, state is current information that affects later behavior. It can be stored in variables or fields, such as a login-failure count or an order's Paid value. The State pattern is a particular design within that broad idea: separate state-specific behavior and delegate to the implementation of the current state.
Term | Meaning |
|---|---|
State value | Stored information such as the current failure count, order status, or edited content |
State machine | A model of transitions and behavior determined by states and events |
State pattern | A design that separates state-specific behavior into objects or similar units and delegates to the current state's implementation |
State value
Stored information such as the current failure count, order status, or edited content
State machine
A model of transitions and behavior determined by states and events
State pattern
A design that separates state-specific behavior into objects or similar units and delegates to the current state's implementation
A state machine can be implemented with enum + switch or a transition table. Separating state-specific behavior into implementations that the main component calls can be described as the GoF State pattern.6 (State and the State pattern are different, though people do use the terms interchangeably.)
An order moving through Awaiting payment -> Paid -> Shipping describes a state model. When cancellation, dispatch, and other operations contain scattered state-specific branches, there is a reason to consider grouping that behavior in PendingState, PaidState, and ShippedState and delegating to the current state object. A transition function or table may be enough if the transition rules are simple to inspect.
The shortcut "Strategy changes from outside; State changes from inside" does answer many simple questions. It becomes confusing when an external event changes state or a component selects its strategy automatically. Ask whether the design represents alternative algorithms for the same role or the object's state and transitions, rather than judging only by who makes the change.
In Template Method, a parent method calls an overridden hook on this; in Strategy, the component calls a collaborating object or function. An import operation can expose a stable sequence through inheritance hooks while receiving its parsing algorithm as a strategy, so the patterns can be combined. Inheritance can be used to control execution order, but it is not required for that purpose.
A decorator wrapped around the chosen formatter must still satisfy the output contract. Prepending a heading to a JSON document keeps the return type string but breaks JSON parsing. Adding functionality and keeping the result readable by existing consumers are separate questions. A decorator that preserves the contract can itself act as a strategy, and an adapter that fits an external API to the internal contract can be selected as one strategy among others.
Choosing a CSV parser or a JSON parser can be described as strategy selection. A flow that reads, parses, validates, and saves needs input and output contracts and failure or stopping rules for each stage. Those stages can have different types; they do not need to be forced through one interface.
Passing a formatter through a constructor is DI; assembling an object with another object and delegating conversion is composition. What makes this example Strategy is that the supplied implementations are alternatives for the same role and can be replaced while honoring their common contract.
Strategy fits choosing one alternative for a role. When several discount rules must all apply, the code combining them also needs rules for their order and whether they can be stacked.
Suppose the list price is 10,000 won and both a 10% discount and a 1,000-won discount apply. Taking the percentage off first gives 8,000 won; taking the fixed amount off first gives 8,100 won. Even if both implementations satisfy IDiscountPolicy, the discount domain must decide which calculation is intended.
Define the discount base, application order, stacking rules, and rounding point as domain rules. One composite strategy or a separate discount pipeline can implement them. Creating a class for every possible combination, such as KoreaGoldMemberHolidayDiscountStrategy, tangles the changing dimensions together again.
If every country uses the same calculation and only the tax rate differs, passing rate data to one strategy or using a table may suffice. Distinguishing a value change from an algorithm change keeps class counts from growing unnecessarily.
Starting by naming classes can make the same code look like Strategy or State.
Write one sentence about what the requirement changes. "Change the transmission method" and "store a transmission request to execute later" describe different changes from the outset.
Suppose we are building notifications.
The broad request to "build notifications" needs to be broken down according to its details.
Added requirement | Design choice |
|---|---|
Send the same delivery request by email or another channel | Consider Strategy if the channels can share a delivery contract |
Keep the channel and add elapsed-time measurement around delivery | Consider a Decorator around the existing delivery operation |
The external SDK's delivery function and response do not match the internal contract | Use an Adapter to translate the difference |
Store the request and send it at a scheduled time | Separate request data from the executor, or express it as a Command |
Display waiting, processing, and completed delivery states | Start with a state value; a status display alone does not call for the State pattern |
Cancellation, resumption, other behavior, and stored data differ substantially by state | Consider whether the State pattern helps group state-specific responsibilities |
Send the same delivery request by email or another channel
Consider Strategy if the channels can share a delivery contract
Keep the channel and add elapsed-time measurement around delivery
Consider a Decorator around the existing delivery operation
The external SDK's delivery function and response do not match the internal contract
Use an Adapter to translate the difference
Store the request and send it at a scheduled time
Separate request data from the executor, or express it as a Command
Display waiting, processing, and completed delivery states
Start with a state value; a status display alone does not call for the State pattern
Cancellation, resumption, other behavior, and stored data differ substantially by state
Consider whether the State pattern helps group state-specific responsibilities
When these requirements coexist, an executor can take a scheduled request, select a channel strategy, and let that strategy use an adapter for the external SDK.
State-specific labels can stay in a lookup table, rate differences in configuration, and a small branch used in one place in a conditional. If one validation function makes state transitions readable and sufficiently testable, separate classes for every state are unnecessary.
A library that accepts user-supplied algorithms can justify a contract even with one built-in implementation. Without a replacement requirement, adding only DefaultStrategy may just add declarations and assembly code. Start with what actually needs to vary.
The publisher's record for Design Patterns gives October 31, 1994 as the publication date and 1995 as the copyright year.7 This dates the book's account of designs already in use, with names and collaboration structures.
The book's Strategy example separates line-breaking algorithms from the document-layout object. It keeps the layout object while replacing algorithms that work line by line or consider an entire paragraph. The intent is the same separation now often explained through discounts or sorting rules.1
In a 2009 retrospective interview, Ralph Johnson explained that some patterns may become unnecessary when a language offers other ways to solve the same problem, and that the GoF patterns are not intended for every language.8 A replacement point expressed with C# interfaces and classes can be a function parameter in TypeScript or Python. Use the form that makes the role and contract clear in the language at hand.
This extends the object example with input validation, output contracts, and format selection. It reuses the same type names, so keep it separate from the minimal version. Combine the C# blocks in Appendices A through C in order to form one program.
This method selects the format and builds its string in one place.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
public sealed class BranchingDiagnosticReporter
{
public void Report(Diagnostic diagnostic, string format, TextWriter output)
{
ArgumentNullException.ThrowIfNull(diagnostic);
ArgumentNullException.ThrowIfNull(format);
ArgumentNullException.ThrowIfNull(output);
string rendered;
switch (format)
{
case "human":
var line = diagnostic.Line.ToString(CultureInfo.InvariantCulture);
var message = diagnostic.Message.Replace("\r", "\\r").Replace("\n", "\\n");
rendered = $"{diagnostic.File}:{line}: {diagnostic.Code}: {message}";
break;
case "json":
rendered = JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = diagnostic.File,
line = diagnostic.Line,
code = diagnostic.Code,
message = diagnostic.Message
});
break;
default:
throw new ArgumentException("Unsupported output format.", nameof(format));
}
output.WriteLine(rendered);
}
}Two implementations can share the signature string Format(Diagnostic diagnostic) yet behave differently from the caller's perspective if one returns output text and another creates a file and returns its path. Replaceable implementations must agree on the meaning of the result and the permitted side effects, as well as the return type.
The example uses .NET 8 and C# 12 with nullable analysis enabled and no external packages.9
A formatter accepts one diagnostic and returns a nonempty string. The same input and fixed configuration produce the same result. It writes no output, leaves the input unchanged, and returns no actual CR or LF characters. The human-readable format displays message line breaks as \r and \n; the JSON serializer escapes them.
Diagnostic holds a file name, positive line number, diagnostic code, and message. The file name, code, and message all reject null, empty strings, and whitespace-only strings. File names and codes also reject CR/LF; a message with content may span several lines.
public sealed class Diagnostic
{
public string File { get; }
public int Line { get; }
public string Code { get; }
public string Message { get; }
public Diagnostic(string file, int line, string code, string message)
{
File = RequireSingleLine(file, nameof(file));
Code = RequireSingleLine(code, nameof(code));
if (line <= 0)
throw new ArgumentOutOfRangeException(nameof(line));
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("A diagnostic message is required.", nameof(message));
Line = line;
Message = message;
}
private static string RequireSingleLine(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value) || value.Contains('\r') || value.Contains('\n'))
throw new ArgumentException("A nonempty single-line value is required.", parameterName);
return value;
}
}
public interface IDiagnosticFormatter
{
// Convert strings only; do not write output or mutate input.
// Return the same result for the same input and fixed configuration.
// The result must be nonempty and contain no actual CR/LF.
string Format(Diagnostic diagnostic);
}Invalid arguments raise argument-related exceptions; a formatter's output-contract violation raises InvalidOperationException. An output device's IOException also reaches the caller.
Human-readable text and JSON have different syntax even for the same diagnostic. They remain interchangeable here because both honor the common promise: return a nonempty string without actual CR/LF, and leave writing to the reporter.
public sealed class HumanDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic)
{
ArgumentNullException.ThrowIfNull(diagnostic);
var line = diagnostic.Line.ToString(CultureInfo.InvariantCulture);
var message = diagnostic.Message
.Replace("\r", "\\r")
.Replace("\n", "\\n");
return $"{diagnostic.File}:{line}: {diagnostic.Code}: {message}";
}
}
public sealed class JsonDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic)
{
ArgumentNullException.ThrowIfNull(diagnostic);
// Do not build JSON by concatenating strings.
// Let the serializer escape quotes and line breaks.
return JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = diagnostic.File,
line = diagnostic.Line,
code = diagnostic.Code,
message = diagnostic.Message
});
}
}System.Text.Json produces JSON with message line breaks escaped inside strings. schemaVersion and the field names belong to this example's format.10
DiagnosticReporter writes the formatter's returned string without identifying the format. The output device is supplied externally too, so a test can use StringWriter while the real program uses a file or standard error.
public sealed class DiagnosticReporter
{
private readonly IDiagnosticFormatter formatter;
public DiagnosticReporter(IDiagnosticFormatter formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
this.formatter = formatter;
}
public void Report(Diagnostic diagnostic, TextWriter output)
{
ArgumentNullException.ThrowIfNull(diagnostic);
ArgumentNullException.ThrowIfNull(output);
var rendered = formatter.Format(diagnostic);
// Implementing an interface alone does not enforce behavioral contracts.
// Check the output shape that the reporter can verify immediately.
if (string.IsNullOrEmpty(rendered) || rendered.Contains('\r') || rendered.Contains('\n'))
throw new InvalidOperationException("The formatter violated the nonempty, CR/LF-free output contract.");
output.WriteLine(rendered);
// Do not close the caller-supplied writer here.
}
}The reporter checks only for empty strings and actual CR/LF. Each formatter's tests check its fields and format-specific meaning.
If a program receives the format as a string, some code must map that string to an implementation. That small conditional can stay.
public static class DiagnosticFormatterFactory
{
public static IDiagnosticFormatter Create(string format)
{
ArgumentNullException.ThrowIfNull(format);
return format switch
{
"human" => new HumanDiagnosticFormatter(),
"json" => new JsonDiagnosticFormatter(),
_ => throw new ArgumentException(
"Unsupported output format.", nameof(format))
};
}
}Assembling new DiagnosticReporter(DiagnosticFormatterFactory.Create("json")) gives the reporter a JSON formatter. A CLI can report invalid user-supplied names as argument-parsing errors rather than silently changing an unsupported format to human.
Adding a strategy changes its format implementation and the factory's registration, while the reporter's output flow stays unchanged. This division of change is the connection to the open-closed principle. A requirement that changes the common contract still calls for reviewing the reporter and existing strategies together.
If only conversion results are needed, without writing to an output device, functions can be used as follows. Render accepts a formatting function and checks its result; RenderAll applies it to a diagnostic list. The supplied function must honor the earlier side-effect-free and deterministic contract. This form serves callers that need string conversion only, unlike DiagnosticReporter, which also writes output.
public static class DiagnosticFunctions
{
public static string Render(Diagnostic diagnostic, Func<Diagnostic, string> format)
{
ArgumentNullException.ThrowIfNull(diagnostic);
ArgumentNullException.ThrowIfNull(format);
var rendered = format(diagnostic);
if (string.IsNullOrEmpty(rendered) || rendered.Contains('\r') || rendered.Contains('\n'))
throw new InvalidOperationException("The formatting function violated the nonempty, CR/LF-free output contract.");
return rendered;
}
public static string[] RenderAll(
IEnumerable<Diagnostic> diagnostics,
Func<Diagnostic, string> format)
{
ArgumentNullException.ThrowIfNull(diagnostics);
ArgumentNullException.ThrowIfNull(format);
// Use Select to convert each diagnostic and ToArray to materialize the result.
return diagnostics.Select(item => Render(item, format)).ToArray();
}
}Pass a method group such as DiagnosticFunctions.RenderAll(items, new HumanDiagnosticFormatter().Format), or a lambda such as item => json.Format(item). The caller selects format, Select applies it to multiple inputs, and Render checks for empty results and CR/LF. The function remains responsible for output meaning, determinism, and avoiding side effects.
An existing formatter can supply its method as a function value. A small rule without separate settings or resource lifetime can start as a function.
First decide whether only the key changes or whether sort direction and priority change too. The lambda in OrderBy(product => product.PriceWon) selects a key. The order parameter below supplies the whole ordering rule combining OrderBy and ThenBy.
public sealed record CatalogProduct(int Id, decimal PriceWon);
public static class ProductOrdering
{
public static IOrderedEnumerable<CatalogProduct> CheapestFirst(
IEnumerable<CatalogProduct> products) =>
products.OrderBy(product => product.PriceWon).ThenBy(product => product.Id);
// The same rule expressed with LINQ query syntax.
public static IOrderedEnumerable<CatalogProduct> CheapestFirstQuery(
IEnumerable<CatalogProduct> products) =>
from product in products
orderby product.PriceWon, product.Id
select product;
public static IOrderedEnumerable<CatalogProduct> ExpensiveFirst(
IEnumerable<CatalogProduct> products) =>
products.OrderByDescending(product => product.PriceWon).ThenBy(product => product.Id);
public static CatalogProduct[] Sort(
IEnumerable<CatalogProduct> products,
Func<IEnumerable<CatalogProduct>, IOrderedEnumerable<CatalogProduct>> order)
{
ArgumentNullException.ThrowIfNull(products);
ArgumentNullException.ThrowIfNull(order);
// Enumerate once, then use the same input for validation and sorting.
var snapshot = products.ToArray();
foreach (var product in snapshot)
{
ArgumentNullException.ThrowIfNull(product);
if (product.Id <= 0 || product.PriceWon < 0)
throw new ArgumentOutOfRangeException(nameof(products), "IDs must be positive and prices must be nonnegative.");
}
// The strategy must only reorder elements, without adding or dropping any.
return order(snapshot).ToArray();
}
public static Func<IEnumerable<CatalogProduct>, IOrderedEnumerable<CatalogProduct>>
PriorityFirst(int productId)
{
if (productId <= 0)
throw new ArgumentOutOfRangeException(nameof(productId));
// productId is a fixed setting used by the returned function.
return products => products
.OrderBy(product => product.Id == productId ? 0 : 1)
.ThenBy(product => product.PriceWon)
.ThenBy(product => product.Id);
}
}ProductOrdering.Sort(products, ProductOrdering.CheapestFirst) and ProductOrdering.Sort(products, ProductOrdering.ExpensiveFirst) keep the same validation and result-building flow while varying the sorting rule. Passing PriorityFirst(3) puts products with ID 3 first, then orders the rest by price and ID. If none exists, the entire list is ordered by price and ID. Duplicate IDs are allowed here, so every product with the priority ID receives that priority.
CheapestFirstQuery expresses the same rule with from, orderby, and select. Here the compiler translates it to calls corresponding to .OrderBy(...).ThenBy(...), so choose the notation you find clearer.11
PriorityFirst is a higher-order function that returns a function. The returned lambda remembering productId is a closure. Instead of adding a strategy class for each setting, we create and pass a configured function; C# lambdas can capture outer variables in this way.12 Capturing a mutable configuration object lets later calls observe its changed values, so decide whether settings should be fixed or read as they change.
Enumerable.OrderBy uses deferred execution. This Sort materializes its input and result with ToArray(), completing the sort inside the call and returning an array.13
This function promises to reorder without adding or removing elements, but the Func type cannot enforce that promise. The receiver validates input; the supplied strategy should be tested for empty input, ties, and element preservation. A function that filters with Where before sorting can have the same return type while violating this contract.
Readonly<T> and readonly T[] constrain type checking; they do not freeze objects or arrays at runtime.14
TypeScript can take a product comparison function as a strategy. This example runs separately from the C# examples. Prices are nonnegative safe integers in won, and IDs are positive safe integers. Both strategies put the smaller ID first when prices tie.
type Product = Readonly<{ id: number; priceWon: number }>;
type ProductComparison = (left: Product, right: Product) => number;
// A comparison leaves inputs unchanged and expresses order with a negative value, zero, or a positive value.
const byPriceAscending: ProductComparison = (left, right) =>
left.priceWon - right.priceWon || left.id - right.id;
const byPriceDescending: ProductComparison = (left, right) =>
right.priceWon - left.priceWon || left.id - right.id;
function sortProducts(
products: readonly Product[],
compare: ProductComparison,
): Product[] {
for (const product of products) {
if (!Number.isSafeInteger(product.priceWon) || product.priceWon < 0) {
throw new RangeError("Prices must be nonnegative safe integers.");
}
if (!Number.isSafeInteger(product.id) || product.id <= 0) {
throw new RangeError("IDs must be positive safe integers.");
}
}
// sort mutates the array, so copy the array before sorting.
// The product objects themselves are not deep-copied.
return [...products].sort(compare);
}
const products: readonly Product[] = [
{ id: 3, priceWon: 2000 },
{ id: 2, priceWon: 1000 },
{ id: 1, priceWon: 1000 },
];
const cheapFirst = sortProducts(products, byPriceAscending);
const expensiveFirst = sortProducts(products, byPriceDescending);
console.log(cheapFirst.map((product) => product.id)); // [1, 2, 3]
console.log(expensiveFirst.map((product) => product.id)); // [3, 1, 2]
console.log(products.map((product) => product.id)); // [3, 2, 1]The flow still copies the product array and calls sort, while the comparison function chooses cheaper or more expensive products first. That comparison rule is the replaceable strategy.
A callback that reports completion and one that decides sort order are both functions, but have different jobs. The comparison callback fits Strategy because it supplies one of several ways to perform the same role. Changing the order unpredictably between calls or mutating inputs breaks that promise. A function type cannot check ordering consistency, so JavaScript comparison functions must also obey conditions such as consistent results for the same input, antisymmetry, and transitivity.5
Add this code to the same file as the C# blocks in Appendices A and B to run 38 checks. It uses StringWriter to check format selection, output, and invalid input without creating files.
public static class Program
{
private static int checks;
public static void Main()
{
var diagnostic = new Diagnostic("main.pgy", 12, "E001", "Name not found.");
var multiline = new Diagnostic("main.pgy", 12, "E001", "First line\r\nSecond line \"x\"");
var human = DiagnosticFormatterFactory.Create("human");
var json = DiagnosticFormatterFactory.Create("json");
// Both versions write the same diagnostic to the same output device.
foreach (var format in new[] { "human", "json" })
{
foreach (var item in new[] { diagnostic, multiline })
{
using var before = new StringWriter(CultureInfo.InvariantCulture);
using var after = new StringWriter(CultureInfo.InvariantCulture);
new BranchingDiagnosticReporter().Report(item, format, before);
new DiagnosticReporter(DiagnosticFormatterFactory.Create(format)).Report(item, after);
Equal(before.ToString(), after.ToString());
}
}
Equal("main.pgy:12: E001: Name not found.", human.Format(diagnostic));
Equal(true, human is HumanDiagnosticFormatter);
Equal(true, json is JsonDiagnosticFormatter);
foreach (var formatter in new[] { human, json })
{
var rendered = formatter.Format(multiline);
Equal(false, string.IsNullOrEmpty(rendered));
Equal(false, rendered.Contains('\r') || rendered.Contains('\n'));
Equal(rendered, formatter.Format(multiline));
}
using (var parsed = JsonDocument.Parse(json.Format(multiline)))
{
var root = parsed.RootElement;
Equal(1, root.GetProperty("schemaVersion").GetInt32());
Equal(multiline.File, root.GetProperty("file").GetString());
Equal(multiline.Line, root.GetProperty("line").GetInt32());
Equal(multiline.Code, root.GetProperty("code").GetString());
Equal(multiline.Message, root.GetProperty("message").GetString());
}
// Check default-encoder notation separately from the parsed value.
// This notation is not required by the common contract of every JSON formatter.
var encodingProbe = new Diagnostic("main.pgy", 1, "E001", "한글<>&'\"");
using (var parsed = JsonDocument.Parse(json.Format(encodingProbe)))
{
var message = parsed.RootElement.GetProperty("message");
Equal("\"\\uD55C\\uAE00\\u003C\\u003E\\u0026\\u0027\\u0022\"", message.GetRawText());
Equal(encodingProbe.Message, message.GetString());
}
using var output = new StringWriter(CultureInfo.InvariantCulture);
var reporter = new DiagnosticReporter(human);
reporter.Report(diagnostic, output);
// Follow this writer's NewLine setting instead of hard-coding a separator.
Equal(human.Format(diagnostic) + output.NewLine, output.ToString());
output.Write("still open"); // Check that the caller-owned writer remains open.
Equal(true, output.ToString().EndsWith("still open", StringComparison.Ordinal));
Expect<ArgumentOutOfRangeException>(() => new Diagnostic("main.pgy", 0, "E001", "Error"));
Expect<ArgumentException>(() => new Diagnostic("main.pgy", 1, "E001", " "));
Expect<ArgumentException>(() => new Diagnostic("main\n.pgy", 1, "E001", "Error"));
Expect<ArgumentException>(() => DiagnosticFormatterFactory.Create("jsno"));
Expect<ArgumentNullException>(() => new DiagnosticReporter(null!));
using var rejectedOutput = new StringWriter(CultureInfo.InvariantCulture);
Expect<InvalidOperationException>(() =>
new DiagnosticReporter(new BrokenFormatter()).Report(diagnostic, rejectedOutput));
Equal("", rejectedOutput.ToString());
using var failingOutput = new FailingWriter();
Expect<IOException>(() => reporter.Report(diagnostic, failingOutput));
// Method groups and lambdas can satisfy the same function contract.
Func<Diagnostic, string> formatFunction = human.Format;
Equal(human.Format(diagnostic), DiagnosticFunctions.Render(diagnostic, formatFunction));
var jsonLines = DiagnosticFunctions.RenderAll(
new[] { diagnostic, multiline }, item => json.Format(item));
Equal(2, jsonLines.Length);
Equal(json.Format(multiline), jsonLines[1]);
var catalog = new[]
{
new CatalogProduct(3, 2000m),
new CatalogProduct(2, 1000m),
new CatalogProduct(1, 1000m)
};
var cheap = ProductOrdering.Sort(catalog, ProductOrdering.CheapestFirst);
var cheapQuery = ProductOrdering.Sort(catalog, ProductOrdering.CheapestFirstQuery);
var expensive = ProductOrdering.Sort(catalog, ProductOrdering.ExpensiveFirst);
var priority = ProductOrdering.Sort(catalog, ProductOrdering.PriorityFirst(3));
Equal("1,2,3", string.Join(",", cheap.Select(product => product.Id)));
Equal("1,2,3", string.Join(",", cheapQuery.Select(product => product.Id)));
Equal("3,1,2", string.Join(",", expensive.Select(product => product.Id)));
Equal("3,1,2", string.Join(",", priority.Select(product => product.Id)));
Equal("3,2,1", string.Join(",", catalog.Select(product => product.Id)));
Console.WriteLine($"C# checks: {checks}");
}
private static void Equal<T>(T expected, T actual)
{
if (!System.Collections.Generic.EqualityComparer<T>.Default.Equals(expected, actual))
throw new Exception($"Expected: {expected}, actual: {actual}");
checks++;
}
private static void Expect<TException>(Action action) where TException : Exception
{
try { action(); }
catch (Exception error)
{
// Unlike catch (TException), require the exact type, not a derived exception.
if (error.GetType() == typeof(TException)) { checks++; return; }
throw new InvalidOperationException(
$"Exception type mismatch: expected {typeof(TException).Name}, actual {error.GetType().Name}", error);
}
throw new InvalidOperationException($"Expected an exception of type {typeof(TException).Name}.");
}
private sealed class BrokenFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) => "first\nsecond";
}
private sealed class FailingWriter : StringWriter
{
public override void WriteLine(string? value) =>
throw new IOException("Simulated output failure");
}
}If a formatter needs only the diagnostic, pass the diagnostic. Passing ApplicationContext, DbContext, configuration storage, and user information so the formatter can find things itself shortens the signature, but hides the information and authority it actually depends on.
An algorithm that needs part of a large component's data structure can receive a narrow query interface. Before handing over the whole object, check whether you can explain the access scope and whether testing a strategy would require starting the entire application.
This formatter's Format() is a synchronous string calculation. Accepting an implementation that calls a remote service also requires a contract for asynchronous results, cancellation, and failures.
The human-readable and JSON formatters here have no instance state, so multiple reporters can share one. A strategy that stores the latest request or a temporary buffer in fields needs review for cross-request data mixing and concurrent access. Replacing the object with a function does not resolve this if its closure retains mutable state.
A strategy that owns a file handle or network connection also needs defined creation and disposal responsibilities. State whether constructor injection transfers ownership or merely lends the resource. The writer in this example belongs to the caller, which also disposes it.
OrderBy call. ↩enum + switch, and the State pattern's delegation to state-specific objects. ↩readonly type checking and its distinction from runtime behavior. ↩