June 17, 2026 in code-smells5 minutes
A intermediate-level guide to Primitive Obsession: before-and-after java code and diagrams for a CS student.
Imagine you are building a user registration system. At first, it seems easy: you just need a few strings for names and an integer for age. You pass these primitive types into a method, and everything works perfectly.
However, as the application grows, you realize that “Email” is not just any string—it must contain an “@” symbol. “Age” is not just any number—it cannot be negative or over 150. Suddenly, your business rules are leaking into every single method that touches these variables. You find yourself writing the same if statements over and over again to check if a string looks like an email or if an integer is within a valid range.
This leads to “fragile” code where one mistake—like accidentally swapping two string arguments in a method call—causes a silent failure or a runtime crash because the compiler sees nothing wrong with two strings being passed in the wrong order.
public class UserService {
// Primitives are used for domain concepts, leading to validation leakage
public void createUser(String firstName, String lastName, String email, int age) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age");
}
System.out.println("User created: " + firstName + " " + lastName);
}
public void sendWelcomeEmail(String email) {
// Validation logic is duplicated everywhere the primitive is used
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
System.out.println("Sending mail to " + email);
}
}
classDiagram
class UserService {
+createUser(String, String, String, int) void
+sendWelcomeEmail(String) void
}
The problem you are seeing is a code smell called Primitive Obsession. This occurs when developers use basic data types (primitives like int, double, or String) to represent complex domain concepts.
When we do this, we lose the ability to let the type system protect us. We rely on “validation leakage,” where the logic required to ensure a piece of data is valid is scattered throughout the entire codebase instead of being held in one secure place. If the definition of a “valid email” changes, you have to hunt through every class that uses an email string to update your if checks.
flowchart TD
A[UserService] -->|uses String for email| B(Invalid Data?)
B -- yes --> C[Throw Exception]
A -->|uses int for age| D(Invalid Data?)
D -- yes --> E[Throw Exception]
style B fill:#f96,stroke:#333
style D fill:#f96,stroke:#333
To fix this, we use a refactoring technique called Replace Primitive with Object (often implemented via the Introduce Parameter Object pattern). Instead of passing raw strings and integers, we create small, dedicated classes that represent our domain concepts.
In Domain-Driven Design (DDD), these are known as Value Objects. A Value Object is an object whose identity is defined by its data rather than a unique ID. Most importantly, a Value Object validates itself during construction. If you have a EmailAddress object in your hand, you can trust it is valid without checking it again.
public record EmailAddress(String value) {
public EmailAddress {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("Invalid email format");
}
}
}
public record UserAge(int value) {
public UserAge {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("Age must be between 0 and 150");
}
}
}
public class UserService {
// Using Value Objects ensures the parameters are always valid
public void createUser(String firstName, String lastName, EmailAddress email, UserAge age) {
System.out.println("User created: " + firstName + " " + lastName);
}
public void sendWelcomeEmail(EmailAddress email) {
// No validation needed here; the type itself guarantees correctness
System.out.println("Sending mail to " + email.value());
}
}
classDiagram
class UserService {
+createUser(String, String, EmailAddress, UserAge) void
+sendWelcomeEmail(EmailAddress) void
}
class EmailAddress {
+value: String
}
class UserAge {
+value: int
}
UserService ..> EmailAddress : uses
UserService ..> UserAge : uses
In this new structure, the UserService no longer cares how an email is validated; it only cares that it has received a valid EmailAddress object. This enforces the Single Responsibility Principle (SRP) by moving validation logic out of the service and into the data types themselves.
sequenceDiagram
participant US as UserService
participant EA as EmailAddress
US ->> EA: new EmailAddress("test@example.com")
EA -->> US: valid object
US ->> US: createUser(...)
While Value Objects provide massive safety gains, they are not a silver bullet.
You should reach for this pattern when a primitive has specific rules or logic associated with it that repeats in multiple places. If you find yourself writing if (string.contains("@")) in more than one class, it is time to create an object.
However, avoid over-engineering by creating Value Objects for everything. If you are simply moving data across a network in a Data Transfer Object (DTO), or if a value has no rules associated with it and will never be reused, sticking to primitives can keep your code from becoming unnecessarily verbose. Do not mistake “more classes” for “better architecture”; use objects when they provide meaningful protection.
Using the refactored API with Value Objects
UserService service = new UserService();
service.createUser("Jane", "Doe", new EmailAddress("jane@example.com"), new UserAge(30));