Strategy, adapter, decorator, middleware, observer, builder and state machines in C#, Java, Go and Rust, with the patterns each language feature deleted, and a measured look at what changes when you add a case.
Design patterns get taught as a catalogue to memorise and used as a badge to collect. Both are wrong. A pattern is a name for a shape that keeps appearing, and the useful question is never “which pattern is this?” but “what varies here, and who is allowed to change it?”
This part takes the handful of patterns that survive in every language we use, shows each one in C#, Java, Go and Rust, and is honest about the ones that have quietly disappeared into language features. We measure two things: the order a middleware chain really runs in, in all four languages, and what has to change when a new case arrives.
Try this first
A shop gives discounts by customer kind, written as a switch:
func Discount(o Order) int64 {
switch o.Kind {
case "staff":
return o.AmountCents / 4
case "loyalty":
return o.AmountCents / 10
default:
return 0
}
}
Marketing asks for a student discount. Which files change, and which packages does the compiler rebuild? Now answer the same two questions for a design where each discount is its own type behind an interface. Write both answers down.
What a pattern is, according to the people who named them
Erich Gamma, in an interview recorded in 2004, reduced the first GoF principle to dependency management: “This principle is really about dependency relationships which have to be carefully managed in a large app. It’s easy to add a dependency on a class. It’s almost too easy; just add an import statement.” The payoff: “Once you depend on interfaces only, you’re decoupled from the implementation. That means the implementation can vary, and that’s a healthy dependency relationship.”
The part that rarely gets quoted comes next: “One question is whether you should always use a Java interfaces for that. An abstract class is good as well.” And on when to introduce the interface: “You can distill an interface from a concrete class once you have the full insights into a problem. The intended interface is just one ‘extract interface’ refactoring away.” You don’t need an IFoo on day one.
The second principle, composition over inheritance, he calls “black box reuse”: “You have a container, and you plug in some smaller objects. These smaller objects configure the container and customize the behavior of the container […] In the end you get customization by configuration.” And its prototype is one pattern: “let me just point you to the Strategy pattern. It is my prototypical example for the flexibility of composition over inheritance.”
He’s equally clear about the failure mode. Gamma tells a story about someone who “tried to use all 23 GoF patterns” and “failed, because they were only able to use 20”. His verdict: “Trying to use all the patterns is a bad thing, because you will end up with synthetic designs—speculative designs that have flexibility that no one needs.” And the confession most teaching leaves out: “in the book we only tell when to apply a pattern, but we never talk about when to remove a pattern. Removing a pattern can simplify a system and a simple solution should almost always win.”
In 2005 three of the four authors sat down to re-cut the catalogue. Their notes, published in 2009, propose new members (“Null Object, Type Object, Dependency Injection, and Extension Object/Interface”) and a new grouping, and Gamma adds: “I’m in favor of dropping Singleton. Its use is almost always a design smell.” (Count their proposed categories and you get 21 entries, not 23: Adapter, Bridge, Chain of Responsibility, Observer, Memento and Singleton are absent, and four new ones are in. They call the notes “just notes in a draft state”, so treat them that way.)
Patterns a language feature deleted
Peter Norvig’s 1996 talk is the most-cited and most-mangled claim in this area. What slide 9 actually says: “16 of 23 patterns have qualitatively simpler implementation in Lisp or Dylan than in C++ for at least some uses of each pattern“. Slide 10 breaks the 16 down by which feature does the work:
| Feature | Patterns it absorbs |
|---|---|
| First-class types | Abstract Factory, Flyweight, Factory Method, State, Proxy, Chain of Responsibility |
| First-class functions | Command, Strategy, Template Method, Visitor |
| Macros | Interpreter, Iterator |
| Method combination | Mediator, Observer |
| Multimethods | Builder |
| Modules | Facade |
So “closures killed the patterns” accounts for four of the sixteen. The rest need other features.
Paul Graham’s version is more famous and more hedged than its reputation: “I wonder if these patterns are not sometimes evidence of case (c), the human compiler, at work. When I see patterns in my programs, I consider it a sign of trouble.” Note the “sometimes” and “I wonder”: the claim is about repeatedly hand-expanding the same boilerplate, not about design vocabulary.
The strongest version of the argument comes from a GoF author. Gamma, on his own framework: “JUnit 3 was a small framework that used several patterns like Composite, Template Method and Command. JUnit 4 leverages the Annotations meta-programming facilities introduced in J2SE 5.0. The use of the patterns disappeared and the framework evolved into a small set of annotations plus a test runner infrastructure.”
You can watch it happen in the ecosystems right now:
- .NET 11 deletes a factory.
System.Text.Jsonhas two ways to write a converter, and the documented reason for the second is generics: “The factory pattern is for converters that handle type Enum or open generics.” Then: “Starting in .NET 11, you can also use open generic converters directly with[JsonConverter]on a generic type, without the factory pattern.” - The JDK deprecated its own Observer.
java.util.Observablecarries@Deprecated(since="9")and explains why: “The event model supported by Observer and Observable is quite limited, the order of notifications delivered by Observable is unspecified, and state changes are not in one-for-one correspondence with notifications.” - Pattern matching replaced Visitor in Java. Brian Goetz: “Before we had records and pattern matching, the standard approach to writing code like this was the visitor pattern. Pattern matching is clearly more concise than visitors, but it is also more flexible and powerful. Visitors require the domain to be built for visitation.”
- Go’s own Strategy shrank. Sorting needed a three-method
sort.Interfacewith a named type;sort.Slice(Go 1.8) and nowslices.SortFunctake a closure instead.
Strategy: the one that’s everywhere
Strategy is “make the varying part a parameter”. Once a language has first-class functions, the class disappears and the pattern stays.
C#: a delegate, or an interface
Order[] orders =
[
new Order("staff", 10_000),
new Order("loyalty", 10_000),
new Order("student", 10_000),
new Order("none", 10_000),
];
// The strategy as a delegate: no interface, no class.
Func<Order, long> discount = order => order.Kind switch
{
"staff" => order.AmountCents / 4,
"loyalty" => order.AmountCents / 10,
_ => 0,
};
// The strategy as a composed set of rules, each one a small object.
IRule[] rules = [new StaffRule(), new LoyaltyRule(), new StudentRule()];
long Apply(Order order) => rules.FirstOrDefault(r => r.Applies(order))?.Discount(order) ?? 0;
foreach (var order in orders)
{
Console.WriteLine($"{order.Kind}: delegate {discount(order)}, rules {Apply(order)}");
}
public record Order(string Kind, long AmountCents);
public interface IRule
{
bool Applies(Order order);
long Discount(Order order);
}
public sealed class StaffRule : IRule
{
public bool Applies(Order order) => order.Kind == "staff";
public long Discount(Order order) => order.AmountCents / 4;
}
public sealed class LoyaltyRule : IRule
{
public bool Applies(Order order) => order.Kind == "loyalty";
public long Discount(Order order) => order.AmountCents / 10;
}
public sealed class StudentRule : IRule
{
public bool Applies(Order order) => order.Kind == "student";
public long Discount(Order order) => order.AmountCents / 20;
}
It prints:
staff: delegate 2500, rules 2500
loyalty: delegate 1000, rules 1000
student: delegate 0, rules 500
none: delegate 0, rules 0
Two designs, side by side, and the difference is visible in the output: the delegate version doesn’t know about students, because adding one means editing that function. The rules version knows, because adding one meant adding a class and listing it.
Microsoft’s guidance on why a delegate is often enough: “Delegates provide a mechanism that enables software designs involving minimal coupling between components. […] You don’t need to create a class that derives from a particular base class. You don’t need to implement a specific interface. The only requirement is to provide the implementation of one method that is fundamental to the task at hand.” Reach for an interface when the type has more than that one job.
Java: a functional interface, and combinators
Java’s version of Strategy is a functional interface, which the specification defines as “an interface that is not declared sealed and has just one abstract method (aside from the methods of Object)”. The java.util.function package gives shared names for the common shapes, so most strategies need no interface of your own.
The JDK’s best example is Comparator, which is Strategy that grew combinators:
import java.util.Comparator;
import java.util.List;
record Order(String kind, long amountCents) {}
void main() {
List<Order> orders = new java.util.ArrayList<>(List.of(
new Order("loyalty", 10_000), new Order("staff", 25_000),
new Order("staff", 10_000), new Order("student", 5_000)));
Comparator<Order> byKind = Comparator.comparing(Order::kind);
Comparator<Order> byAmountDescending = Comparator.comparingLong(Order::amountCents).reversed();
orders.sort(byKind.thenComparing(byAmountDescending));
orders.forEach(o -> IO.println(o.kind() + " " + o.amountCents()));
}
It prints:
loyalty 10000
staff 25000
staff 10000
student 5000
comparing, reversed and thenComparing are the pattern turning into a small language for ordering. The javadoc’s warning is worth keeping: an ordering is “consistent with equals if and only if c.compare(e1, e2)==0 has the same boolean value as e1.equals(e2)“, and sorted collections misbehave when it isn’t.
Go: a func type, or an interface
Go does both without ceremony. A function type satisfies an interface if you give it a method, which is exactly what the standard library does for HTTP handlers:
package main
import (
"fmt"
"slices"
"strings"
)
type Order struct {
Kind string
AmountCents int64
}
// The strategy as a function type, so a plain func can be passed.
type Rule func(Order) (int64, bool)
func staff(o Order) (int64, bool) { return o.AmountCents / 4, o.Kind == "staff" }
func loyalty(o Order) (int64, bool) { return o.AmountCents / 10, o.Kind == "loyalty" }
func discount(rules []Rule, o Order) int64 {
for _, rule := range rules {
if amount, applies := rule(o); applies {
return amount
}
}
return 0
}
func main() {
rules := []Rule{staff, loyalty}
orders := []Order{{"staff", 10000}, {"loyalty", 10000}, {"none", 10000}}
for _, o := range orders {
fmt.Printf("%s: %d\n", o.Kind, discount(rules, o))
}
// Sorting used to need a three-method sort.Interface; now the strategy is a closure.
slices.SortFunc(orders, func(a, b Order) int { return strings.Compare(a.Kind, b.Kind) })
fmt.Println(orders)
}
It prints:
staff: 2500
loyalty: 1000
none: 0
[{loyalty 10000} {none 10000} {staff 10000}]
Rust: a trait, or a closure, and the closure has a type
struct Order {
kind: &'static str,
amount_cents: i64,
}
// A trait when the strategy has a name and maybe state.
trait Rule {
fn discount(&self, order: &Order) -> Option<i64>;
}
struct Staff;
impl Rule for Staff {
fn discount(&self, order: &Order) -> Option<i64> {
(order.kind == "staff").then_some(order.amount_cents / 4)
}
}
// A closure when it doesn't: Fn is the trait a closure implements.
fn discount_with(rule: impl Fn(&Order) -> Option<i64>, order: &Order) -> i64 {
rule(order).unwrap_or(0)
}
fn main() {
let orders = [
Order {
kind: "staff",
amount_cents: 10_000,
},
Order {
kind: "loyalty",
amount_cents: 10_000,
},
];
let rules: Vec<Box<dyn Rule>> = vec![Box::new(Staff)];
for order in &orders {
let from_trait = rules.iter().find_map(|r| r.discount(order)).unwrap_or(0);
let from_closure = discount_with(
|o: &Order| (o.kind == "loyalty").then_some(o.amount_cents / 10),
order,
);
println!("{}: trait {from_trait}, closure {from_closure}", order.kind);
}
}
It prints:
staff: trait 2500, closure 0
loyalty: trait 0, closure 1000
Rust adds something the other three don’t have: the strategy’s capture is part of its type. Fn is for closures that “can be called repeatedly without mutating state”, FnMut for those that mutate what they captured, and FnOnce for those that consume it. A strategy that owns a database connection and one that borrows a counter aren’t interchangeable, and the compiler says so.
Adapter: the pattern the standard libraries name out loud
Adapter makes one interface look like another. Go’s standard library uses the word in its own documentation:
“The HandlerFunc type is an adapter to allow the use of ordinary functions as HTTP handlers. If f is a function with the appropriate signature, HandlerFunc(f) is a Handler that calls f.”
That’s a function type with a method on it, and it’s why Go web code never needs a Handler class. Rust’s standard library uses the word too, for iterators: “Functions which take an Iterator and return another Iterator are often called ‘iterator adapters’, as they’re a form of the ‘adapter pattern’.” Rust’s other adapter convention is conversion: implement From, and you get Into free, because “One should always prefer implementing From over Into […] thanks to the blanket implementation in the standard library.”
Decorator and middleware: the shape every web framework has
A decorator wraps something and keeps its interface. Chain a few and you have middleware, which every one of our ecosystems has, under a different name:
| Ecosystem | The shape | What it’s called |
|---|---|---|
| ASP.NET Core | Func<HttpContext, RequestDelegate, Task> |
middleware |
| Go | func(http.Handler) http.Handler |
handler wrappers |
| Java | Filter.doFilter(request, response, chain) |
servlet filters |
| Rust (tower) | Layer::layer(inner) -> Service |
layers |
Only tower’s documentation names the pattern: Layer “Decorates a Service, transforming either the request or the response”.
ASP.NET Core documents the ordering rule precisely: “The order that middleware appears in the app’s Program file defines the order in which middleware are invoked on a request with the reverse order for the response.” A middleware that doesn’t call the next one “short-circuits” the pipeline.
Our lab runs the same chain, logging then recover then auth then the handler, in all four languages. The Go one uses real net/http handlers; the other three hand-roll the same shape that ASP.NET Core, servlet filters and tower use, so that nothing depends on a framework:
The lines are the Go program’s real output from checks/part12_patterns/run.py, which runs the same chain in Go, C#, Java and Rust. ASP.NET Core documents the rule: “The order that middleware appears in the app’s Program file defines the order in which middleware are invoked on a request with the reverse order for the response”.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("logging: before")
next.ServeHTTP(w, r)
fmt.Println("logging: after")
})
}
Three cases, and the lab’s results:
| Case | What Go printed | Other languages |
|---|---|---|
| Everything works | logging: before; auth: before; handler: running; auth: after; logging: after; status: 200 | the same in all four |
| Auth rejects the request | logging: before; auth: before; auth: rejecting, the handler never runs; logging: after; status: 401 | the same in all four |
| The handler fails | logging: before; auth: before; handler: running; recover: caught handler failed; logging: after; status: 500 | Rust prints auth: after as well |
Two things worth reading twice. First, the “before” lines come out in the order the middleware were added and the “after” lines in reverse, in every language: that’s the shape, not a framework feature. Second, Rust printed one extra line. When the handler failed, Go, C# and Java unwound the stack past auth, so its code after the call never ran. Our Rust chain returns an Err value instead, so auth carried on and printed auth: after before the recovering layer turned the error into a 500. That’s the design, not the language: a panic! in Rust unwinds past auth exactly as the others do. Middleware that must run on the way out, such as releasing a lock or finishing a span, needs defer, finally or Drop, unless failures are values all the way through (Part 10).
The ordering rule has teeth in production. Microsoft’s recommended order, abridged here (exception handler, then HSTS, HTTPS redirection, static files, routing, authentication, authorization, session, endpoints), is a security ordering: “the order of middleware can be critical for security, performance, and functionality”. Put authorization after your endpoint and it never runs.
Observer: where the JDK changed its mind
GoF Observer has one hole: the observer can’t say “slower”. Java’s answer, java.util.concurrent.Flow, is the Reactive Streams interfaces, and the javadoc names the fix: “Communication relies on a simple form of flow control (method Flow.Subscription.request(long)) that can be used to avoid resource management problems that may otherwise occur in ‘push’ based systems.”
Our lab subscribes, asks for two items, and then publishes five:
subscriber: request(2)
publisher: submitted 1
subscriber: received 1
publisher: submitted 2
subscriber: received 2
publisher: submitted 3
publisher: submitted 4
publisher: submitted 5
nothing more arrives until the subscriber requests again
Items 3, 4 and 5 sit in the publisher’s buffer. When that buffer fills, the publisher has to choose: the javadoc says “Method submit blocks until resources are available”, while “The offer methods may drop items”. That’s backpressure, and it’s the difference between a design that survives a slow consumer and one that fills memory.
In .NET, the same interfaces exist as IObservable<T> and IObserver<T>, and Microsoft’s docs now list four things to consider before implementing them: events “for simple notification scenarios”, IAsyncEnumerable<T> “for async pull-based sequences where the consumer controls the pace”, System.Threading.Channels “for producer-consumer patterns with backpressure”, and Rx.NET “for complex event composition”. The observation that makes it all click is from Microsoft’s archived Rx documentation: IObservable<T> is “a dual of the familiar IEnumerable<T> interface”. Push and pull are the same shape with the arrows reversed.
Builder: the same pattern, checked three ways
Builder exists because some objects need many inputs, some optional. What differs is when the rules are checked.
- Java, at run time.
Stream.Builder“has a lifecycle, which starts in a building phase, during which elements can be added, and then transitions to a built phase, after which elements may not be added”. Add after building and you get an exception.HttpClient.Builderis the ordinary kind: “Each of the setter methods modifies the state of the builder and returns the same instance”, and it’s “not thread-safe”. - Rust, at compile time, if you want it. The Rust API Guidelines describe both flavours and pick a side: “Non-consuming builders (preferred)”, because
Command::new("/bin/cat").arg("file.txt").spawn()then works both as a one-liner and in a loop. A consuming builder (selftoSelf) can enforce the lifecycle in the type system, which is typestate from Part 9. - Go, as functions. Rob Pike’s 2014 post introduced options as functions that configure the value: “First, we define an option type. It is a function that takes one argument, the Foo we are operating on.” Dave Cheney’s talk later that year popularised the simplified version and credited it: “the idea of functional options comes from a blog post titled[sic] Self referential functions and design by Rob Pike”. The part almost nobody copies is Pike’s original twist, where an option returns the previous value so it can be restored.
- .NET, as a host builder.
HostApplicationBuilder“helps manage configuration, logging, lifetime”, and configuration lands in typed classes through “the options pattern”, which “uses classes to provide strongly typed access to groups of related settings”. Microsoft’s name for it, not GoF’s.
package main
import "fmt"
type Server struct {
addr string
timeout int
tls bool
}
// An option is a function that configures the value.
type Option func(*Server)
func WithTimeout(seconds int) Option { return func(s *Server) { s.timeout = seconds } }
func WithTLS() Option { return func(s *Server) { s.tls = true } }
func New(addr string, options ...Option) *Server {
s := &Server{addr: addr, timeout: 30}
for _, option := range options {
option(s)
}
return s
}
func main() {
fmt.Printf("%+v\n", *New("localhost:8080"))
fmt.Printf("%+v\n", *New("localhost:8443", WithTimeout(5), WithTLS()))
}
It prints:
{addr:localhost:8080 timeout:30 tls:false}
{addr:localhost:8443 timeout:5 tls:true}
State machines: the pattern Rust’s own book dismantles
The GoF State pattern gives each state a class and lets the states decide the transitions. The Rust book implements it faithfully, then takes it apart: “because the states implement the transitions between states, some of the states are coupled to each other. If we add another state between PendingReview and Published, such as Scheduled, we would have to change the code in PendingReview”. Its verdict: “By implementing the state pattern exactly as it’s defined for object-oriented languages, we’re not taking as full advantage of Rust’s strengths as we could.”
The rewrite makes each state a type, and the book is honest about both sides: “invalid states are now impossible because of the type system”, but “The transformations between the states are no longer encapsulated entirely within the Post implementation.” That’s Part 9’s typestate, with its trade-off stated by the people who teach it.
In C# and Java, the modern version is a sealed hierarchy plus exhaustive matching, which JEP 441 exists to make safe: “Increase the safety of switch statements by requiring that pattern switch statements cover all possible input values.”
One caution before you call a sealed enum a state machine. David Harel’s 1987 paper introduced statecharts precisely because flat state machines don’t scale: his additions are hierarchy (states inside states), orthogonality (regions running at once) and broadcast communication. An exhaustive match gives you none of those. For workflows with parallel regions, timers and history, use a statechart library or a workflow engine, and keep the sealed type for the part that’s genuinely one machine.
Measured: what changes when you add a case
Measured by checks/part12_patterns/run.py with go list and go build -x on two small Go modules. Red means Go considers the package stale and rebuilds it.
Our lab has two small Go modules that price an order. One has the switch from the top of this part inside the pricing package. The other defines a Rule interface in pricing, puts the rules in their own package, and wires them together in main. We added a student discount to each and asked the Go tool what became stale:
| Design | Files changed | Packages Go marked stale |
|---|---|---|
A switch inside pricing |
pricing/pricing.go |
cmd/app, pricing |
A Rule interface, rules in their own package |
rules/rules.go, cmd/app/main.go |
cmd/app, rules |
The Strategy version does what Open-Closed promises: the pricing logic itself never changed. What it cost is a second package and an extra edit at the wiring point, and, unlike a sealed type (Part 9), nothing tells you that a case is missing: a customer kind with no rule silently gets no discount.
The switch version costs one edit in one file, and the package everything depends on is the one that changes. Both designs leave two packages stale, so Strategy’s win is which package, not how many. And with a sealed type and an exhaustive match, forgetting a case would be a compile error in Java and Rust, or warning CS8509 in C#.
That’s the whole trade, and it’s why “always use Strategy” is bad advice. Use it when the set of cases is open, or when the cases come from elsewhere, such as plugins, configuration or another team. Use a switch, ideally over a closed type, when the set is yours and small.
Across languages
| Pattern | C# | Java | Go | Rust |
|---|---|---|---|---|
| Strategy | delegate (Func<T>), or an interface |
functional interface, java.util.function |
func type, or a small interface | closure (Fn), or a trait |
| Adapter | wrapper class, extension methods | wrapper class (Collections.list, Arrays.asList) |
http.HandlerFunc, func types with methods |
From/Into, iterator adapters |
| Decorator | wrapping class, Stream wrappers |
wrapping class, InputStream wrappers |
wrapping funcs, io.TeeReader |
wrapping struct, iterator adapters |
| Middleware | Use/Run pipeline |
servlet Filter chain |
func(http.Handler) http.Handler |
tower Layer |
| Observer | IObservable<T>, events, Channels |
Flow (request(n)), deprecated Observer |
channels (one receiver each) | channels, broadcast channels |
| Command | delegate, Action |
lambda, Runnable |
func value | closure, Box<dyn Fn> |
| Builder | object initializers, HostApplicationBuilder |
HttpClient.Builder |
functional options | non-consuming builder (API guidelines) |
| State | sealed records plus switch |
sealed interface plus switch |
interface plus type switch | enum plus match, or typestate |
| Template Method | virtual hooks, BackgroundService.ExecuteAsync |
abstract methods | pass a func | trait with default methods |
| Visitor | pattern matching | pattern matching (JEP 441) | type switch | match |
Trade-offs
- Indirection isn’t free. Every plug-in point is a level a reader has to follow. Gamma again: “when you don’t need it, you should keep your design simple and not add unnecessary levels of indirection.”
- Open sets versus checked sets. A Strategy registry accepts new cases without touching existing code, and gives you no compiler help when one is missing. A closed type with exhaustive matching is the reverse (Part 8’s expression problem).
- A closure is lighter than a class until it needs a name. Anonymous strategies are quick to write and hard to test, log and reuse. When one grows state, a name and a type pay for themselves.
- Patterns as vocabulary beat patterns as code. “Decorator” in a review comment communicates. A
DiscountStrategyFactoryImplcommunicates nothing. - Frameworks already own some patterns. Middleware ordering, DI lifetimes and options binding are the framework’s shape. Fighting it with your own abstraction layer costs more than it saves.
- Patterns can lose, sometimes. Prechelt and colleagues ran controlled experiments and found a program harder to maintain with Observer where it looked justified, and easier with Decorator even when its flexibility was superfluous. Their own bottom line leans the other way, though: “if in doubt, using the pattern rather than the simpler solution appears to be a good default approach”.
Common mistakes
- Adding an interface with one implementation “for testing”. If nothing else implements it and your tests don’t need it, it’s a rename with extra files. Extract it when the second implementation or the awkward test actually arrives.
- Singleton as a global variable with a nicer name. It hides dependencies and makes tests share state. Use the DI container’s lifetime, or pass the thing.
- A factory that only calls
new. If there’s no choice to make, it’s ceremony. - Deep decorator stacks. Five wrappers around one call means a stack trace nobody can read. Two or three, named after what they do, is usually the limit.
- Middleware in the wrong order. Authorization after routing to the endpoint, exception handling registered last, compression before the response is written. Follow the framework’s documented order.
- Pattern names in class names.
OrderStrategysays how;DiscountRulesays what. The second survives the refactor that removes the pattern. - Treating a sealed enum as a statechart. No hierarchy, no parallel regions, no history. Real workflows outgrow it.
- Keeping a pattern after the language grew a feature. A
Commandclass per action, a hand-written Visitor, a converter factory for generics on .NET 11. Delete them.
Interview questions
Try to answer each one before opening the model answer.
1. What problem does the Strategy pattern solve, and how does it look in a language with closures?
Show a strong answer
- Problem: one algorithm varies while its surroundings stay the same, and the choice may change at run time. Gamma calls Strategy his “prototypical example for the flexibility of composition over inheritance”.
- With closures: the interface and the class vanish. C# takes a
Func<Order, long>, Java a functional interface, Go a func type, Rust animpl Fn. - When the class is still better: the strategy has state, a name worth testing, several methods, or must be discovered and registered.
- Rust’s extra:
Fn,FnMutandFnOnceencode how the strategy captures its environment, so ownership is part of its type.
Likely follow-up: “How do you pick which strategy at run time?” A map from a key to the strategy, or a list of rules asked in order. Both make the set open, which is the point and the cost.
2. Middleware runs in which order, and what happens when one short-circuits?
Show a strong answer
- Order: registration order on the way in, reverse on the way out. ASP.NET Core documents exactly that; Go, Java filters and tower behave the same way.
- Short-circuit: a middleware that doesn’t call the next one ends the request there. The layers outside it still run their “after” code. ASP.NET Core calls that terminal middleware.
- Ordering is a security property: authentication and authorization before the endpoint, exception handling outermost, static files early.
- On failure: in C#, Java and Go an exception or panic skips the rest of the enclosing middleware’s code unless it uses
finally/defer; in Rust an error is a return value, so the code after the call still runs.
Likely follow-up: “Where would you put a request-ID and timing middleware?” Outermost, so it covers everything, including the exception handler’s response.
3. Which patterns has your language made unnecessary?
Show a strong answer
- Command, Strategy, Template Method: replaced by first-class functions in all four languages.
- Visitor: replaced by pattern matching over sealed types in C# and Java,
matchin Rust, a type switch in Go. Goetz: “Visitors require the domain to be built for visitation.” - Iterator: built into the language (
foreach,for ... range,Iterator). - Singleton: replaced by the DI container’s lifetime; Gamma would drop it from the catalogue.
- A concrete recent one: .NET 11 lets an open generic converter be used directly, “without the factory pattern”.
- Nuance: Norvig’s “16 of 23” is hedged with “for at least some uses of each pattern”, and only four of the sixteen are down to first-class functions.
Likely follow-up: “So are patterns obsolete?” No: the vocabulary still communicates, and the shapes still exist. What changes is how much code each one costs.
4. When would you not use a pattern?
Show a strong answer
- When nothing varies yet. Gamma: patterns used everywhere produce “speculative designs that have flexibility that no one needs”.
- When the framework owns that shape, such as DI lifetimes or the middleware pipeline.
- When a closed set with exhaustive matching serves better, because you want the compiler to object when a case is added: an error in Java and Rust, warning CS8509 in C#, and nothing in Go without a linter.
- When removing one simplifies the code: “Removing a pattern can simplify a system and a simple solution should almost always win.”
- Evidence: Prechelt’s experiments found a pattern can make maintenance slower where it looks justified.
Likely follow-up: “How do you decide in review?” Ask what varies, who adds cases, and whether the indirection has earned its keep yet.
5. How do Observer and backpressure relate?
Show a strong answer
- GoF Observer pushes: the subject notifies, and the observer takes what it gets. A slow observer either blocks the subject or drops.
Flowaddsrequest(n): the subscriber asks for a number of items, and the publisher must not exceed it. The javadoc says it exists “to avoid resource management problems that may otherwise occur in ‘push’ based systems”.- In our lab: requesting 2 and publishing 5 delivered exactly 2, and the rest waited.
- In .NET:
IObservable<T>has no backpressure either, which is why the docs point atIAsyncEnumerable<T>andChannelsfor consumer-paced work. - Design consequence: an unbounded queue with no backpressure just moves the failure to memory.
Likely follow-up: “Why did the JDK deprecate java.util.Observer?” Correctness, not style: unspecified notification order, and notifications not one-for-one with state changes.
6. Strategy registry or a switch: how do you choose?
Show a strong answer
- Ask who adds cases. Your team, in this repository: a switch over a closed type, checked by the compiler. Other teams, plugins or config: a registry.
- Ask what a missed case should do. If you want the compiler to object, use a closed type (an error in Java and Rust, CS8509 in C#); if a sensible default is right, use a registry.
- Cost, measured: in our lab, adding a discount to the switch changed the shared
pricingpackage; with Strategy,pricingwas untouched and the new rule plus the wiring changed instead. - The registry’s blind spot: nothing warns you that a case has no rule, in any of the four languages.
Likely follow-up: “Can you have both?” Yes: a closed type for the cases you own and a fallback rule for extensions, or a registry whose keys come from an enum you match exhaustively.
7. What is a decorator, and when does it become a problem?
Show a strong answer
- Definition: a wrapper with the same interface that adds behaviour.
io.TeeReaderin Go, iterator adapters in Rust, stream wrappers in Java and C#, middleware everywhere. - Why it’s popular: it composes at run time, and each layer is testable alone.
- Where it hurts: deep stacks make stack traces and debugging painful; timing changes (Go’s
TeeReaderdocuments “no internal buffering – the write must complete before the read completes”); and error handling can be swallowed layer by layer. - Rule of thumb: name each layer after what it adds, keep the stack shallow, and log the layer that changed the outcome.
Likely follow-up: “How is it different from inheritance?” Decoration composes at run time and can be stacked and reordered; a subclass is fixed at compile time and can only be one thing (Part 8).
8. How would you model an order’s lifecycle?
Show a strong answer
- Start with a sealed type per state carrying only that state’s data, and exhaustive matching on transitions (Part 9). Adding a state then breaks the build in Java and Rust, and warns in C#, everywhere it matters.
- Keep transitions in one place rather than in the states, to avoid the coupling the Rust book describes: adding a state between two others forces edits to its neighbours.
- Persist the state as a column plus a check constraint, and treat a row from the database as untrusted input.
- Outgrow it deliberately: when you need parallel regions, timers, retries or history, that’s a statechart or a workflow engine, not a bigger enum.
- Typestate (one type per state, transitions consuming the old value) suits in-memory protocols, not entities loaded from a database.
Likely follow-up: “Where do side effects go?” Not in the state objects. Return an event or a command from the transition and let the caller perform it, so the machine stays testable.
Sources
- Labs:
system-design/checks/part12_patterns/(the middleware chain in four languages, the two Go pricing designs, and the JavaObserverandFlowruns); the C#, Java, Go and Rust programs above are run by the series’ code verifiers - Erich Gamma with Bill Venners: Design Principles from Design Patterns, How to Use Design Patterns and Patterns and Practice, 2005; E. Gamma, R. Helm, R. Johnson with L. O’Brien, Design Patterns 15 Years Later, 2009
- Peter Norvig, Design Patterns in Dynamic Languages, 1996; Paul Graham, Revenge of the Nerds, 2002
- D. Harel, “Statecharts: A Visual Formalism for Complex Systems”, Science of Computer Programming 8(3), 1987; L. Prechelt, B. Unger, W. F. Tichy, P. Brössler, L. G. Votta, “A Controlled Experiment in Maintenance Comparing Design Patterns to Simpler Solutions”, IEEE TSE 27(12), 2001
- .NET: middleware and writing middleware, the observer design pattern, System.Text.Json converters, delegates, HostApplicationBuilder, the options pattern, hosted services
- Java: JLS §9.8, functional interfaces, Comparator, Observable (deprecated), Flow, SubmissionPublisher, the Jakarta Servlet specification, HttpClient.Builder, Stream.Builder, JEP 441; Brian Goetz, Data Oriented Programming in Java, 2022
- Go: net/http, io, sort, Effective Go; Rob Pike, Self-referential functions and the design of options, 2014; Dave Cheney, Functional options for friendly APIs, 2014
- Rust: std::iter, From, Fn, impl Trait, the Book’s object-oriented design patterns chapter, the API Guidelines on builders; tower’s Service and Layer (community crate)
What to remember
- A pattern is a name for a shape, not a goal. Ask what varies and who may add cases.
- Strategy is composition over inheritance made concrete, and in all four languages it’s usually a function.
- Middleware is the pattern every framework agrees on: registration order in, reverse order out, and a short-circuit ends the chain.
- Adding a case to a switch changes the shared code; adding it as a strategy leaves that code alone and gives you no compile-time warning when one is missing.
- Observer without backpressure is a memory leak waiting for a slow consumer.
request(n), channels andIAsyncEnumerableexist for that. - When a language feature deletes a pattern, delete the pattern. The GoF authors did it themselves with JUnit 4.
The patterns worth keeping are the ones you’d still name in a design discussion after the code is written.