Design patterns are a vocabulary. Saying "this is a strategy" or "that is an adapter" lets a team discuss structure quickly. The trouble starts when the vocabulary becomes a checklist and patterns get applied because they exist, not because the code needs them.
This post covers four patterns I have removed from production code in the last few years. For each one: what it looked like, what it cost, and the signals that would have made it worth keeping.
1. Singleton holding mutable state
What we had
A Python service had a Config singleton that also cached feature flags fetched from a remote service:
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.flags = {}
cls._instance.loaded_at = None
return cls._instance
def flag(self, name):
if self.loaded_at is None or time.time() - self.loaded_at > 60:
self.flags = fetch_flags()
self.loaded_at = time.time()
return self.flags.get(name, False)
What it cost
- Tests interfered with each other. One test set a flag; a later test in the same process saw it.
We had a fixture that reached into
Config._instanceto reset it, which every new test author had to know about. - Hidden network calls. Any function anywhere could call
Config().flag(...), which might trigger an HTTP request. Latency spikes in unrelated endpoints traced back to flag refreshes. - No way to run two configurations side by side, which we needed for a multi-tenant migration.
What we did instead
A plain FlagClient object created once at startup and passed to the code that needs it. The
module-level instance still exists for convenience in scripts, but nothing reaches for it implicitly.
When a singleton pays off
When the object is genuinely one-per-process, immutable or thread-safe, and expensive to create: a
connection pool, a compiled regex table, a logger. Even then, a module-level variable created at startup
is usually simpler than the __new__ dance. The problem is rarely "only one instance"; it is global,
mutable, implicitly accessed state.
2. Abstract factory with one product
What we had
Code that generated PDF invoices:
public interface DocumentFactory {
Renderer createRenderer();
Layout createLayout();
FontProvider createFontProvider();
}
public class PdfDocumentFactory implements DocumentFactory { ... }
Plus a DocumentFactoryProvider, and a config key to choose the factory. There was one factory. There
had only ever been one factory. The original author expected HTML and Word output "soon"; four years
later neither had been asked for.
What it cost
Every change to invoice rendering touched four or five files. New engineers spent their first day on the
billing team working out which interface to open. When we finally did add HTML output, the abstraction
did not fit: HTML had no use for FontProvider and needed a template engine the interface did not
model. We changed the interface anyway, so the up-front abstraction saved nothing.
What we did instead
Deleted the factory, provider and interfaces. PdfInvoiceRenderer became one class. When HTML arrived
we wrote HtmlInvoiceRenderer and introduced a small InvoiceRenderer interface with one method,
render(Invoice): byte[], shaped by two real implementations instead of one guess.
When an abstract factory pays off
When you have families of related objects that must match, and more than one family exists today. UI toolkits with per-platform widgets are the classic case: a Windows button must go with a Windows scrollbar. If you have one family, you have a constructor.
3. Observer that leaked memory
What we had
A Node.js server used an EventEmitter so modules could react to order updates. A websocket handler
subscribed per connection:
wss.on("connection", (socket, req) => {
const onUpdate = (order) => {
if (order.userId === req.userId) socket.send(JSON.stringify(order));
};
orderEvents.on("updated", onUpdate);
});
What it cost
Nobody removed the listener when the socket closed. Every closed connection left a closure holding the socket in memory. After a day the process used several gigabytes, and Node had been printing a warning we ignored:
MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 updated listeners added to [EventEmitter]. MaxListeners is 10.
Use emitter.setMaxListeners() to increase limit
Someone had "fixed" that warning with setMaxListeners(0).
What we did instead
Tied every subscription to the lifetime of its owner:
wss.on("connection", (socket, req) => {
const onUpdate = (order) => { /* ... */ };
orderEvents.on("updated", onUpdate);
socket.once("close", () => orderEvents.off("updated", onUpdate));
});
In newer code we use an AbortSignal: addEventListener on an EventTarget, and Node's events.on()
and events.once() helpers, accept a signal, and aborting it removes the listener. One controller.abort() on close cleans up everything the
connection registered.
When an observer pays off
When publishers genuinely should not know their subscribers, and subscribers have a clear lifetime you can tie the unsubscribe to. If the subscriber lives as long as the process, it is fine. If it lives as long as a request, connection or component, unsubscribe must be part of the same code that subscribes, not a separate cleanup someone has to remember.
4. Strategy hierarchy for two if statements
What we had
class ShippingStrategy(ABC):
@abstractmethod
def cost(self, order) -> int: ...
class StandardShipping(ShippingStrategy):
def cost(self, order): return 0 if order.total >= 5000 else 499
class ExpressShipping(ShippingStrategy):
def cost(self, order): return 1299
class ShippingStrategyFactory:
_map = {"standard": StandardShipping, "express": ExpressShipping}
@classmethod
def for_method(cls, method): return cls._map[method]()
Three classes and a factory for what is a two-line lookup.
What we did instead
def shipping_cost(order) -> int:
if order.shipping == Shipping.EXPRESS:
return 1299
return 0 if order.total_cents >= 5000 else 499
When a strategy pays off
When strategies have real behaviour of their own (several methods, state, configuration), when they are chosen at runtime from a list that grows, or when callers outside your code need to supply their own. In languages with first-class functions, a dictionary of functions often gives you the strategy pattern without the class hierarchy.
Signals that a pattern is worth it
Across all four cases, the patterns were added in anticipation. They were removed because the anticipated need never came, or came in a different shape. The signals I look for now before adding one:
| Signal | Example |
|---|---|
| A second real implementation exists or is scheduled | HTML renderer is in this sprint, not "someday" |
| Tests need a substitute | A fake payment gateway for unit tests |
| Callers outside your module need to plug in | A library letting users supply serialisers |
| The variation is runtime, not compile time | Strategy chosen per tenant from config |
| Lifetime and ownership are clear | Observer unsubscribes when its owner closes |
If none of these apply, write the direct version. Refactoring toward a pattern when the second case arrives is usually a small change. Living with the wrong abstraction for years is not.
Patterns that rarely cost anything
To be fair to the catalogue, some patterns almost always earn their place:
- Adapter around third-party clients, so the rest of your code depends on your interface and you can swap or fake the vendor.
- Builder for objects with many optional parameters, in languages without named arguments.
- Repository at the boundary between domain logic and the database, as long as it stays thin.
What they have in common: they sit at a boundary you already have, rather than inventing a new one.
Summary
- Patterns are vocabulary first, structure second. Use them when the code already has the shape.
- Singletons hurt when they hold mutable state accessed implicitly. Pass the object instead.
- An abstract factory with one family is a constructor with extra files.
- Every observer subscription needs an unsubscribe tied to the subscriber's lifetime.
- Prefer a function or a dictionary of functions to a strategy hierarchy until behaviour grows.
References: Node.js EventEmitter, AbortSignal on MDN, Python abc module.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.