Primitive Obsession is a code smell where domain concepts with semantic meaning, validation requirements, and associated behavior are represented using primitive types — String, int, float, boolean, or simple arrays — instead of small, focused domain objects — creating code where "a phone number" is just any string, "a price" is just any floating-point number, and "a user ID" is interchangeable with "a product ID" at the type level, eliminating the compile-time safety, centralized validation, and encapsulated behavior that dedicated domain types provide.
What Is Primitive Obsession?
Primitive Obsession manifests in identifiable patterns:
- Identifier Confusion: user_id: int and product_id: int are both integers — accidentally passing one where the other is expected is a type-safe operation that silently corrupts data.
- String Abuse: phone: str, email: str, zip_code: str, credit_card: str — all strings, each with completely different validation rules, formatting requirements, and behavior, treated identically by the type system.
- Monetary Values as Floats: price: float represents money with floating-point arithmetic, which cannot represent decimal currency values exactly (0.1 + 0.2 ≠ 0.3 in IEEE 754), leading to financial calculation errors and rounding bugs.
- Status Codes as Strings/Ints: status = "active" or status = 1 rather than OrderStatus.ACTIVE — no compile-time guarantee that only valid statuses are assigned, no IDE autocomplete, no refactoring safety.
- Configuration as Primitives: Functions accepting host: str, port: int, timeout: int, retry_count: int, use_ssl: bool rather than a ConnectionConfig object.
Why Primitive Obsession Matters
- Type Safety Loss: When user IDs and product IDs are both int, the type system cannot prevent delete_product(user_id) from compiling. Wrapper types (UserId(int), ProductId(int)) make this a compile-time error rather than a silent runtime data corruption.
- Scattered Validation: Phone number validation, email format checking, ZIP code pattern matching — each appears at every point where the primitive is accepted rather than once in the domain type's constructor. This guarantees validation inconsistency: some call sites validate, others don't, and the rules diverge over time.
- Lost Behavior Opportunities: A Money class should know how to add itself to other Money objects of the same currency, format itself for display, convert between currencies, and compare values. A float provides none of this — the behavior is scattered across the codebase as utility functions operating on raw floats.
- Documentation Through Types: def charge(amount: Money, recipient: AccountId) -> TransactionId is self-documenting — the types explain what each parameter means and what is returned. def charge(amount: float, recipient: int) -> int requires reading the docstring or guessing.
- Refactoring Safety: If "user ID" changes from integer to UUID, a UserId wrapper type requires changing the definition once. A raw int: user_id requires a global search-and-replace that may affect unrelated integer fields with the same name.
The Strangler Pattern for Primitive Obsession
Martin Fowler's Tiny Types approach: create minimal wrapper classes for each semantic concept, initially just wrapping the primitive with validation:
``python
# Before: Primitive Obsession
def create_user(email: str, age: int, phone: str) -> int:
if "@" not in email: raise ValueError("Invalid email")
if age < 0 or age > 150: raise ValueError("Invalid age")
...
# After: Domain Types
@dataclass(frozen=True)
class Email:
value: str
def __post_init__(self):
if "@" not in self.value:
raise ValueError(f"Invalid email: {self.value}")
@dataclass(frozen=True)
class Age:
value: int
def __post_init__(self):
if not (0 <= self.value <= 150):
raise ValueError(f"Invalid age: {self.value}")
@dataclass(frozen=True)
class UserId:
value: int
def create_user(email: Email, age: Age, phone: PhoneNumber) -> UserId:
... # Validation has already happened in the domain type constructors
`
Common Primitive Obsessions and Their Replacements
| Primitive | Replacement | Benefits |
|-----------|-------------|---------|
| float for money | Money(amount, currency) | Exact decimal arithmetic, currency safety |str
| for email | Email(address) | Validated format, normalization |int
| for user ID | UserId(int) | Type safety, prevents ID confusion |str
| for status | OrderStatus enum | Exhaustive pattern matching, autocomplete |str
| for URL | URL(str) | Validated format, path extraction |str
| for phone | PhoneNumber(str)` | E.164 normalization, formatting |
Tools
- SonarQube: Detects Primitive Obsession patterns in multiple languages.
- IntelliJ IDEA: "Introduce Value Object" refactoring suggestion for recurring primitive groups.
- Designite (C#/Java): Design smell detection covering Primitive Obsession.
- JDeodorant: Java-specific detection with automated refactoring support.
Primitive Obsession is fear of small objects — the reluctance to create dedicated types for domain concepts that results in a flat, semantically undifferentiated model where every concept is "just a string" or "just an integer," trading type safety, centralized validation, and encapsulated behavior for the illusion of simplicity that ultimately costs far more in scattered validation, silent type errors, and missed business logic concentration opportunities.