Refactoring Entity Validation Logic to Reduce Repeated Null Checks
- Published on
Junyoung Yang
After implementing user profile initialization and update in GRIT, I looked back at the code and noticed repeated null checks and blank string validation for each field. The feature worked, but if more profile fields were added later, I would have to keep updating both the validation part and the assignment part together.
At first, it looked like a simple problem of reducing the number of lines. But after looking again, the real question was "Where should the validation live?" At first, I thought it was enough for the service to block invalid values. But when the rules for changing entity state are spread across different places, the flow becomes harder to follow.
This post is a record of moving the validation needed to change the User entity state into the entity itself.
Problem
The profile initialization and update logic looked like this.
public void initializeProfile(String nickname, String introduction, String image) {
if (this.role != Role.PENDING) {
throw new ProfileAlreadyInitializedException("이미 프로필이 초기화된 회원이다.");
}
if (StringUtils.isBlank(nickname) || StringUtils.isBlank(introduction)
|| StringUtils.isBlank(image)) {
throw new IllegalArgumentException();
}
this.nickname = nickname;
this.introduction = introduction;
this.image = image;
this.role = Role.USER;
}
public void updateProfile(String nickname, String introduction, String image) {
validateNotBlankIfPresent(nickname, "닉네임");
validateNotBlankIfPresent(introduction, "자기소개");
validateNotBlankIfPresent(image, "이미지");
if (nickname != null) {
this.nickname = nickname;
}
if (introduction != null) {
this.introduction = introduction;
}
if (image != null) {
this.image = image;
}
}
The problem was clear. In the update logic, the same field was checked once for validation and then checked again for assignment. There were only three fields at the time, but if the number of fields increased, it would become easier to make mistakes.
Approach
First, I cleaned up the initialization logic so that required value validation and assignment did not look separated.
private void validateRoleForInitialization() {
if (this.role != Role.PENDING) {
throw new ProfileAlreadyInitializedException("이미 프로필이 초기화된 회원이다.");
}
}
private String validateAndGet(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new InvalidProfileFieldException(fieldName + "은(는) 필수이며 공백일 수 없다.");
}
return value;
}
public void initializeProfile(String nickname, String introduction, String image) {
validateRoleForInitialization();
this.nickname = validateAndGet(nickname, "닉네임");
this.introduction = validateAndGet(introduction, "자기소개");
this.image = validateAndGet(image, "이미지");
this.role = Role.USER;
}
validateAndGet returns the value after it passes validation. Because of that, the caller can see in one line which field is validated and assigned.
Implementation
Profile update was closer to PATCH. Fields that came in as null should not be changed, and only fields with values should be validated and applied.
So I grouped validation and assignment into a method that receives a Consumer<String> and runs only when a value is present.
private void updateIfPresent(String value, Consumer<String> setter, String fieldName) {
if (value != null) {
setter.accept(validateAndGet(value, fieldName));
}
}
public void updateProfile(String nickname, String introduction, String image) {
updateIfPresent(nickname, value -> this.nickname = value, "닉네임");
updateIfPresent(introduction, value -> this.introduction = value, "자기소개");
updateIfPresent(image, value -> this.image = value, "이미지");
}
After this change, validation and assignment were tied into one flow. When adding a new field, I no longer had to match separate validation calls and assignment calls as much.
Alternative
At first, I also considered using a Spring utility such as Assert.hasText.
Assert.hasText(nickname, "닉네임은 필수이며 공백일 수 없다.");
This makes the code shorter, but it throws IllegalArgumentException when validation fails. In GRIT, the error code and message returned to the client had to follow the project's own rule.
So instead of using a simple utility, I kept a business exception such as InvalidProfileFieldException, so validation failures could also be returned through the common error response flow. Even if the code became a little longer, it fit the project's exception handling flow better.
Takeaway
This refactoring was not mainly about shortening the code. It was more about making the entity hold the rules needed to change its own state.
The points I checked were:
- Do not handle the same field twice separately for validation and assignment.
- Keep entity state change rules inside the entity when possible.
- Framework utilities are convenient, but check whether they fit the project's exception policy.
- If the rules become more complex, consider separating them into a Value Object such as
Nickname.
For now, the validation is only about blank strings. But if nickname length limits or special character rules are added later, separating the rule into a value object may become more natural than keeping string validation methods inside the entity.