Aggregate Root ArchUnit 검증 규칙
목적: Aggregate Root 설계 규칙의 자동 검증 (빌드 시 자동 실행)
철학: 모든 규칙을 빌드 타임에 강제하여 Zero-Tolerance 달성
참조: 상세 설계 원칙은 Aggregate Guide 참조
1) 검증 항목 요약
Aggregate Root 규칙 (18개)
| # |
규칙 |
유형 |
| 1 |
Lombok 어노테이션 금지 |
❌ 금지 |
| 2 |
JPA 어노테이션 금지 |
❌ 금지 |
| 3 |
Spring 어노테이션 금지 |
❌ 금지 |
| 4 |
Setter 메서드 금지 |
❌ 금지 |
| 5 |
생성자 private 필수 |
✅ 필수 |
| 6 |
forNew() 메서드 필수 |
✅ 필수 |
| 7 |
of() 메서드 필수 |
✅ 필수 |
| 8 |
reconstitute() 메서드 필수 |
✅ 필수 |
| 9 |
ID 필드 final 필수 |
✅ 필수 |
| 10 |
Instant 타입 필드 필수 (시간 처리) |
✅ 필수 |
| 11 |
외래키 VO 타입 필수 |
❌ 금지 (원시 타입) |
| 12 |
패키지 위치 규칙 |
✅ 필수 |
| 13 |
public 클래스 |
✅ 필수 |
| 14 |
final 클래스 금지 |
❌ 금지 |
| 15 |
비즈니스 메서드 명명 규칙 |
⚠️ 권장 |
| 16 |
외부 레이어 의존 금지 |
❌ 금지 |
| 17 |
createdAt 필드 (Instant, final) |
✅ 필수 |
| 18 |
updatedAt 필드 (Instant, non-final) |
✅ 필수 |
TestFixture 패턴 규칙 (4개)
| # |
규칙 |
유형 |
| 19 |
forNew() 메서드 필수 |
✅ 필수 |
| 20 |
of() 메서드 필수 |
✅ 필수 |
| 21 |
reconstitute() 메서드 필수 |
✅ 필수 |
| 22 |
create*() 메서드 금지 |
❌ 금지 |
Domain Events 조건부 규칙 (2개)
| # |
규칙 |
유형 |
| 23 |
domainEvents 필드가 있으면 final 필수 |
🔄 조건부 |
| 24 |
domainEvents 필드가 있으면 registerEvent(), pullDomainEvents() 필수 |
🔄 조건부 |
ID VO 타입 규칙 (1개)
| # |
규칙 |
유형 |
| 25 |
*Id 접미사 필드는 VO 타입 필수 |
❌ 금지 (원시 타입) |
경고 규칙 (1개)
| # |
규칙 |
유형 |
| 26 |
List<String> 필드 사용 시 경고 |
⚠️ 경고 (테스트 실패 안함) |
2) 의존성 추가
// build.gradle
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
3) ArchUnit 테스트 코드
package com.company.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 java.time.Clock;
import java.time.Instant;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
/**
* AggregateRoot ArchUnit 검증 테스트 (Zero-Tolerance)
*/
@DisplayName("AggregateRoot ArchUnit Tests")
@Tag("architecture")
class AggregateRootArchTest {
private static JavaClasses classes;
@BeforeAll
static void setUp() {
classes = new ClassFileImporter()
.importPackages("com.company.domain");
}
// ==================== 금지 규칙 (1-4) ====================
@Test
@DisplayName("[금지] Lombok 어노테이션 사용 금지")
void aggregateRoot_MustNotUseLombok() {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should().beAnnotatedWith("lombok.Data")
.orShould().beAnnotatedWith("lombok.Builder")
.orShould().beAnnotatedWith("lombok.Getter")
.orShould().beAnnotatedWith("lombok.Setter")
.orShould().beAnnotatedWith("lombok.AllArgsConstructor")
.orShould().beAnnotatedWith("lombok.NoArgsConstructor")
.orShould().beAnnotatedWith("lombok.RequiredArgsConstructor")
.orShould().beAnnotatedWith("lombok.Value")
.because("Pure Java 원칙");
rule.check(classes);
}
@Test
@DisplayName("[금지] JPA 어노테이션 사용 금지")
void aggregateRoot_MustNotUseJPA() {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should().beAnnotatedWith("jakarta.persistence.Entity")
.orShould().beAnnotatedWith("jakarta.persistence.Table")
.orShould().beAnnotatedWith("jakarta.persistence.Column")
.orShould().beAnnotatedWith("jakarta.persistence.Id")
.orShould().beAnnotatedWith("jakarta.persistence.ManyToOne")
.orShould().beAnnotatedWith("jakarta.persistence.OneToMany")
.because("Domain Layer는 JPA에 독립적");
rule.check(classes);
}
@Test
@DisplayName("[금지] Spring 어노테이션 사용 금지")
void aggregateRoot_MustNotUseSpring() {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should().beAnnotatedWith("org.springframework.stereotype.Component")
.orShould().beAnnotatedWith("org.springframework.stereotype.Service")
.orShould().beAnnotatedWith("org.springframework.stereotype.Repository")
.because("Domain Layer는 Spring에 독립적");
rule.check(classes);
}
@Test
@DisplayName("[금지] Setter 메서드 금지")
void aggregateRoot_MustNotHaveSetterMethods() {
ArchRule rule = noMethods()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().arePublic()
.and().haveNameMatching("set[A-Z].*")
.should().beDeclared()
.because("비즈니스 메서드로 상태 변경");
rule.check(classes);
}
// ==================== 필수 규칙 (5-10) ====================
@Test
@DisplayName("[필수] 생성자는 private")
void aggregateRoot_ConstructorMustBePrivate() {
ArchRule rule = constructors()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().areDeclaredInClassesThat().areNotInterfaces()
.and().areDeclaredInClassesThat().areNotEnums()
.should().bePrivate()
.because("정적 팩토리 메서드(forNew, of, reconstitute)로만 생성");
rule.check(classes);
}
@Test
@DisplayName("[필수] forNew() 정적 팩토리 메서드")
void aggregateRoot_MustHaveForNewMethod() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should(haveStaticMethodWithName("forNew"))
.because("신규 생성용 팩토리 메서드 필수");
rule.check(classes);
}
@Test
@DisplayName("[필수] of() 정적 팩토리 메서드")
void aggregateRoot_MustHaveOfMethod() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should(haveStaticMethodWithName("of"))
.because("ID 기반 생성용 팩토리 메서드 필수");
rule.check(classes);
}
@Test
@DisplayName("[필수] reconstitute() 정적 팩토리 메서드")
void aggregateRoot_MustHaveReconstituteMethod() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should(haveStaticMethodWithName("reconstitute"))
.because("영속성 복원용 팩토리 메서드 필수");
rule.check(classes);
}
@Test
@DisplayName("[필수] ID 필드는 final")
void aggregateRoot_IdFieldMustBeFinal() {
ArchRule rule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().haveNameMatching("id")
.should().beFinal()
.because("ID는 불변");
rule.check(classes);
}
@Test
@DisplayName("[필수] Clock 필드 필수")
void aggregateRoot_MustHaveClockField() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should().dependOnClassesThat().areAssignableTo(Clock.class)
.because("테스트 가능성을 위해 Clock 주입 필수");
rule.check(classes);
}
// ==================== 타입 규칙 (11-14) ====================
@Test
@DisplayName("[금지] 외래키는 VO 타입 (원시 타입 금지)")
void aggregateRoot_ForeignKeyMustBeValueObject() {
ArchRule rule = noFields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().haveNameMatching(".*[Ii]d")
.and().doNotHaveName("id")
.should().haveRawType(Long.class)
.orShould().haveRawType(String.class)
.orShould().haveRawType(Integer.class)
.because("외래키는 VO 사용 (Long paymentId ❌ → PaymentId paymentId ✅)");
rule.check(classes);
}
@Test
@DisplayName("[필수] 패키지 위치: domain.[bc].aggregate.[name]")
void aggregateRoot_MustBeInCorrectPackage() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.and().haveSimpleNameNotEndingWith("Id")
.and().haveSimpleNameNotEndingWith("Event")
.and().haveSimpleNameNotEndingWith("Exception")
.and().haveSimpleNameNotEndingWith("Status")
.should().resideInAPackage("..domain..aggregate..")
.because("Aggregate는 aggregate 패키지에 위치");
rule.check(classes);
}
@Test
@DisplayName("[필수] public 클래스")
void aggregateRoot_MustBePublic() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should().bePublic()
.because("다른 레이어에서 사용하기 위해 public 필수");
rule.check(classes);
}
@Test
@DisplayName("[금지] final 클래스 금지")
void aggregateRoot_ShouldNotBeFinal() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.should().notBeFinal()
.because("확장 가능성을 위해 final 금지");
rule.check(classes);
}
// ==================== 비즈니스 규칙 (15-16) ====================
@Test
@DisplayName("[권장] 비즈니스 메서드는 명확한 동사")
void aggregateRoot_BusinessMethodsShouldHaveExplicitVerbs() {
ArchRule rule = methods()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().arePublic()
.and().areNotStatic()
.and().haveNameNotMatching(".*<init>.*")
.and().haveNameNotMatching("(id|status|createdAt|updatedAt|pullDomainEvents).*")
.and().haveNameNotMatching("(is|has|can).*")
.should().haveNameMatching("(add|remove|confirm|cancel|approve|reject|ship|deliver|complete|fail|update|change|place|validate|calculate|transfer|process|register).*")
.because("비즈니스 메서드는 명확한 동사로 시작");
rule.check(classes);
}
@Test
@DisplayName("[금지] Application/Adapter 레이어 의존 금지")
void aggregateRoot_MustNotDependOnOuterLayers() {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..aggregate..")
.should().dependOnClassesThat().resideInAnyPackage(
"..application..",
"..adapter.."
)
.because("헥사고날 아키텍처: Domain은 외부 레이어에 의존 금지");
rule.check(classes);
}
// ==================== 시간 필드 규칙 (17-18) ====================
@Test
@DisplayName("[필수] createdAt 필드 (Instant 타입, final)")
void aggregateRoot_CreatedAtMustBeInstantAndFinal() {
ArchRule typeRule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().haveNameMatching("createdAt")
.should().haveRawType(Instant.class)
.because("시간 필드는 Instant 사용 (LocalDateTime 금지)");
ArchRule finalRule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().haveNameMatching("createdAt")
.should().beFinal()
.because("createdAt은 불변");
typeRule.check(classes);
finalRule.check(classes);
}
@Test
@DisplayName("[필수] updatedAt 필드 (Instant 타입, non-final)")
void aggregateRoot_UpdatedAtMustBeInstantAndNotFinal() {
ArchRule typeRule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().haveNameMatching("updatedAt")
.should().haveRawType(Instant.class)
.because("시간 필드는 Instant 사용 (LocalDateTime 금지)");
ArchRule notFinalRule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().haveNameMatching("updatedAt")
.should().notBeFinal()
.because("updatedAt은 상태 변경 시 갱신");
typeRule.check(classes);
notFinalRule.check(classes);
}
// ==================== TestFixture 규칙 (19-22) ====================
@Test
@DisplayName("[필수] TestFixture는 forNew() 메서드 필수")
void fixtureClassesShouldHaveForNewMethod() {
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("Fixture")
.and().resideInAPackage("..fixture..")
.should(haveStaticMethodWithName("forNew"))
.because("Fixture는 Aggregate와 동일한 패턴(forNew, of, reconstitute)");
rule.check(classes);
}
@Test
@DisplayName("[필수] TestFixture는 of() 메서드 필수")
void fixtureClassesShouldHaveOfMethod() {
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("Fixture")
.and().resideInAPackage("..fixture..")
.should(haveStaticMethodWithName("of"))
.because("Fixture는 Aggregate와 동일한 패턴");
rule.check(classes);
}
@Test
@DisplayName("[필수] TestFixture는 reconstitute() 메서드 필수")
void fixtureClassesShouldHaveReconstituteMethod() {
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("Fixture")
.and().resideInAPackage("..fixture..")
.should(haveStaticMethodWithName("reconstitute"))
.because("Fixture는 Aggregate와 동일한 패턴");
rule.check(classes);
}
@Test
@DisplayName("[금지] TestFixture는 create*() 메서드 금지")
void fixtureClassesShouldNotHaveCreateMethod() {
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("Fixture")
.and().resideInAPackage("..fixture..")
.should(notHaveMethodsWithNameStartingWith("create"))
.because("create*() 대신 forNew(), of(), reconstitute() 사용");
rule.check(classes);
}
// ==================== Domain Events 규칙 (23-24) ====================
@Test
@DisplayName("[조건부] List<DomainEvent> 필드가 있으면 final이어야 한다")
void aggregateRoot_DomainEventsFieldMustBeFinal() {
ArchRule rule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().areDeclaredInClassesThat().areNotInterfaces()
.and().areDeclaredInClassesThat().areNotEnums()
.and().haveNameMatching("domainEvents")
.should().beFinal()
.allowEmptyShould(true)
.because("domainEvents 필드는 불변이어야 합니다 (add/remove만 허용)");
rule.check(classes);
}
@Test
@DisplayName("[조건부] List<DomainEvent> 필드가 있으면 registerEvent(), pullDomainEvents() 메서드가 있어야 한다")
void aggregateRoot_WithDomainEvents_MustHaveRegisterEventMethod() {
ArchRule rule = classes()
.that().resideInAPackage("..domain..aggregate..")
.and().areNotInterfaces()
.and().areNotEnums()
.and().haveSimpleNameNotEndingWith("Id")
.and().haveSimpleNameNotEndingWith("Event")
.should(haveDomainEventsMethodsIfFieldExists())
.allowEmptyShould(true)
.because("domainEvents 필드가 있으면 registerEvent(), pullDomainEvents() 메서드 필수");
rule.check(classes);
}
private static ArchCondition<JavaClass> haveDomainEventsMethodsIfFieldExists() {
return new ArchCondition<>("have registerEvent() and pullDomainEvents() if domainEvents field exists") {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
boolean hasDomainEventsField = javaClass.getAllFields().stream()
.anyMatch(field -> field.getName().equals("domainEvents"));
if (!hasDomainEventsField) {
return; // domainEvents 필드가 없으면 규칙 적용 안함
}
// registerEvent 메서드 확인 (protected)
boolean hasRegisterEvent = javaClass.getAllMethods().stream()
.anyMatch(method -> method.getName().equals("registerEvent")
&& method.getModifiers().contains(JavaModifier.PROTECTED));
// pullDomainEvents 메서드 확인 (public)
boolean hasPullDomainEvents = javaClass.getAllMethods().stream()
.anyMatch(method -> method.getName().equals("pullDomainEvents")
&& method.getModifiers().contains(JavaModifier.PUBLIC));
if (!hasRegisterEvent) {
events.add(SimpleConditionEvent.violated(javaClass,
String.format("%s가 domainEvents 필드를 가지고 있지만 protected registerEvent() 메서드가 없습니다",
javaClass.getSimpleName())));
}
if (!hasPullDomainEvents) {
events.add(SimpleConditionEvent.violated(javaClass,
String.format("%s가 domainEvents 필드를 가지고 있지만 public pullDomainEvents() 메서드가 없습니다",
javaClass.getSimpleName())));
}
}
};
}
// ==================== ID VO 타입 규칙 (25) ====================
@Test
@DisplayName("[금지] *Id 접미사 필드는 VO 타입이어야 한다 (Long, String, Integer 금지)")
void aggregateRoot_IdSuffixFieldsMustBeValueObject() {
ArchRule rule = noFields()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.and().areDeclaredInClassesThat().areNotInterfaces()
.and().areDeclaredInClassesThat().areNotEnums()
.and().haveNameMatching(".*[Ii]d")
.and().doNotHaveName("id") // 자기 자신 ID는 제외
.should().haveRawType(Long.class)
.orShould().haveRawType(String.class)
.orShould().haveRawType(Integer.class)
.allowEmptyShould(true)
.because("*Id 접미사 필드는 VO 타입 필수 (Long paymentId ❌ → PaymentId paymentId ✅)");
rule.check(classes);
}
// ==================== 경고 규칙 (26) ====================
@Test
@DisplayName("[경고] List<String> 필드는 전용 VO 사용 권장 (테스트 실패 안함)")
void aggregateRoot_ListStringFieldsShouldBeWrapped() {
// 권장 패턴 - 실패해도 테스트 통과, 경고만 출력
List<String> warnings = new ArrayList<>();
classes.stream()
.filter(javaClass -> javaClass.getPackageName().contains(".aggregate"))
.filter(javaClass -> !javaClass.isInterface())
.filter(javaClass -> !javaClass.isEnum())
.forEach(javaClass -> javaClass.getAllFields().stream()
.filter(field -> field.getRawType().isEquivalentTo(List.class))
.filter(field -> field.reflect() != null
&& field.reflect().getGenericType().getTypeName().contains("java.lang.String"))
.forEach(field -> warnings.add(
String.format("[경고] %s.%s - List<String> 대신 전용 VO 컬렉션 사용 권장",
javaClass.getSimpleName(), field.getName()))));
// 경고 출력 (테스트는 통과)
if (!warnings.isEmpty()) {
System.out.println("\n=== List<String> 필드 경고 ===");
warnings.forEach(System.out::println);
System.out.println("=== 권장: List<String> → List<VO> 또는 VO 래퍼 클래스 사용 ===\n");
}
}
// ==================== 커스텀 ArchCondition ====================
private static ArchCondition<JavaClass> haveStaticMethodWithName(String methodName) {
return new ArchCondition<>("have public static method: " + 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) {
events.add(SimpleConditionEvent.violated(javaClass,
String.format("%s에 public static %s() 메서드 없음",
javaClass.getName(), methodName)));
}
}
};
}
private static ArchCondition<JavaClass> notHaveMethodsWithNameStartingWith(String prefix) {
return new ArchCondition<>("not have methods starting with: " + prefix) {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
javaClass.getAllMethods().stream()
.filter(method -> method.getName().startsWith(prefix))
.forEach(method -> events.add(SimpleConditionEvent.violated(javaClass,
String.format("%s에 금지된 메서드 %s() 존재",
javaClass.getName(), method.getName()))));
}
};
}
}
4) 실행 방법
# ArchUnit 테스트 실행
./gradlew test --tests '*ArchTest'
# 특정 테스트만
./gradlew test --tests 'AggregateRootArchTest'
5) 실패 예시
❌ aggregateRoot_CreatedAtMustBeInstantAndFinal() FAILED
Field <Order.createdAt> has raw type LocalDateTime (expected: Instant)
→ 해결: Instant createdAt으로 변경
❌ aggregateRoot_ForeignKeyMustBeValueObject() FAILED
Field <Order.paymentId> has raw type Long
→ 해결: PaymentId paymentId로 변경
❌ aggregateRoot_DomainEventsFieldMustBeFinal() FAILED
Field <Order.domainEvents> is not final
→ 해결: private final List<DomainEvent> domainEvents = new ArrayList<>();
❌ aggregateRoot_WithDomainEvents_MustHaveRegisterEventMethod() FAILED
Order가 domainEvents 필드를 가지고 있지만 protected registerEvent() 메서드가 없습니다
→ 해결: protected void registerEvent(DomainEvent event) { domainEvents.add(event); }
❌ aggregateRoot_IdSuffixFieldsMustBeValueObject() FAILED
Field <Order.paymentId> has raw type Long
→ 해결: PaymentId paymentId로 변경 (VO 타입 사용)
⚠️ aggregateRoot_ListStringFieldsShouldBeWrapped() WARNING (테스트 통과)
[경고] Order.tags - List<String> 대신 전용 VO 컬렉션 사용 권장
→ 권장: List<Tag> tags 또는 Tags tags (래퍼 VO)
📖 관련 문서