Skip to the content.

Value Object ArchUnit 검증 가이드

목적: Value Object 아키텍처 규칙 자동 검증

ArchUnit을 활용하여 VO 설계 규칙을 빌드 시 자동으로 검증합니다.


1️⃣ 검증 규칙 개요

Value Object 검증 규칙

  1. Record 사용 필수 (Enum VO 제외)
  2. 정적 팩토리 메서드 (of) 필수 (Enum VO 제외)
  3. ID VO는 forNew() 필수
    • Long ID: forNew() → null 반환 (DB가 ID 생성)
    • String ID: forNew(String) → 외부 주입값 사용 (Application이 ID 생성)
  4. Long ID VO는 isNew() 필수 (UUID ID는 제외)
  5. Lombok 어노테이션 절대 금지
  6. JPA 어노테이션 절대 금지
  7. Spring 어노테이션 절대 금지
  8. create*() 메서드 절대 금지
  9. Enum VO는 displayName() 필수

총 9개 규칙 (필수 5개, 금지 4개)


2️⃣ 테스트 파일 위치

domain/
└── src/test/java/com/ryuqq/domain/architecture/
    └── VOArchTest.java

3️⃣ 전체 ArchUnit 테스트 코드

package com.ryuqq.domain.architecture;

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

/**
 * Value Object ArchUnit 아키텍처 검증 테스트
 *
 * <p><strong>검증 규칙</strong>:</p>
 * <ul>
 *   <li>Record 사용 필수 (Enum VO 제외)</li>
 *   <li>정적 팩토리 메서드 (of) 필수 (Enum VO 제외)</li>
 *   <li>ID VO는 forNew() 추가 필수</li>
 *   <li>Long ID VO는 isNew() 필수 (UUID ID 제외)</li>
 *   <li>Enum VO는 displayName() 필수</li>
 *   <li>Lombok 금지</li>
 *   <li>JPA 어노테이션 금지</li>
 *   <li>Spring 어노테이션 금지</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("architecture")
@Tag("domain")
@Tag("vo")
@DisplayName("Value Object 아키텍처 검증 테스트")
class VOArchTest {

    private static JavaClasses classes;

    @BeforeAll
    static void setUp() {
        classes = new ClassFileImporter().importPackages("com.ryuqq.domain");
    }

    /**
     * 규칙 1: Value Object는 Record여야 한다 (Enum 제외)
     */
    @Test
    @DisplayName("[필수] Value Object는 Record로 구현되어야 한다 (Enum 제외)")
    void valueObjectsShouldBeRecords() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .and().areNotEnums()  // Enum VO 제외
            .and().haveSimpleNameNotContaining("Fixture")
            .and().haveSimpleNameNotContaining("Mother")
            .and().haveSimpleNameNotContaining("Test")
            .should(beRecords())
            .because("Value Object는 Java 21 Record로 구현해야 합니다 (Enum VO 제외)");

        rule.check(classes);
    }

    /**
     * 규칙 2: Value Object는 of() 메서드를 가져야 한다 (Enum 제외)
     */
    @Test
    @DisplayName("[필수] Value Object는 of() 정적 팩토리 메서드를 가져야 한다 (Enum 제외)")
    void valueObjectsShouldHaveOfMethod() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .and().areNotEnums()  // Enum VO 제외
            .and().haveSimpleNameNotContaining("Fixture")
            .and().haveSimpleNameNotContaining("Mother")
            .and().haveSimpleNameNotContaining("Test")
            .should(haveStaticMethodWithName("of"))
            .because("Value Object는 of() 정적 팩토리 메서드로 생성해야 합니다 (Enum VO 제외)");

        rule.check(classes);
    }

    /**
     * 규칙 3: ID VO는 forNew() 메서드를 가져야 한다
     */
    @Test
    @DisplayName("[필수] ID Value Object는 forNew() 메서드를 가져야 한다")
    void idValueObjectsShouldHaveForNewMethod() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .and().haveSimpleNameEndingWith("Id")
            .and().haveSimpleNameNotContaining("Fixture")
            .and().haveSimpleNameNotContaining("Mother")
            .and().haveSimpleNameNotContaining("Test")
            .should(haveStaticMethodWithName("forNew"))
            .because("ID Value Object는 forNew() 메서드로 null 생성을 지원해야 합니다");

        rule.check(classes);
    }

    /**
     * 규칙 4: Long 타입 ID VO는 isNew() 메서드를 가져야 한다 (UUID ID 제외)
     *
     * <p>Long 타입 ID VO (Auto Increment)만 isNew() 필수.
     * UUID 타입 ID VO는 항상 값이 존재하므로 isNew() 불필요.</p>
     */
    @Test
    @DisplayName("[필수] Long 타입 ID VO는 isNew() 메서드를 가져야 한다 (UUID ID 제외)")
    void longIdValueObjectsShouldHaveIsNewMethod() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .and().haveSimpleNameEndingWith("Id")
            .and().haveSimpleNameNotContaining("Fixture")
            .and().haveSimpleNameNotContaining("Mother")
            .and().haveSimpleNameNotContaining("Test")
            .should(haveLongFieldAndIsNewMethod())
            .because("Long 타입 ID VO는 isNew() 메서드로 null 여부를 확인해야 합니다 (UUID ID 제외)");

        rule.check(classes);
    }

    /**
     * 규칙 5: Value Object는 Lombok 어노테이션을 사용하지 않아야 한다
     */
    @Test
    @DisplayName("[금지] Value Object는 Lombok 어노테이션을 사용하지 않아야 한다")
    void valueObjectsShouldNotUseLombok() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .should().notBeAnnotatedWith("lombok.Data")
            .andShould().notBeAnnotatedWith("lombok.Value")
            .andShould().notBeAnnotatedWith("lombok.Builder")
            .andShould().notBeAnnotatedWith("lombok.Getter")
            .andShould().notBeAnnotatedWith("lombok.Setter")
            .andShould().notBeAnnotatedWith("lombok.AllArgsConstructor")
            .andShould().notBeAnnotatedWith("lombok.NoArgsConstructor")
            .because("Value Object는 Lombok을 사용하지 않고 Pure Java Record로 구현해야 합니다");

        rule.check(classes);
    }

    /**
     * 규칙 6: Value Object는 JPA 어노테이션을 사용하지 않아야 한다
     */
    @Test
    @DisplayName("[금지] Value Object는 JPA 어노테이션을 사용하지 않아야 한다")
    void valueObjectsShouldNotUseJpa() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .should().notBeAnnotatedWith("javax.persistence.Entity")
            .andShould().notBeAnnotatedWith("javax.persistence.Table")
            .andShould().notBeAnnotatedWith("javax.persistence.Embeddable")
            .andShould().notBeAnnotatedWith("jakarta.persistence.Entity")
            .andShould().notBeAnnotatedWith("jakarta.persistence.Table")
            .andShould().notBeAnnotatedWith("jakarta.persistence.Embeddable")
            .because("Value Object는 JPA 어노테이션을 사용하지 않아야 합니다");

        rule.check(classes);
    }

    /**
     * 규칙 7: Value Object는 Spring 어노테이션을 사용하지 않아야 한다
     */
    @Test
    @DisplayName("[금지] Value Object는 Spring 어노테이션을 사용하지 않아야 한다")
    void valueObjectsShouldNotUseSpring() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .should().notBeAnnotatedWith("org.springframework.stereotype.Component")
            .andShould().notBeAnnotatedWith("org.springframework.stereotype.Service")
            .andShould().notBeAnnotatedWith("org.springframework.context.annotation.Configuration")
            .because("Value Object는 Spring 어노테이션을 사용하지 않아야 합니다");

        rule.check(classes);
    }

    /**
     * 규칙 8: Value Object는 create*() 메서드를 사용하지 않아야 한다
     */
    @Test
    @DisplayName("[금지] Value Object는 create*() 메서드를 사용하지 않아야 한다")
    void valueObjectsShouldNotHaveCreateMethod() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .and().haveSimpleNameNotContaining("Fixture")
            .and().haveSimpleNameNotContaining("Mother")
            .and().haveSimpleNameNotContaining("Test")
            .should(notHaveMethodsWithNameStartingWith("create"))
            .because("Value Object는 create*() 대신 of(), forNew()를 사용해야 합니다");

        rule.check(classes);
    }

    /**
     * 규칙 9: Enum VO는 displayName() 메서드를 가져야 한다
     */
    @Test
    @DisplayName("[필수] Enum VO는 displayName() 메서드를 가져야 한다")
    void enumValueObjectsShouldHaveDisplayNameMethod() {
        ArchRule rule = classes()
            .that().resideInAPackage("..vo..")
            .and().areEnums()
            .and().haveSimpleNameNotContaining("Fixture")
            .and().haveSimpleNameNotContaining("Mother")
            .and().haveSimpleNameNotContaining("Test")
            .should(haveMethodWithName("displayName"))
            .because("Enum VO는 displayName() 메서드로 화면 표시용 이름을 제공해야 합니다");

        rule.check(classes);
    }

    // ==================== 커스텀 ArchCondition 헬퍼 메서드 ====================

    /**
     * Record 타입인지 검증
     */
    private static ArchCondition<JavaClass> beRecords() {
        return new ArchCondition<JavaClass>("be records") {
            @Override
            public void check(JavaClass javaClass, ConditionEvents events) {
                boolean isRecord = javaClass.getModifiers().contains(JavaModifier.FINAL)
                    && javaClass.getAllMethods().stream()
                        .anyMatch(method -> method.getName().equals("toString")
                            && method.getModifiers().contains(JavaModifier.PUBLIC)
                            && method.getModifiers().contains(JavaModifier.FINAL));

                if (!isRecord) {
                    String message = String.format(
                        "Class %s is not a record. Use 'public record' instead of 'public class'",
                        javaClass.getName()
                    );
                    events.add(SimpleConditionEvent.violated(javaClass, message));
                }
            }
        };
    }

    /**
     * 클래스가 특정 이름의 public static 메서드를 가지고 있는지 검증
     */
    private static ArchCondition<JavaClass> haveStaticMethodWithName(String methodName) {
        return new ArchCondition<JavaClass>("have public static method with name " + methodName) {
            @Override
            public void check(JavaClass javaClass, ConditionEvents events) {
                boolean hasMethod = javaClass.getAllMethods().stream()
                    .anyMatch(method -> method.getName().equals(methodName)
                        && method.getModifiers().contains(JavaModifier.STATIC)
                        && method.getModifiers().contains(JavaModifier.PUBLIC));

                if (!hasMethod) {
                    String message = String.format(
                        "Class %s does not have a public static method named '%s'",
                        javaClass.getName(), methodName
                    );
                    events.add(SimpleConditionEvent.violated(javaClass, message));
                }
            }
        };
    }

    /**
     * 클래스가 특정 이름의 메서드를 가지고 있는지 검증 (static 아님)
     */
    private static ArchCondition<JavaClass> haveMethodWithName(String methodName) {
        return new ArchCondition<JavaClass>("have method with name " + methodName) {
            @Override
            public void check(JavaClass javaClass, ConditionEvents events) {
                boolean hasMethod = javaClass.getAllMethods().stream()
                    .anyMatch(method -> method.getName().equals(methodName));

                if (!hasMethod) {
                    String message = String.format(
                        "Class %s does not have a method named '%s'",
                        javaClass.getName(), methodName
                    );
                    events.add(SimpleConditionEvent.violated(javaClass, message));
                }
            }
        };
    }

    /**
     * 클래스가 특정 접두사로 시작하는 메서드를 가지지 않는지 검증
     */
    private static ArchCondition<JavaClass> notHaveMethodsWithNameStartingWith(String prefix) {
        return new ArchCondition<JavaClass>("not have methods with name starting with " + prefix) {
            @Override
            public void check(JavaClass javaClass, ConditionEvents events) {
                javaClass.getAllMethods().stream()
                    .filter(method -> method.getName().startsWith(prefix))
                    .forEach(method -> {
                        String message = String.format(
                            "Class %s has method %s starting with '%s' which is prohibited",
                            javaClass.getName(), method.getName(), prefix
                        );
                        events.add(SimpleConditionEvent.violated(javaClass, message));
                    });
            }
        };
    }

    /**
     * Long 타입 필드를 가진 ID VO가 isNew() 메서드를 가지는지 검증
     *
     * <p>Long 타입 ID VO (Auto Increment)만 isNew() 필수.
     * String 타입 (UUID) ID VO는 항상 값이 존재하므로 isNew() 불필요.</p>
     */
    private static ArchCondition<JavaClass> haveLongFieldAndIsNewMethod() {
        return new ArchCondition<JavaClass>("have Long field and isNew() method") {
            @Override
            public void check(JavaClass javaClass, ConditionEvents events) {
                // Long 타입 필드가 있는지 확인 (value 또는 id 필드)
                boolean hasLongField = javaClass.getAllFields().stream()
                    .anyMatch(field -> field.getRawType().getName().equals("java.lang.Long")
                        || field.getRawType().getName().equals("long"));

                // String 타입 필드만 있으면 (UUID ID) isNew() 불필요
                boolean hasStringFieldOnly = javaClass.getAllFields().stream()
                    .anyMatch(field -> field.getRawType().getName().equals("java.lang.String"))
                    && !hasLongField;

                // String 타입 (UUID) ID VO는 isNew() 불필요하므로 검증 통과
                if (hasStringFieldOnly) {
                    return;
                }

                // Long 타입 ID VO는 isNew() 메서드 필수
                if (hasLongField) {
                    boolean hasIsNewMethod = javaClass.getAllMethods().stream()
                        .anyMatch(method -> method.getName().equals("isNew"));

                    if (!hasIsNewMethod) {
                        String message = String.format(
                            "Long ID VO %s must have isNew() method (UUID ID VOs are exempt)",
                            javaClass.getName()
                        );
                        events.add(SimpleConditionEvent.violated(javaClass, message));
                    }
                }
            }
        };
    }
}

