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.
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 zero-boilerplate:
record AccountId(String value) { AccountId { validate(value); } }. - Performance concerns are a myth at application scale. The JVM inlines small objects aggressively, and Project Valhalla (value types) will eliminate the remaining overhead for hot paths.
The Problem
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");
I've seen the cents/dollars confusion in a production payment system. A developer passed dollar amounts to a cent-denominated API. The bug existed for three weeks before a customer reported being charged $199 instead of $1.99. The payment provider had no upper-bound validation either.
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
| 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 NotesFromSDE Premium.