Primitive Obsession, Switch Statements, Speculative Generality
What This Concept Is
Three smells that cluster around type discipline and future-proofing regret:
- Primitive Obsession. Using built-in types (
String,int,double) for domain concepts that deserve their own type -- money asdouble, telephone numbers asString, dates aslongepochs. The domain loses its vocabulary; calculations lose their units; invalid values slip in undetected. - Repeated Switch. The same
switch/if-elseon a type code shows up in several places. Every new case requires finding and updating every switch; missing one silently breaks behavior. - Speculative Generality. Abstract classes, interfaces, extension points, and hooks introduced "just in case" a second variant shows up -- which it never does. The code ships with ceremony that delivers no value today and carries maintenance cost forever.
The first two pull toward more type or polymorphism. The third pulls away from it. Taken together they mark the boundary where you should abstract, and where you should stop.
Why It Matters Here
These smells are where the SOLID cluster meets reality. Primitive Obsession and Repeated Switch are the classic triggers for Replace Primitive with Object, Replace Type Code with Subclasses, and Replace Conditional with Polymorphism. Speculative Generality is the check that prevents those moves from turning into ceremony.
If you only learn "always use polymorphism," you will over-abstract. If you only learn "YAGNI," you will under-abstract. You need both, and the smell set tells you which pressure applies.
Concrete Example
Primitive Obsession:
def charge(card_number: str, amount: float, currency: str):
...
Currency is a string; amount is a float. Adding dollars and euros silently typechecks. A domain type can enforce this contract if it validates construction and operations. Sketch (imports, Currency and Card definitions omitted):
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: Currency
def plus(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("currency mismatch")
return Money(self.amount + other.amount, self.currency)
def charge(card: Card, total: Money): ...
Repeated Switch:
switch (shape.kind) {
case CIRCLE: return Math.PI * shape.r * shape.r;
case RECTANGLE: return shape.w * shape.h;
}
// ... same switch appears in draw(), boundingBox(), contains()
Each new shape kind requires checking several operations. Compare polymorphism with exhaustive matching on a closed variant set. A shape need not own graphics infrastructure: drawing can remain an external operation. Choose the extension axis deliberately.
Speculative Generality:
public abstract class AbstractProcessor<T, R, E extends Exception> {
protected abstract R doProcess(T t) throws E;
protected void beforeHook(T t) {}
protected void afterHook(R r) {}
}
public class OrderProcessor extends AbstractProcessor<Order, Receipt, OrderProcessingException> { ... }
Three type parameters, an exception type and two hooks introduce costs. In this sketch OrderProcessingException must extend Exception. One implementation alone does not prove waste: check test seams, ownership boundaries and public compatibility before collapsing the hierarchy.
Common Confusion / Misconception
"All switches are bad." Not true. A switch over a stable, closed set (e.g., day of week) is fine. The smell is the repeated switch -- the same discrimination in many places. A single switch in one dispatcher is routine; three switches on the same type across the codebase is a signal.
A second misconception: "creating a class for every primitive is always good." Wrap primitives when the domain gives them meaning (Money, Address, PhoneNumber). Wrapping a primitive just to introduce a layer (UserId that's literally int) can add noise in languages without cheap newtype support.
A third: "more abstraction always helps." Speculative Generality is the counterweight. An abstraction with no current benefit carries cost, but a boundary may already pay for testing, invariant protection or independent ownership even with one provider.
How To Use It
When you find one of these signals, compare candidate moves with keeping the design:
- Primitive Obsession -> Replace Primitive with Object. A domain value may centralize validation and arithmetic; presentation formatting can remain an external concern.
- Repeated Switch -> Replace Conditional with Polymorphism. Compare subclasses, supplied functions and closed matching under the actual extension axis.
- Speculative Generality -> Inline Class / Remove Hook / Collapse Hierarchy. Remove a boundary only when its present benefits and compatibility obligations do not justify its costs. A second implementation is not a prerequisite for usefulness.
Check Yourself
- Why is a single switch different from a repeated switch?
- Compare a type alias, a distinct newtype and a validating PhoneNumber value. Which guarantees depend on the language and constructor policy?
- How can Speculative Generality coexist with the Open-Closed Principle without contradicting it?
Mini Drill or Application
Pick a real file with at least one switch or type flag. Do all four:
- Count the places the same switch appears. One -> fine. Two or more -> candidate for polymorphism.
- List any primitive fields that represent a domain concept (money, phone, email, id).
- For one of them, sketch the object you would introduce and which existing code would become simpler.
- Look for any abstract class or interface with exactly one implementation -- mark it as a Speculative Generality candidate.
Read This Only If Stuck
- Refactoring: Data Clumps to Repeated Switches
- Refactoring: Replace Primitive with Object
- Refactoring: Replace Conditional with Polymorphism (Part 1)
- Refactoring: Replace Conditional with Polymorphism (Part 2)
- Clean Code: Functions -- Switch Statements and arguments
Engineering extension: Value types enforce only implemented rules
A Money wrapper that accepts NaN, mismatched currencies or ambiguous units is not safe just because it is a class. The lab uses exact integer minor units and validates currency labels; it deliberately excludes exchange and rounding. Ordinary assertions can be disabled, so use explicit public-input validation. Try: pass a Boolean or a floating-point amount and explain the error policy. Check: an immutable record with a mutable nested field would still require an aliasing argument.
Use the design contracts and practice feedback after an independent attempt.