주의: 커스텀 ArchCondition 사용을 위해 다음 import 필요:

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;

4️⃣ 규칙 상세 설명

규칙 1: Record 사용 필수

@Test
void valueObjectsShouldBeRecords() {
    ArchRule rule = classes()
        .that().resideInAPackage("..vo..")
        .and().haveSimpleNameNotContaining("Fixture")
        .and().haveSimpleNameNotContaining("Mother")
        .and().haveSimpleNameNotContaining("Test")
        .should(beRecords())
        .because("Value Object는 Java 21 Record로 구현해야 합니다");

    rule.check(classes);
}

검증 내용:

위반 시:

Class com.ryuqq.domain.order.vo.Money is not a record. 
Use 'public record' instead of 'public class'

규칙 2: of() 메서드 필수

@Test
void valueObjectsShouldHaveOfMethod() {
    ArchRule rule = classes()
        .that().resideInAPackage("..vo..")
        .and().haveSimpleNameNotContaining("Fixture")
        .and().haveSimpleNameNotContaining("Mother")
        .and().haveSimpleNameNotContaining("Test")
        .should(haveStaticMethodWithName("of"))
        .because("Value Object는 of() 정적 팩토리 메서드로 생성해야 합니다");

    rule.check(classes);
}

검증 내용:

