1
/
5

Primitive Obsession

Photo by Joel Filipe on Unsplash

目次

  • What is Primitive Obsession?

  • What's Wrong with this Code?

  • An Attempt at a Better Solution

  • A Common Solution: Validation in the Resources Layer

  • Why This Approach Falls Short

  • Towards a Solution

  • Introducing Value Objects

  • Advantages of using Value Objects

  • Summary

  • Encapsulating Data Structures in Value Objects

  • When the Data Structure is Tightly Coupled to the Entity

  • When to Use a Separate Value Object

  • Summary

  • Resources

What is Primitive Obsession?

Primitive obsession is a common anti-pattern that occurs when you overuse primitive types (like strings, integers, or doubles) to model your domain.

For example, let's take the following User class:

public class User {
private String email;
private String phoneNumber;


public User (String email, String phoneNumber) {
this.email = email;
this.phoneNumber = phoneNumber;
}


public void setEmail(String email) {
this.email = email;
}

public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}

// getters...
}

Next, let's add some validation:

public class User {
private String email;
private String phoneNumber;


public User (String email, String phoneNumber) {
if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
if (phoneNumber == null || !phoneNumber.matches("\\d{10}|(?:\\d{3}-){2}\\d{4}")) {
throw new IllegalArgumentException("Invalid phone number format");
}

this.email = email;
this.phoneNumber = phoneNumber;
}


public void setEmail(String email) {
if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
this.email = email;
}

public void setPhoneNumber(String phoneNumber) {
if (phoneNumber == null || !phoneNumber.matches("\\d{10}|(?:\\d{3}-){2}\\d{4}")) {
throw new IllegalArgumentException("Invalid phone number format");
}
this.phoneNumber = phoneNumber;
}

// getters...
}

We can see that the email and phone-number validation logic is duplicated within the User class, both in the constructor and the setter methods.

However, this duplication doesn’t stop here. Any other domain entities or services that deal with emails or phone-numbers will also need to implement the same validation logic:

public class NotificationService {

public void sendNotification(String email, String phoneNumber) {
if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
if (phoneNumber == null || !phoneNumber.matches("\\d{10}|(?:\\d{3}-){2}\\d{4}")) {
throw new IllegalArgumentException("Invalid phone number format");
}

// Proceed with sending notification...
}
}

