- DDD
- Software Architecture
- Java
Primitive Obsession: when String and BigDecimal become your domain
IDs, money, email, and status modeled as primitive types. Why it scatters rules, blinds the compiler, and how Value Objects fix it.

Make illegal states unrepresentable.
Yaron Minsky
In the anemic domain model post I mentioned primitive obsession in passing: when the Value Objects you're missing turn into loose String and BigDecimal all over the code. It's a cousin of the anemic model, they almost always show up together, and it deserves its own post because it has a specific, expensive consequence: the compiler stops helping you.
Primitive obsession is modeling domain concepts with the language's primitive types. The order id is a String. The price is a BigDecimal. The email is a String. The status is a String. Each of these is a business concept with its own rules, and each is being represented by a generic type that knows nothing about those rules.
Yaron Minsky's line at the top captures the antidote: a good type makes an invalid state impossible to exist. Primitive obsession does the opposite, it leaves every invalid state representable.
The snippets are in Java, but the concept holds for any typed language.
What it looks like
A method signature with primitive obsession usually looks like this:
public void placeOrder(String customerId, String productId,
BigDecimal price, String email) {
// ...
}Four parameters, three String and one BigDecimal. Looks harmless. Now look at this call:
placeOrder(productId, customerId, price, email);The first two arguments are swapped. I passed productId where customerId should go and vice versa. And the code compiles. It runs. It ships. It only breaks when someone notices orders are coming out with customer and product flipped, probably in a bug report three weeks later.
The compiler doesn't catch it because, to it, everything is a String. It has no way to know that one String is a customer id and the other is a product id. You threw away the information that would have let it protect you.
Why it hurts
The compiler goes blind
This is the most direct cost. When everything is a String, the compiler can't tell a customer id from a product id, from a zip code, from a name. Any String fits any parameter that expects a String. You swap arguments by position and nobody warns you.
The same goes for BigDecimal: a price, a discount percentage, and a quantity can all be BigDecimal, and the compiler lets you add a price to a discount without complaint, even though it makes no sense in the domain.
Validation gets scattered
Where do you validate that an email is valid? If the answer is "everywhere that receives an email", you have primitive obsession. Since the email is just a String, nothing guarantees it's actually an email. So every entry point revalidates: the controller validates, the service validates just in case, the import job validates a different way, and one of them forgets.
The rule "what a valid email is" should live in one place. With String, it has no home, so it scatters and diverges.
The primitive has no behavior
A BigDecimal knows how to add a BigDecimal. It doesn't know that money has a currency, that you can't add reais to dollars, that a negative value makes no sense for a price. All that behavior sits outside the type, scattered across services, which is exactly the anemic model from the previous post. Primitive obsession and the anemic model are two views of the same problem: behavior that should live in a domain object ended up outside it.
The code doesn't speak the business language
String status doesn't say which statuses are possible or which transitions are valid. BigDecimal price doesn't say it's money. The Ubiquitous Language, which was supposed to live in the code, gets lost among the primitives. Whoever reads the signature placeOrder(String, String, BigDecimal, String) has no idea what's going on there.
The cure: Value Objects
The fix is the same as in the fundamentals post: each concept becomes its own type. Ids become types, money becomes Money, email becomes Email.
public void placeOrder(CustomerId customerId, ProductId productId,
Money price, Email email) {
// ...
}Now the swapped call:
placeOrder(productId, customerId, price, email);It doesn't compile. productId is a ProductId, and the first parameter expects a CustomerId. The compiler stops it before it runs. The bug that would show up three weeks later becomes a compile error in three seconds.
And each Value Object carries its own validation and behavior, like Email:
public record Email(String value) {
private static final Pattern PATTERN =
Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
public Email {
if (value == null || !PATTERN.matcher(value).matches()) {
throw new InvalidEmailException(value);
}
}
}The rule "what a valid email is" now lives in one place, the Email constructor. Once you hold an Email, it is valid, guaranteed. No part of the system needs to revalidate. If the String was invalid, the Email never came into existence.
The Money from the fundamentals post is the same reasoning for BigDecimal: it knows its currency, refuses to go negative, and knows how to add another Money while checking the currencies match. Money behavior inside the money type.
The rule of thumb: if a primitive has a validation rule, its own behavior, or can't be swapped for another primitive of the same type without becoming a bug, it wants to be a Value Object.
"But it's just a String"
Here's the honest part, because primitive obsession has an opposite mistake: wrapping everything.
Not every primitive needs to become a Value Object. If a field is just pass-through data, with no rule and no risk of a swap, a dedicated type is pointless ceremony. A free-text notes field on a form gains nothing by becoming a Notes. A boolean active flag is just a boolean.
The sign that a type is worth it is having a rule or a risk of confusion. An id that can be swapped for another id in a signature: worth the type. An email with a format to validate: worth the type. Money with a currency and behavior: worth the type. Some arbitrary free text: probably not.
As with the anemic model, the mistake isn't using a primitive, it's the mismatch. Modeling a rule-heavy concept as a primitive is expensive. Creating an anemic Value Object for every trivial field is expensive too, in ceremony and noise. The work is telling them apart.
How to spot it in your codebase
Signs of primitive obsession:
- Signatures with several parameters of the same primitive type.
method(String, String, String)is an invitation to swap arguments without the compiler noticing. - The same validation repeated in multiple places. If you validate email, tax id, or zip code format in more than one spot, the rule has no owner.
BigDecimalorStringcarrying a business concept. Price, money, percentage, status, all as raw primitives.- Status constants as
String.if (status.equals("SUBMITTED"))scattered around, instead of a type that knows the valid states. - Utility methods to format/validate primitives.
EmailUtils.isValid,ZipFormatter.format. Usually behavior looking for the object it belongs to.
What's next
The anemic model and primitive obsession are two faces of the same problem: behavior and rules that should live in the domain end up outside it, in a service or in a primitive. The next posts in the series stay on this theme of misplaced boundaries: the domain service used as a dumping ground for logic, the use case that does too much, and the confusion of treating every JPA entity as if it were an aggregate.
Ever lost time to a swapped-argument bug a type would have caught? Or seen the opposite excess, a Value Object for every trivial field? Reach out on LinkedIn, I'd love to hear about it!