위반 시:

Class com.ryuqq.domain.order.vo.Money does not have a public static method named 'of'

규칙 3: ID VO는 forNew() 필수

@Test
void idValueObjectsShouldHaveForNewMethod() {
    ArchRule rule = classes()
        .that().resideInAPackage("..vo..")
        .and().haveSimpleNameEndingWith("Id")
        .and().haveSimpleNameNotContaining("Fixture")
        .and().haveSimpleNameNotContaining("Mother")
        .and().haveSimpleNameNotContaining("Test")
        .should(haveStaticMethodWithName("forNew"))
        .because("ID Value Object는 forNew() 메서드로 신규 생성을 지원해야 합니다");

    rule.check(classes);
}

검증 내용:

ID VO 유형별 forNew() 패턴:

유형 필드 타입 forNew() 시그니처 반환값 사용처
Long ID Long forNew() null 포함 ID DB Auto Increment
String ID String forNew(String) 외부 주입값 UUID, 외부 시스템 ID

Long ID VO 예시 (Auto Increment):

public record OrderId(Long value) {
    public static OrderId forNew() {
        return new OrderId(null);  // DB가 ID 생성
    }

    public static OrderId of(Long value) {
        if (value == null) {
            throw new IllegalArgumentException("OrderId must not be null");
        }
        return new OrderId(value);
    }

