Primitive obsession anti-pattern
Learn why passing raw strings, ints, and booleans for domain concepts creates type-confusion bugs, and how value objects and Java records fix it.
Primitive obsession is a modeling smell: raw values are used for domain concepts that have their own identity, validation rules, units, or behavior. The code remains easy to start but makes meaning implicit at every call site, so mistakes are discovered later than they need to be.
TL;DR
- Primitive obsession uses raw
String,int, andbooleantypes to represent domain concepts that have their own rules and identity. The compiler seesString fromAccountIdandString toAccountIdas the same type, so swapping them at a call site compiles without error. - "Stringly-typed" code is unsearchable. If 200 methods accept a
String userId, finding all methods that specifically handle user IDs requires reading every signature manually. - Value objects (immutable wrappers with validation) make illegal states unrepresentable.
new Email("invalid")throws at construction, not three layers later during SMTP delivery. - Java records make value objects low-boilerplate:
record AccountId(String value) { AccountId { validate(value); } }. - Wrapping a domain value has a cost, so keep wrappers out of measured allocation-sensitive inner loops when the trade-off is justified. For ordinary application code, decide from type-safety and domain clarity first, then verify performance with a profile.
The Problem
A primitive is not the problem merely because it is small. The smell appears when a raw value carries domain meaning that the type system, constructor, or API does not express. Symptoms include same-type parameters that can be swapped, validation repeated across callers, unit conversions scattered through services, and formatting rules hidden in utility methods.
Concrete Example
A banking transfer method that accepts four primitives:
public class TransferService {
public TransferResult transfer(
String fromAccountId,
String toAccountId,
long amountInCents,
String currencyCode
) {
if (amountInCents <= 0)
throw new IllegalArgumentException("Amount must be positive");
if (!VALID_CURRENCIES.contains(currencyCode))
throw new IllegalArgumentException("Unknown currency");
// ... execute transfer
}
}
All call-site bugs compile successfully:
// Swapped from and to: compiles, sends money the wrong direction
service.transfer(toAccount, fromAccount, 10000, "USD");
// Cents vs dollars confusion: compiles, transfers 100x too much
service.transfer(from, to, dollarAmount, "USD");
// Invalid currency case: compiles, fails at runtime
service.transfer(from, to, 10000, "usd");
The cents-versus-dollars mix-up is especially dangerous because both values are valid longs. Without a domain type or boundary validation, an amount can be wrong while still looking syntactically correct; a $1.99 transfer can become a $199 transfer.
The compiler cannot help you. Every argument is a valid String or long. The bug is invisible until runtime, and often invisible until a human notices the data is wrong.
Why It Happens
Primitives are the default because they are the path of least effort. Every reason to use them sounds reasonable in isolation.
- "It's just a string." Early in development, an account ID is just a string. The validation rules and confusion risks emerge later, but the type decision is made early and never revisited.
- Boilerplate aversion. Creating a class for a single wrapped string feels like over-engineering. Developers resist writing 20 lines to wrap one primitive (though Java records reduce this to 5).
- Serialization simplicity. JSON maps strings to strings. Adding value objects requires custom serializers. Teams avoid the initial effort and accept the type-safety gap.
- Language culture. Dynamically typed languages (Python, JavaScript) normalize passing raw strings everywhere. Developers moving to Java bring the same habits without leveraging the type system.
The searchability problem
If you represent user IDs as String, how do you find all methods that accept a user ID? You search for String userId, but that misses String uid, String uId, String user, and String id. With a UserId type, you search for UserId and find every method that touches user identity, instantly.
How to Detect It
Diagnose primitive obsession at API boundaries and in repeated validation code. Look for places where a parameter's meaning is explained by a comment rather than carried by its type.
| Signal | What It Means | How to Check |
|---|---|---|
| Method with 3+ same-type parameters | Argument swap risk at every call site | Search for methods with repeating types: (String, String, String) |
| Validation logic repeated across methods | Each caller re-validates the same domain rule | Search for duplicate regex checks or range validations |
| Bug reports with "wrong value in wrong field" | Types did not prevent confusion | Review recent bugs for argument ordering issues |
| Comments explaining what a parameter means | The type does not convey meaning | Methods with /** @param currency ISO 4217 code */ |
| Utility methods that convert or validate primitives | CurrencyUtils.isValid(String) should be a constructor | Search for *Utils or *Helper classes that validate domain primitives |
| Domain-specific formatting scattered in codebase | "acc-" + id or amount / 100 repeated in 10+ places | Search for format patterns like string prefixes or cent/dollar conversions |
If a method has three or more parameters of the same primitive type, argument-swap bugs are near-certain in a growing codebase.
The Fix
Wrap each domain primitive in a value object that validates at construction. Java records make this nearly zero-boilerplate.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.