Strategy Pattern
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
Why use it?
Why would we need the Strategy pattern?
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."
}We can initially handle the formats with a conditional.
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.
We can separate the part that varies.
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.
That is the reason to use Strategy here.
The point is to separate interchangeable algorithms with different reasons to change from the component that uses them, not simply to eliminate conditionals.
Where it is useful
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 |
- Use case
Diagnostics and reports
- Replaceable part
Human-readable text, JSON, or another output format
- Use case
Product sorting
- Replaceable part
Price order, newest first, or priority products
- Use case
Price calculation
- Replaceable part
Percentage discounts, fixed discounts, or other calculation rules
- Use case
Data compression
- Replaceable part
Compression methods that satisfy a common input and result contract
- Use case
Part of a retry policy
- Replaceable part
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.
Roles and call flow
Let us use diagnostic output as the example.
The pattern's terms map to it 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 |
- Role
Context
- Name in this example
DiagnosticReporter- Responsibility
Requests formatting and writes the result
- Role
Strategy
- Name in this example
IDiagnosticFormatter- Responsibility
Defines the common contract for converting a diagnostic into a string
- Role
Concrete Strategy
- Name in this example
HumanDiagnosticFormatter,JsonDiagnosticFormatter- Responsibility
Implements the conversion for each format
- Role
Client
- Name in this example
Startup code or request-handling code
- Responsibility
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.
As selection grows, it can be collected in a factory or resolver. For the two formats in Appendix A, a small factory is enough. Choose between StrategyFactory, StrategyResolver, and StrategyRegistry according to the size of the selection rules and how implementations are managed.
A factory creates the required implementation; a resolver finds one that matches the conditions. A registry holds registered implementations. With only two formats to choose from here, a small factory will do.
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.
Building diagnostic-output strategies in C#
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, multiline-message handling, and checks for invalid formatter output.
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 that fill that place.
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);
}
}Relaxing the valid-input assumption also shows where the code starts to grow. Appendix A adds input and output contracts and format selection; Appendix C adds executable tests for those promises.
When to select the strategy
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? |
- Selection time
Build or compile time
- Suitable situation
The algorithm is fixed per deployment
- Question to check
Does changing strategy require rebuilding?
- Selection time
Startup
- Suitable situation
CLI options or process settings stay fixed
- Question to check
Are configuration errors detected at startup?
- Selection time
Start of a request or job
- Suitable situation
Formats or customer policies vary per request
- Question to check
Can selection or state leak between requests?
- Selection time
During execution
- Suitable situation
An editor changes its tool or processing mode
- Question to check
Does work already in progress use the old strategy or the new one?
Exposing SetStrategy() when runtime replacement is unnecessary means tracking when other code sharing the object might change its strategy. An editor that genuinely needs replacement can have a setter or another selection mechanism, but first define what replacement means for work in progress. readonly prevents reference reassignment; whether the strategy object's internal state can change depends on its implementation.
Switching to functions and LINQ
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
A minimal strategy supplied as one function
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 extends it with Render and RenderAll for passing an existing object's method or converting 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.
Changing only the sorting rule with LINQ
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 according to the function it receives. To compare IDs when prices tie, continue with ThenBy; calling another OrderBy establishes a new primary sort key.4 Appendix B extends the example with OrderBy's deferred execution and stable ordering, when to materialize the input as an array, a closure that remembers a priority product, and a query-syntax variant.5
Passing a comparison function in TypeScript
TypeScript can receive a comparison rule in much the same way. This version assumes valid safe-integer prices and IDs and shows only copying the input array and sorting it. Appendix B includes input validation and the execution-environment requirements.
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.6
Separating implementation tests from selection tests
Checking that a formatter produces the right string is different from checking that the program selected the right formatter.
A correctly formatted string alone does not tell us whether that formatter suits the program's request.
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 |
- What to verify
Input contract
- Example check
Reject nonpositive line numbers, empty messages, and CR/LF in file names
- What to verify
Common contract
- Example check
Return a nonempty, single-line string and the same result for the same input
- What to verify
Format-specific meaning
- Example check
Keep the human-readable location correct and preserve JSON fields and the original message
- What to verify
Selection boundary
- Example check
Connect
humanandjsoncorrectly and reject unknown names
- What to verify
Reporter output
- Example check
Write one line separator after the string and leave the caller-owned writer open
- What to verify
Failure propagation
- Example check
Reject contract violations and expose failures of the output device
Appendix C injects failures and checks the exact exception types. The minimal examples introduce the replacement relationship first; the extended implementation checks the conditions in this table individually.
Cases that resemble other patterns
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 |
- Similar concept
State pattern
- Example
Delegate cancellation and dispatch to the current order-state object
- Distinction from Strategy
Represent state-specific behavior and transitions, beyond a state value
- Similar concept
Template Method
- Example
Override a particular stage of an import operation
- Distinction from Strategy
Use the parent's execution skeleton and stable inheritance hooks
- Similar concept
Command
- Example
Queue an output request for later execution
- Distinction from Strategy
Store the request to execute rather than an alternative algorithm
- Similar concept
Adapter
- Example
Adapt an external library's API to the internal formatter contract
- Distinction from Strategy
Make calling conventions and data representations compatible
- Similar concept
Decorator
- Example
Wrap an existing formatter to add a heading
- Distinction from Strategy
Add functionality while retaining the existing behavior
- Similar concept
DI and composition
- Example
Supply a formatter to the component and delegate conversion to it
- Distinction from Strategy
Describe dependency assembly, with a different intent and scope from algorithm replacement
- Similar concept
Function composition and pipelines
- Example
Read, parse, validate, and save in sequence
- Distinction from Strategy
Execute several stages rather than choose one alternative
- Similar concept
Configuration and lookup tables
- Example
Keep the calculation while varying a rate or name
- Distinction from Strategy
Different values alone do not require more strategy classes
State in general and the State pattern
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 |
- Term
State value
- Meaning
Stored information such as the current failure count, order status, or edited content
- Term
State machine
- Meaning
A model of transitions and behavior determined by states and events
- Term
State pattern
- Meaning
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.7 (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.
Keep their roles distinct when combining them
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.
Applying several policies
Strategy fits choosing one alternative for a role.
If three discount rules must all run in sequence, their order and conflict rules need defining. If validation should collect every error, define when to stop and how to aggregate errors. Those rules belong in the code that combines the policies.
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.
Choosing a pattern from requirements
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.
Example
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 |
- Added requirement
Send the same delivery request by email or another channel
- Design choice
Consider Strategy if the channels can share a delivery contract
- Added requirement
Keep the channel and add elapsed-time measurement around delivery
- Design choice
Consider a Decorator around the existing delivery operation
- Added requirement
The external SDK's delivery function and response do not match the internal contract
- Design choice
Use an Adapter to translate the difference
- Added requirement
Store the request and send it at a scheduled time
- Design choice
Separate request data from the executor, or express it as a Command
- Added requirement
Display waiting, processing, and completed delivery states
- Design choice
Start with a state value; a status display alone does not call for the State pattern
- Added requirement
Cancellation, resumption, other behavior, and stored data differ substantially by state
- Design choice
Consider whether the State pattern helps group state-specific responsibilities
When these requirements coexist, an executor might take a scheduled request, select a channel strategy, and let that strategy use an adapter for the external SDK. Supporting storage and re-execution also requires rules for request serialization, permissions, duplicate execution, and failures in the actual job-execution contract.
Storing a request as a Command does not define cancellation by itself. Design the information and actions needed to undo it separately.
When no pattern is needed
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 contract can be justified even with one implementation in the repository if it is a library extension point for user-supplied algorithms or meets a confirmed deployment-specific replacement requirement. Creating only DefaultStrategy without such a need may add declarations and assembly code while leaving the behavior unchanged. Look at the actual use as well as the implementation count.
It helps to prioritize performance where it matters and readability in higher-level application code.
The GoF context
The publisher's record for Design Patterns gives October 31, 1994 as the publication date and 1995 as the copyright year.8 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.9 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.
Appendix A. Extended C# implementation with contracts and selection
This extends the minimal object example by removing the valid-input assumption and adding validation, output contracts, and format selection. It reuses the minimal example's type names, so do not combine the two versions in one file. Instead, collect the C# blocks from this appendix through Appendix C in order to form one program.
A branching implementation for comparison
The following method selects the format and builds its string in one place. After separating the strategy, Report(diagnostic, output) still records the diagnostic. Combining the C# blocks in Appendices A through C in one file also includes the final tests.
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);
}
}Input and output contracts
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 targets .NET 8 (net8.0) and C# 12 (LangVersion=12.0) with nullable analysis enabled (Nullable=enable) and no external packages. C# 12 matches the default language version for a .NET 8 project. The extended implementation has also been built and run with LangVersion=9.0.10 The verification record lists the verification date, reference commit, tool versions, and results.
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.
The single-line contract here means no actual CR or LF. Appendix E covers other Unicode separators, control characters, and the limits of whitespace validation.
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);
}Failure behavior is part of the contract. Invalid arguments use argument-related exceptions; a formatter's output-contract violation is reported as InvalidOperationException. An IOException from the output device reaches the caller instead of being hidden or converted into a success string. A buffered writer may fail later at Flush(), after the write call. Flushing and disposal belong to the writer's owner.11
Format-specific implementations
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
});
}
}By default, System.Text.Json emits compact JSON without indentation, with message line breaks escaped inside strings.12 schemaVersion and the field names are this example's own schema. Connecting to a particular compiler or LSP requires implementing the schema that consumer expects.
The default encoder also escapes non-ASCII characters and HTML-sensitive characters such as <, >, &, and '. The Hangul test string νκΈ therefore appears as \uD55C\uAE00 in the JSON text. With the .NET 8 defaults used here, a double quote inside a string also appears as \u0022. Parsing the JSON restores the original Hangul and quotes, so check the visible representation separately from preservation of the value.12 The tests below check both. This example keeps the default settings. Changing the encoder to display Hangul directly does not make every character pass through unchanged: block lists can override allowed ranges. UnsafeRelaxedJsonEscaping also relaxes protections needed when embedding text in HTML, so it should not be treated as a readability-only setting.
The human-readable format is for display: an original literal \n and an actual LF both appear as \n. Consumers that need to recover the message or read individual fields should use JSON. The current implementation does not separately reject malformed UTF-16, so unpaired surrogates may become replacement characters during serialization. Appendix E checks this limit on recovery.13
This call uses reflection-based serialization. It caches type metadata collected on first use and reuses it on later calls. Measure startup time, memory, and throughput before choosing source generation. A Native AOT deployment needs a reflection-independent serialization path and deployment settings, using named output types and facilities such as JsonSerializerContext.14
The reporter writes the formatted result
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's runtime checks only reject empty strings and CR/LF. Implementation tests and review check JSON schemas, format-specific meaning, and side effects. Concurrent use of TextWriter and atomic disk writes belong to the output boundary; a program with several tasks sharing an output needs a synchronization policy there.
Selection and assembly
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.
The argument parser can default to human when --format is omitted. Treating the invalid explicit value --format jsno as human would conceal an error in the user's request. Let the parser define omission and the selection boundary check whether a supplied name is supported.
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.
<a id="extended-functions"></a>
Appendix B. Extended function, LINQ, and TypeScript implementations
Passing Func values and method groups
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 pure function. In either case, ToArray() completes the calculation inside this call, so an exception while processing an element fails the call. Returning a deferred IEnumerable<string> instead moves that failure to the point of enumeration.
Building sorting strategies with LINQ
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. The C# compiler translates query syntax into the corresponding method calls, which in this case mean .OrderBy(...).ThenBy(...).15 Choose the clearer of the two notations for the same sorting strategy.
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.16 Capturing a mutable configuration object lets later calls observe its changed values, so decide whether settings should be fixed or read as they change.
Use OrderBy(...).ThenBy(...) to add ID order for equal prices. In OrderBy(...).OrderBy(...), the latter call establishes a new primary key.4 Enumerable.OrderBy uses deferred execution and gathers elements when sorting is required. The Sort above materializes both input and result with ToArray(), so it finishes the calculation inside the call and returns a sorted array.5
Enumerable.OrderBy is stable, so sorting by price alone preserves input order among equal-priced products. Comparing IDs as well defines the order by price and ID instead of depending on that input order. Items whose two keys both tie still retain their input order.5
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.
These examples use LINQ to Objects in memory. With EF Core's IQueryable<T>, a provider interprets expression trees as SQL or another execution form, so server-side filtering, sorting, and pagination need queries that provider can translate.15 Passing a database query to this Sort instead makes its internal ToArray() retrieve the entire input and sort it in memory.
Passing a comparison function in TypeScript
Readonly<T> and readonly T[] constrain type checking; they do not freeze objects or arrays at runtime.17
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.
The readonly Product[] syntax was introduced in TypeScript 3.4. The runtime needs ES2015's Number.isSafeInteger.1819 The verification record lists the actual package and Node.js versions and commands. Execution with the TypeScript 3.4 compiler and older browsers has not been verified. target affects emitted syntax and the default lib selection; lib defines the API types known to the compiler, so an unsupported declaration set can fail compilation. Conversely, adding ES2015 to lib can satisfy type checking without supplying the runtime API implementation. Older environments still need API support or a polyfill.18
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. The comparison function chooses cheaper or more expensive products first. The replaceable rule here is the ordering comparison; Java's Comparator is also an official API for supplying comparison rules to sorting and other methods.20
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.6
<a id="extended-tests"></a>
Appendix C. Executable tests for the extended implementation
Add this small test program to the same file as the C# code in Appendices A and B. It uses StringWriter rather than a file or terminal. The failing writer checks only that an I/O failure reaches the caller.
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");
}
}The tests append output.NewLine instead of hard-coding "\n" because StringWriter defaults to "\r\n" on Windows and "\n" on Linux and macOS, and the caller can change it.21 The supplemental tests set each value explicitly to check that the reporter follows the writer's setting.
Expect<TException> checks the exact exception type here. A test expecting ArgumentException does not accept ArgumentNullException. The supplemental tests deliberately pass a derived exception to the document's test helper to check this distinction. The JSON tests inspect field values after parsing and the default encoder's escapes, without turning the entire runtime-dependent block list into a separate wire contract.
Combining only the C# blocks in Appendices A through C runs the 38 checks in Program.Main(). Use node WIKI/assets/strategy-pattern/verify.mjs --document-only to reproduce that path in the source repository. Running the example verifier with node WIKI/assets/strategy-pattern/verify.mjs from the repository root also checks Appendix E, supplemental cases, C# 9 compatibility, the standalone minimal examples, and TypeScript. The text blocks are call-flow diagrams rather than executable code; the verifier also checks code fences, languages, and footnote anchors. The verification record distinguishes the scope and results of each execution mode.
You need a .NET SDK and runtime capable of building and running .NET 8, Node.js, and the TypeScript CLI. The supplemental C# tests use a primary constructor, so their default verification stays on C# 12; a separate project checks the extended example's C# 9 syntax compatibility. The script defaults to %APPDATA%/npm/node_modules/typescript/bin/tsc rather than searching every global npm installation location. If you changed the npm prefix or use another operating system, pass your TypeScript package's bin/tsc path after the script. The TypeScript checks cover sorting, preservation of the input array, rejection of invalid prices and IDs, and the distinction between lib declarations and runtime API implementations.
Appendix D. State, lifetime, and output boundaries
How much input to pass
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.
Do not force synchronous and asynchronous work into one shape
This formatter only builds strings. Putting a remote translation service behind the same interface and blocking internally changes the expectation that Format() is a local calculation. If remote work really belongs in the role, the common contract must address asynchronous results, cancellation, time limits, and remote failures. Translating first and passing the result to the existing formatter may fit better.
The same distinction applies to a retry-delay strategy. attempt -> delay calculates the wait from the attempt count. The retry-execution boundary decides which failures may be retried, whether repeating a write is safe, and the attempt and time budgets.
Consider state and lifetime together
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.
Keep output formats and protocols distinct
The JSON formatter converts a diagnostic into a string. The protocol layer handles LSP message structures, position conventions, and transport framing. For example, LSP 3.17's base protocol requires a Content-Length header before the JSON-RPC content, so an LSP connection must send messages under that transport contract.22 Giving connection and protocol management to a strategy that replaces one string-output step calls for checking whether it still has a common role.
<a id="unicode-boundary-example"></a>
Appendix E. Unicode and the single-line boundary
In this defensive implementation, "single-line" means no actual CR or LF. The human-readable formatter preserves NEL (U+0085), line separator (U+2028), and paragraph separator (U+2029). A display that treats those as line breaks needs broader validation. Sanitizing terminal control characters generally is also a separate output-boundary responsibility.
IsNullOrWhiteSpace rejects a message consisting only of these separators or tabs, but preserves them among other content. It does not trim leading or trailing spaces. The current validation also allows NUL; consumers must define the complete permitted character sets for file names and codes.
Input validation rejects whitespace-only values, while the output-shape check uses IsNullOrEmpty and accepts one space. Whether that is a meaningful diagnostic is a separate question.
The current constructor also stores unpaired surrogates, as in "Error \uD800". The .NET 8 default JSON path replaces the unpaired part with U+FFFD, so recovering the original assumes valid UTF-16 input. The tests below check replacement of unpaired high and low surrogates and preservation of a valid pair.13
The verifier generates ExtendedProgram.Main() and calls UnicodeBoundaryExamples.Run() after Program.Main(). If you collect the code from Appendices A through C and the block below yourself, add UnicodeBoundaryExamples.Run(); inside Program.Main(). Without that call, only Appendix C's 38 checks run.
public static class UnicodeBoundaryExamples
{
public static void Run()
{
var checks = 0;
// These characters alone fail the whitespace-only input check.
foreach (var separator in new[] { "\u0085", "\u2028", "\u2029" })
{
var rejected = false;
try
{
_ = new Diagnostic("main.pgy", 1, "E001", separator);
}
catch (ArgumentException error) when (error.GetType() == typeof(ArgumentException))
{
rejected = true;
}
if (!rejected)
throw new InvalidOperationException("Whitespace-only messages must be rejected.");
checks++;
}
// The human-readable formatter preserves these separators among other content.
var message = "Before\u0085Middle\u2028Next\u2029After";
var diagnostic = new Diagnostic("main.pgy", 1, "E001", message);
var humanText = new HumanDiagnosticFormatter().Format(diagnostic);
foreach (var separator in new[] { '\u0085', '\u2028', '\u2029' })
{
if (!humanText.Contains(separator))
throw new InvalidOperationException("Separator preservation changed.");
checks++;
}
if (humanText.Contains('\r') || humanText.Contains('\n'))
throw new InvalidOperationException("This example's single-line contract prohibits CR/LF.");
checks++;
using (var parsed = JsonDocument.Parse(new JsonDiagnosticFormatter().Format(diagnostic)))
{
if (parsed.RootElement.GetProperty("message").GetString() != message)
throw new InvalidOperationException("The JSON message did not round-trip.");
checks++;
}
// Check that the constructor's current validation does not reject NUL.
var withNul = new Diagnostic("main\0.pgy", 1, "E001", "Error");
if (!withNul.File.Contains('\0'))
throw new InvalidOperationException("The NUL boundary changed.");
checks++;
// A single space passes the shape check without proving the diagnostic is meaningful.
using var output = new StringWriter(CultureInfo.InvariantCulture);
new DiagnosticReporter(new WhitespaceFormatter()).Report(diagnostic, output);
if (output.ToString() != " " + output.NewLine)
throw new InvalidOperationException("The whitespace-output shape check changed.");
checks++;
// Malformed UTF-16 accepted by the constructor can change during JSON serialization.
foreach (var example in new[]
{
(Input: "Error \uD800", Restored: "Error \uFFFD"),
(Input: "Error \uDC00", Restored: "Error \uFFFD"),
(Input: "Error \uD83D\uDE00", Restored: "Error \uD83D\uDE00")
})
{
var item = new Diagnostic("main.pgy", 1, "E001", example.Input);
if (item.Message != example.Input)
throw new InvalidOperationException("The constructor retains the original UTF-16 value.");
checks++;
using var parsed = JsonDocument.Parse(new JsonDiagnosticFormatter().Format(item));
if (parsed.RootElement.GetProperty("message").GetString() != example.Restored)
throw new InvalidOperationException("The UTF-16 serialization boundary changed.");
checks++;
}
Console.WriteLine($"C# Unicode boundary checks: {checks}");
}
private sealed class WhitespaceFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) => " ";
}
}Footnotes
- Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, published 1994, copyright 1995, ISBN 978-0-201-63361-0, Strategy chapter. Excerpt in Duke University course materials. Covers the algorithm family, roles, Policy alias, and compile-time template implementation. The publisher's record is in [8]. β©
- Martin Fowler. Inversion of Control Containers and the Dependency Injection pattern, 2004. Separation of assembly from use, and constructor injection. β©
- Microsoft Learn. Using delegates. Passing methods as values and invoking them without knowing the concrete implementation in C#. β©
- Microsoft Learn. Enumerable.ThenBy. Secondary sort keys and the distinction from a new
OrderBycall. β© - Microsoft Learn. Enumerable.OrderBy. Key selectors, deferred execution, and sorting behavior. β©
- MDN. Array.prototype.sort. In-place array mutation and the conditions a comparison function must satisfy. β©
- Robert Nystrom. Game Programming Patterns, State, author's source. Distinguishes state machines,
enum + switch, and the State pattern's delegation to state-specific objects. β© - Addison-Wesley / InformIT publisher record. Publication date and copyright year for Design Patterns. β©
- Erich Gamma, Richard Helm, Ralph Johnson, Larry O'Brien. Design Patterns 15 Years Later, 2009. An interview about language features and where patterns apply. β©
- Microsoft Learn. C# language versioning. The relationship between the target framework and the default language version. β©
- Microsoft Learn. StreamWriter.Flush (.NET 8). Writing buffers to the underlying stream and I/O exceptions. β©
- Microsoft Learn. How to serialize JSON in C#, How to customize character encoding with System.Text.Json. Default output, escaping non-ASCII and HTML-sensitive characters, allow lists and block lists, and the security boundary of the relaxed encoder. β©
- .NET Runtime, v8.0.0. JavaScriptEncoderTests.cs. Tests replacement characters for malformed surrogates and preservation of valid pairs. Appendix E separately executes this article's default JsonSerializer path. β©
- Microsoft Learn. Reflection versus source generation in System.Text.Json. Metadata caching, source generation, and Native AOT. β©
- Microsoft Learn. C# specification Β§12.23.3 Query expression translation, Standard query operators overview. The first covers translation to method calls and removal of a final identity
select; the second covers execution differences betweenIEnumerable<T>and expression-tree-basedIQueryable<T>. β© - Microsoft Learn. Lambda expressions. Lambdas and capture of outer variables. β©
- TypeScript Handbook. Object Types: readonly Properties. The scope of
readonlytype checking and its distinction from runtime behavior. β© - TypeScript. TypeScript 3.4 release notes, TSConfig target and lib. Introduction of
readonly T[]and the distinction between emitted syntax and API type declarations. β© - Ecma International. ECMA-262, sixth edition, ECMAScript 2015 specification, Β§20.1.2.5. Definition of
Number.isSafeInteger. The official final HTML edition also links to the approved PDF. β© - Oracle. Comparator, Java SE 8 API. Comparison rules supplied to sorting and the comparison contract. β©
- Microsoft Learn. TextWriter.NewLine. Platform defaults and per-writer newline settings. β©
- Microsoft. Language Server Protocol 3.17 specification source. JSON-RPC messages and the base transport contract using
Content-Length. β©