    public boolean isNew() {
        return value == null;
    }
}

String ID VO 예시 (UUID, 외부 시스템 ID):

public record TokenId(String value) {
    public static TokenId forNew(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("TokenId must not be empty");
        }
        return new TokenId(value);  // Application이 ID 생성 (외부 주입)
    }

    public static TokenId of(String value) {
        return forNew(value);  // 동일 검증
    }
}

위반 시:

Class com.ryuqq.domain.order.vo.OrderId does not have a public static method named 'forNew'

규칙 4: Long ID VO는 isNew() 필수 (UUID ID 제외)

@Test
void longIdValueObjectsShouldHaveIsNewMethod() {
    ArchRule rule = classes()
        .that().resideInAPackage("..vo..")
        .and().haveSimpleNameEndingWith("Id")
        .and().haveSimpleNameNotContaining("Fixture")
        .and().haveSimpleNameNotContaining("Mother")
        .and().haveSimpleNameNotContaining("Test")
        .should(haveLongFieldAndIsNewMethod())
        .because("Long 타입 ID VO는 isNew() 메서드로 null 여부를 확인해야 합니다 (UUID ID 제외)");

    rule.check(classes);
}

검증 내용:

Long ID vs UUID ID 구분: | 타입 | 필드 타입 | isNew() | 이유 | |——|———-|———|——| | Long ID | Long | 필수 | DB가 ID 생성 (Auto Increment) | | UUID ID | String | 불필요 | Application이 ID 생성 (항상 값 존재) |