What's Wrong with this Code?

  • Lack of Domain Knowledge Cohesion: Domain-specific knowledge is scattered across multiple services and methods, making it harder to understand, maintain, and ensure consistency.
    • Lack of a Single Source of Truth: This approach breaks the DRY principle (Don't Repeat Yourself), which emphasizes having a single source of truth for each piece of domain knowledge.
      In this example, email and phone-number validation are repeated across at least 3 places.
    • Maintenance Overhead: If validation rules change, updates are needed in multiple places.
    • Inconsistencies: Different services might implement validation logic differently, leading to potential bugs.
  • Weak Typing: Incorrect values can passed by mistake. For example, a phone-number string could accidentally be assigned to an email field, and the compiler wouldn't detect the error.

An Attempt at a Better Solution

One potential solution to avoid duplicating validation logic is to perform the validation in the resources layer, validating the parameters only once before passing them to the domain layer.
But does this fully solve the problem? Let's explore this approach...

A Common Solution: Validation in the Resources Layer

One common solution to avoid duplicating validation logic across services is to handle validation in the resources layer.

For example, consider a scenario where our application exposes a REST API for registering new users. Here, the incoming request can be validated in the resources layer, ensuring that only validated data is passed to the domain layer for further processing.

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserResource {

private final UserRequestValidationService userRequestValidationService;
private final UserMapper userMapper;
private final UserRegistrationService userRegistrationService;
private final UserResponseMapper userResponseMapper;


@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody UserRequest userRequest) {
try {
userRequestValidationService.validate(userRequest);

User userToRegister = userMapper.map(userRequest);
User registeredUser = userRegistrationService.register(userToRegister);

UserResponse userResponse = userResponseMapper.map(registeredUser);

return new ResponseEntity<>(userResponse, HttpStatus.CREATED);

} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}

public class UserRequestValidationService {

public void validate(UserRequest userRequest) {
if (userRequest.email == null || !userRequest.email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
if (userRequest.phoneNumber == null || !userRequest.phoneNumber.matches("\\d{10}|(?:\\d{3}-){2}\\d{4}")) {
throw new IllegalArgumentException("Invalid phone number format");
}
}

}

Why This Approach Falls Short

Although request validation is an important practice, it doesn't completely solve the primitive obsession issue.
Here's why:

  • Lack of Domain Knowledge Cohesion: Domain-specific knowledge is still scattered across multiple services and methods.
    • Maintenance Overhead & Inconsistencies: As the duplication problem was not solved completely, these issues remain as well.
  • Lack of a Single Source of Truth
    While moving validation to the resources layer appears to centralize the logic, it only handles the validation of one entry point.
    The same validation logic might be needed in several other cases, such as:
    • Other API endpoints (e.g., other REST API endpoints).
    • Internal service-to-service communication.
    • Additional APIs, including REST APIs, RPC APIs, and message queue consumers (e.g., Kafka, RabbitMQ).
      Modern applications have multiple ways data enters the system, each requiring the same validation.
  • Weak Typing: In this example, we pass the already validated User entity to the domain layer, which helps to some extent. However, the issue isn't fully resolved, because there may still be services that expect multiple strings, such as email or phone-number, instead of the User entity. In such cases, the weak typing issue remains.

The "Lack of Domain Knowledge Cohesion" causes the validation to be carried out far from the point where the data is ultimately used.
For instance, consider a scenario where an email is validated in the resources layer, passed through the domain layer, and eventually arrives at the NotificationService:

public class NotificationService {

public void sendNotification(String email, String phoneNumber) {
// The data was validated elsewhere.
// Proceed with sending notification...
}

}

In this case, NotificationService is the component that uses the data, but it isn’t responsible for validating it. As a result, the service has no direct assurance that the data it receives is valid.

This creates a challenge: ensuring data validity requires manually checking every possible path that leads to this service to confirm that validation has been performed earlier.
This approach is both error-prone and increases the effort required to maintain the system over time.

Towards a Solution

The real solution to primitive obsession lies in properly modeling your domain concepts. This is where Value Objects come into play. Let’s explore how they can help…

Introducing Value Objects

In Domain-Driven Design (DDD), the Value Object pattern offers a powerful solution to the problem of Primitive Obsession. Value objects are immutable classes that encapsulate both data and behavior, representing specific domain concepts. They also centralize validation logic, ensuring that only valid data enters the domain layer.

Let’s see how introducing value objects improves our example:

public class Email {
private final String value;


public Email(String value) {
if (value == null || !value.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
this.value = value;
}


public String getValue() {
return value;
}

}

public class PhoneNumber {
private final String value;


public PhoneNumber(String value) {
if (value == null || !value.matches("\\d{10}|(?:\\d{3}-){2}\\d{4}")) {
throw new IllegalArgumentException("Invalid phone number");
}
this.value = value;
}


public String getValue() {
return value;
}

}

Here, Email and PhoneNumber encapsulate their respective validation logic. Any invalid data is rejected during object creation, ensuring that the domain model is always valid.

Java records make value objects more concise:

public record Email(String value) {

public Email {
if (value == null || !value.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
}

}

public record PhoneNumber(String value) {

public PhoneNumber {
if (value == null || !value.matches("\\d{10}|(?:\\d{3}-){2}\\d{4}")) {
throw new IllegalArgumentException("Invalid phone number");
}
}

}

With these value objects, the User class and other services are updated as follows:

public class User {
private Email email;
private PhoneNumber phoneNumber;


public User (Email email, PhoneNumber phoneNumber) {
this.email = email;
this.phoneNumber = phoneNumber;
}


public void setEmail(Email email) {
this.email = email;
}

public Email getEmail() {
return this.email;
}

public void setPhoneNumber(PhoneNumber phoneNumber) {
this.phoneNumber = phoneNumber;
}

public PhoneNumber getPhoneNumber() {
return this.phoneNumber;
}
}

public class NotificationService {

public void sendNotification(Email email, PhoneNumber phoneNumber) {
// The data is valid.
// Proceed with sending notification...
}
}

Alternatively, if User is also a value object, we can represent it as a record:

public record User(Email email, PhoneNumber phoneNumber) {}


Advantages of using Value Objects

  • Domain Knowledge Cohesion: Value objects encapsulate all relevant knowledge about domain concepts. Need to understand email validation? Just look at the Email class!
    • Single Source of Truth: The validation logic is centralized in one place, eliminating duplication and ensuring adherence to the DRY (Don't Repeat Yourself) principle.
    • Reduced Maintenance Overhead: Changing validation rules, requires updating only one place.
    • Consistency Across the System: Duplicate validation logic is eliminated, ensuring all services rely on the same rules.
  • Strong Typing: Eliminates errors like passing an email where a phone-number is expected, as such mistakes will now fail at compile-time.

Summary

In this section, we explored how value objects tackle the primitive obsession anti-pattern for simple types like Strings and Integers.
However, value objects can also be used for more complex structures, such as lists or maps.
Let's explore when and how to encapsulate these data structures within value objects...

Encapsulating Data Structures in Value Objects

Data structures like lists or maps may need validation or contain specific business rules. While these structures can often be part of a domain entity, it might sometimes make sense to encapsulate them in separate value objects.
In this section, we’ll explore when to encapsulate data structures inside a value object and when to leave the validation within the root entity.

Let’s update our example: The User class now contains a list of emails instead of a single email:

public class User {
private List<Email> emails;
private PhoneNumber phoneNumber;

...
}

In this case, the email list might have some validation rules, such as preventing duplicate emails.
Does this mean we should introduce a new value object to encapsulate the list of emails? Not necessarily.

When the Data Structure is Tightly Coupled to the Entity

If the email list is tightly coupled to the User entity, the User class itself can perform the necessary validation:

public class User {
private Set<Email> emails; // Using set to avoid duplicates
private PhoneNumber phoneNumber;


public User(List<Email> emails, PhoneNumber phoneNumber) {
this.emails = new HashSet<>(emails);
this.phoneNumber = phoneNumber;
}

public List<Email> getEmails() {
return new ArrayList<>(emails);
}

public void setEmails(List<Email> emails) {
this.emails = new HashSet<>(emails);
}


public void addEmail(Email email) {
if (!emails.add(email)) {
throw new IllegalArgumentException("Duplicate email not allowed: " + email.value());
}
}

// Getter and setter for PhoneNumber...
}

In this case, the User class is the one that validates duplicate emails, ensuring the integrity of the email list without requiring a separate value object.

When to Use a Separate Value Object

However, if the data structure—like the email list—has its own validation rules and business logic that doesn't belong to any specific domain entity, it might make sense to create a separate value object.
For example, we can create a ContactEmails value object to encapsulate the email list and validation logic:

public class ContactEmails {
private Set<Email> emails;


public ContactEmails(List<Email> emails) {
this.emails = new HashSet<>(emails);
}


public List<Email> getEmails() {
return new ArrayList<>(emails);
}

public void addEmail(Email email) {
if (!emails.add(email)) {
throw new IllegalArgumentException("Adding a duplicate email is not allowed: " + email.value());
}
}

// Additional validation and business logic...

}

In this case, the ContactEmails value object encapsulates the email list along with its validation and business logic, making it easier to maintain and ensuring the integrity of the list.

Summary

  • If the data structure (such as a list of emails) is tightly coupled with an entity, it’s often appropriate to handle the validation within the entity itself.
  • If the data structure has its own validation and business rules that aren't tightly tied to any specific domain entity, it's beneficial to encapsulate it in a separate value object.

Resources

Blog posts by Vladimir Khorikov:
https://enterprisecraftsmanship.com/



1 いいね!
1 いいね!
Yevgeny Lvovskiさんにいいねを伝えよう
Yevgeny Lvovskiさんや会社があなたに興味を持つかも