Choosing and Avoiding Design Patterns: Over-Engineering and Alternatives
ProgrammingLesson 4 of 47 min read
What you'll learn
- Recognise over-engineered pattern use and describe what a simpler version looks like
- Apply a short checklist to decide whether a pattern earns its place
- Map classic patterns to the language features that replace them in modern Python and Java
- Use pattern names as shared vocabulary without forcing them into code
On this page
A pull request adds a 10 percent discount code. It touches nine files: an abstract strategy, two concrete strategies, a factory, a service, an interface for the factory, and tests for each. The reviewer's comment is one line: "Could this be a dictionary?" Often it can. Knowing the patterns is only half the skill; the other half is knowing when the problem in front of you is too small for them.
The over-engineered version
Here is a representative slice of that pull request, trimmed to one file. It is not *wrong*: every class is individually reasonable and the output is correct. The question is what each piece buys.
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, amount): ...
class PercentageDiscountStrategy(DiscountStrategy):
def __init__(self, percent):
self.percent = percent
def apply(self, amount):
return amount - amount * self.percent / 100
class FlatDiscountStrategy(DiscountStrategy):
def __init__(self, off):
self.off = off
def apply(self, amount):
return max(amount - self.off, 0)
class DiscountStrategyFactory:
_registry = {
"SAVE10": lambda: PercentageDiscountStrategy(10),
"FLAT200": lambda: FlatDiscountStrategy(200),
}
@classmethod
def create(cls, code):
return cls._registry[code]()
class DiscountService:
def __init__(self, factory):
self.factory = factory
def apply_code(self, code, amount):
return self.factory.create(code).apply(amount)
service = DiscountService(DiscountStrategyFactory)
print(service.apply_code("SAVE10", 2000))
print(service.apply_code("FLAT200", 2000))Output
1800.0 1800
There are five types and about forty lines to answer "given a code and an amount, what is the new amount?" Two codes exist today. The DiscountStrategy interface has two implementations and a factory that is really a dictionary with extra steps. Anyone who needs to add a code must find and edit the registry, understand the class hierarchy, and write a class. Compare this with the next version.
DISCOUNTS = {
"SAVE10": lambda amount: amount * 0.90,
"FLAT200": lambda amount: max(amount - 200, 0),
}
def apply_code(code, amount):
try:
return DISCOUNTS[code](amount)
except KeyError:
raise ValueError(f"unknown code {code!r}") from None
print(apply_code("SAVE10", 2000))
print(apply_code("FLAT200", 2000))
# A third code is one more line. When some codes need state or several methods
# (usage limits, expiry), THAT is the moment to promote them to classes.
DISCOUNTS["WELCOME"] = lambda amount: amount - 50
print(apply_code("WELCOME", 2000))Output
1800.0 1800 1950
Same results for the two codes, in a dozen lines, and adding a code is one more line (the last output, 1950, comes from the WELCOME code added at the bottom). The structure still follows the strategy idea, since each code maps to a function with the same signature, but it was reached by stopping at the smallest thing that works. The comment in the code names the trigger for adding structure: when codes need state, such as usage limits or expiry dates, they have earned classes.
A checklist before you reach for a pattern
- Name the pain in one sentence. "Every new discount means editing the checkout function" is a real problem. "This should be extensible" is not.
- Has it happened twice? The first time, write the simple version. The second time, notice the repetition. The third time, you have enough examples to know what the abstraction should look like.
- Does the language already do it? Functions, closures, generators, context managers, decorators, records and sealed types cover many classic patterns, as the next section shows.
- Could a new teammate trace a request through it in five minutes? If a pattern adds three layers between the caller and the work, it must remove more confusion than it adds.
- Can you remove it later? Structure that is easy to introduce when needed is cheaper than structure that is hard to remove. Prefer the version that keeps both options open.
Patterns that quietly became language features
Many classic patterns were written for languages where the alternative was a lot of boilerplate. Modern Python and Java include features that express the same intent directly. The snippet below shows six in Python.
import functools
from contextlib import contextmanager
# Decorator pattern -> a function decorator
def retry(times):
def wrap(func):
@functools.wraps(func)
def inner(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except ConnectionError:
print(f" attempt {attempt} failed")
raise ConnectionError("gave up")
return inner
return wrap
calls = {"n": 0}
@retry(times=3)
def fetch_rate():
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionError
return 83.2
print("decorator:", fetch_rate())
# Iterator pattern -> a generator
def batches(items, size):
for start in range(0, len(items), size):
yield items[start:start + size]
print("iterator:", list(batches([1, 2, 3, 4, 5], 2)))
# Template method / acquire-release -> a context manager
@contextmanager
def locked(resource):
print(f" lock {resource}")
try:
yield
finally:
print(f" unlock {resource}")
with locked("ledger"):
print("context manager: working")
# Strategy -> a function argument
print("strategy:", sorted(["bb", "a", "ccc"], key=len, reverse=True))
# Memoisation -> functools.cache
@functools.cache
def slow_square(n):
return n * n
slow_square(12)
slow_square(12)
print("cache:", slow_square.cache_info())
# Type-based dispatch (visitor-like) -> singledispatch
@functools.singledispatch
def describe(value):
return "something else"
@describe.register
def _(value: int):
return "an integer"
@describe.register
def _(value: list):
return f"a list of {len(value)}"
print("dispatch:", describe(7), "|", describe([1, 2]), "|", describe(3.5))Output
attempt 1 failed attempt 2 failed decorator: 83.2 iterator: [[1, 2], [3, 4], [5]] lock ledger context manager: working unlock ledger strategy: ['ccc', 'bb', 'a'] cache: CacheInfo(hits=1, misses=1, maxsize=None, currsize=1) dispatch: an integer | a list of 2 | something else
Read the output top to bottom. The retry decorator wraps a flaky function without a decorator class hierarchy. A generator is the iterator pattern in four lines. The locked context manager guarantees the unlock in a finally, which is the acquire-and-release template. sorted(key=len) is a strategy passed as an argument. functools.cache replaces a hand-written memoisation object, and its cache_info shows one hit and one miss. singledispatch selects behaviour by argument type, the job people often reach for the visitor pattern to do.
| Classic pattern | Python today | Java today |
|---|---|---|
| Strategy | A function or key= argument | A lambda or method reference for a functional interface |
| Command | A closure or functools.partial | A Runnable or Callable lambda (add a class when you need undo) |
| Iterator | Generators and yield | Iterable with a for-each loop, and streams |
| Decorator (wrapper) | @decorator syntax | Still a wrapper class; or a framework annotation and proxy |
| Singleton | A module-level object | A single-element enum, or one instance managed by the DI container |
| Builder | Keyword arguments and dataclasses | Still useful; records reduce the need for simple cases |
| Visitor | singledispatch or structural match | Sealed interfaces with pattern-matching switch (Java 21) |
| Template method | A context manager or a callback | Still a base class, or a lambda passed in |
None of this makes the patterns obsolete. It means that you should recognise the *intent* (swap an algorithm, undo an action, add behaviour around a call) and then use whatever the language gives you for it. A Java developer on 17 or 21 is using patterns whenever they pass a lambda to Comparator.comparing, and a Python developer is whenever they write a decorator.
Using the names without the machinery
The strongest use of patterns in day-to-day work is communication. "Make the checkout take a pricing strategy" or "wrap the client in a retrying decorator" compresses a design discussion into a sentence, if the team shares the vocabulary. Naming a class RetryingDecorator rarely adds anything over Retrying, though; the name belongs in your conversation, not necessarily in your identifiers.
Common mistakes
- Pattern first, problem second. Starting from "let's use a factory" instead of from the duplicated code produces classes with nothing to do. The symptom is a type with one implementation and a name ending in
FactoryorManager. - Abstracting after one example. An interface designed from a single use usually has the wrong shape, and the second implementation forces breaking changes.
- Naming noise.
AbstractDiscountStrategyFactoryProvidercommunicates structure, not purpose. Names should describe the business role. - Cargo-cult Java in Python. Getters, setters, interfaces for everything and one-class-per-file habits often make Python code much longer than the idiomatic equivalent.
- Ignoring the framework. Re-implementing observer or dependency injection by hand next to a framework that already provides it creates two systems to learn.
- Refusing patterns on principle. A 600-line
if/elifovertypecodes is not "simple"; it is a missing strategy. The aim is proportion, not abstinence. - Judging by diagram. A class diagram that matches the book is not evidence the design is good. Judge by how painful the next change is.
How this shows up in real projects and interviews
In code review the useful comments are questions: what problem does this solve, how many implementations exist, what happens when we add the next one? Senior engineers are often distinguished less by how many patterns they can implement than by how often they decline to. Legacy systems show the other failure: a framework of abstractions built for flexibility nobody ever used, which the team now has to maintain.
In interviews, a design question rewards proportion. Write the simple version first, say which requirement would make you introduce a strategy or an observer, and then show it. Interviewers read "I'd start with a dictionary and promote it to classes once rules need state" as strong judgement. For deeper practice at that trade-off, work through SOLID in a realistic refactor, and then try recognising these ideas in the codebases of the roles you find on /jobs.
Try it yourself
Take the over-engineered discount code from this lesson and extend it with a requirement that genuinely needs structure: each code has an expiry date and a maximum number of uses, and applying it increments a counter. Decide which version (dictionary of functions, or classes) is now the better design, implement it, and write two sentences justifying the switch in terms of the checklist above.
Show a hint
Functions cannot easily hold per-code state, so a small class (or a frozen dataclass plus a separate usage counter) is now justified. Keep the public function apply_code(code, amount) unchanged so that callers do not notice the refactor.
Key takeaways
- A pattern earns its place by removing a specific, repeated pain; if you cannot name the pain, do not add it.
- Wait for the second or third occurrence before abstracting, and promote functions to classes only when state or multiple methods appear.
- Modern languages express many patterns directly: decorators, generators, context managers, lambdas, sealed types and pattern matching.
- Pattern names are valuable shared vocabulary for design discussions, not a requirement to create classes with those names.
- Judge a design by how easy the next change is, not by how closely it matches a catalogue diagram.
Test yourself
A trick from this lesson's ride: Indirection must pay rent: one implementation behind one factory has no tenant. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
When should I use a design pattern?
Use one when you can name a specific, recurring problem it solves, such as the same type-based if-else chain in several places, and the simple version has already become painful. Use it as a vocabulary and a refactoring target rather than a design you plan up front.
Are design patterns still relevant in modern Python and Java?
Yes, as ideas. Many are now built into the language: decorators, generators, lambdas, context managers, records and sealed types. You still apply strategy, observer or decorator, but the implementation is often a function or annotation instead of a class hierarchy.
What is over-engineering in software design?
Adding abstraction, layers or flexibility beyond what current requirements need, such as an interface with one implementation or a factory that wraps a dictionary. It raises the cost of understanding and changing code without providing a benefit, and it is often caused by applying patterns before the problem appears.