위반 시:

Long ID VO com.ryuqq.domain.order.vo.OrderId must have isNew() method (UUID ID VOs are exempt)

규칙 5-7: 외부 의존성 금지

Lombok, JPA, Spring 어노테이션 사용 금지.

위반 시:

Class com.ryuqq.domain.order.vo.Money should not be annotated with @lombok.Value

규칙 8: create*() 메서드 금지

@Test
void valueObjectsShouldNotHaveCreateMethod() {
    ArchRule rule = classes()
        .that().resideInAPackage("..vo..")
        .and().haveSimpleNameNotContaining("Fixture")
        .and().haveSimpleNameNotContaining("Mother")
        .and().haveSimpleNameNotContaining("Test")
        .should(notHaveMethodsWithNameStartingWith("create"))
        .because("Value Object는 create*() 대신 of(), forNew()를 사용해야 합니다");

    rule.check(classes);
}

위반 시:

Class com.ryuqq.domain.order.vo.Money has method createMoney starting with 'create' which is prohibited

규칙 9: Enum VO는 displayName() 필수

@Test
void enumValueObjectsShouldHaveDisplayNameMethod() {
    ArchRule rule = classes()
        .that().resideInAPackage("..vo..")
        .and().areEnums()
        .and().haveSimpleNameNotContaining("Fixture")
        .and().haveSimpleNameNotContaining("Mother")
        .and().haveSimpleNameNotContaining("Test")
        .should(haveMethodWithName("displayName"))
        .because("Enum VO는 displayName() 메서드로 화면 표시용 이름을 제공해야 합니다");

    rule.check(classes);
}

검증 내용:

올바른 예시:

public enum OrderStatus {
    PENDING("주문 대기"),
    CONFIRMED("주문 확정"),
    SHIPPED("배송 중"),
    DELIVERED("배송 완료"),
    CANCELLED("주문 취소");

    private final String displayName;

    OrderStatus(String displayName) {
        this.displayName = displayName;
    }

    public String displayName() {  // ✅ 필수
        return displayName;
    }
}

위반 시:

Class com.ryuqq.domain.order.vo.OrderStatus does not have a method named 'displayName'

5️⃣ 실패 예시

❌ 실패 예시 1: Record 대신 class 사용

valueObjectsShouldBeRecords() FAILED
    Rule: classes should be records
    Violation: Class <Money> is not a record. Use 'public record' instead of 'public class'

➡️ 해결: public class Money → public record Money

❌ 실패 예시 2: of() 메서드 없음

valueObjectsShouldHaveOfMethod() FAILED
    Rule: classes should have public static method with name of
    Violation: Class <Money> does not have a public static method named 'of'

➡️ 해결: public static Money of(Long amount) 추가

❌ 실패 예시 3: ID VO에 forNew() 없음

idValueObjectsShouldHaveForNewMethod() FAILED
    Rule: classes should have public static method with name forNew
    Violation: Class <OrderId> does not have a public static method named 'forNew'

➡️ 해결: public static OrderId forNew() 추가

❌ 실패 예시 4: Lombok 사용

valueObjectsShouldNotUseLombok() FAILED
    Rule: classes should not be annotated with @lombok.Value
    Violation: Class <Money> is annotated with @lombok.Value

➡️ 해결: @lombok.Value 제거 → pure Record 사용

❌ 실패 예시 5: create*() 메서드 사용

valueObjectsShouldNotHaveCreateMethod() FAILED
    Rule: classes should not have methods with name starting with create
    Violation: Class <Money> has method createMoney starting with 'create'

➡️ 해결: createMoney() 제거 → of() 사용

❌ 실패 예시 6: Long ID VO에 isNew() 없음

longIdValueObjectsShouldHaveIsNewMethod() FAILED
    Rule: classes should have Long field and isNew() method
    Violation: Long ID VO <OrderId> must have isNew() method (UUID ID VOs are exempt)

➡️ 해결: public boolean isNew() { return value == null; } 추가

참고: UUID ID VO (String 타입)는 이 규칙에서 자동 제외됩니다.


❌ 실패 예시 7: Enum VO에 displayName() 없음

enumValueObjectsShouldHaveDisplayNameMethod() FAILED
    Rule: classes should have method with name displayName
    Violation: Class <OrderStatus> does not have a method named 'displayName'

➡️ 해결: public String displayName() { return displayName; } 추가

6️⃣ 빌드 통합

Gradle 설정

dependencies {
    testImplementation 'com.tngtech.archunit:archunit:1.2.1'
    testImplementation 'com.tngtech.archunit:archunit-junit5:1.2.1'
}

test {
    useJUnitPlatform()
}

빌드 시 자동 실행

./gradlew test

ArchUnit 테스트가 실패하면 빌드가 실패하여 규칙 위반을 방지합니다.


7️⃣ 체크리스트

ArchUnit 테스트 작성 후 다음을 확인:

Value Object 규칙 (9개)

ID VO 유형별 확인

| 유형 | 필드 타입 | forNew() 시그니처 | forNew() 반환값 | isNew() | |——|———-|——————|—————–|———| | Long ID (Auto Increment) | Long | forNew() (파라미터 없음) | null 포함 ID | ✅ 필수 | | String ID (UUID, 외부 ID) | String | forNew(String) (외부 주입) | 주입된 값 | ❌ 불필요 |

통합 확인


✅ ArchUnit은 아키텍처 규칙을 자동으로 검증하여 Zero-Tolerance를 달성합니다.