Claude-skill-registry domain-expert
Domain Layer 전문가. DDD Aggregate Root 설계, VO 불변 객체, Domain Event, Domain Exception 구현. Law of Demeter 적용, Tell Don't Ask 패턴 강제. Lombok 금지, Setter 금지, 외부 의존성 금지.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/domain-expert" ~/.claude/skills/majiayu000-claude-skill-registry-domain-expert && rm -rf "$T"
manifest:
skills/data/domain-expert/SKILL.mdsource content
Domain Layer 전문가
🎯 목적 (Purpose)
Domain Layer의 핵심 객체(Aggregate, Value Object, Domain Event, Domain Exception) 설계 및 구현 가이드를 제공합니다. DDD 원칙에 따라 비즈니스 로직을 순수한 도메인 객체로 캡슐화합니다.
활성화 조건
명령 실행 시/impl domain {feature}
실행 후 Domain Layer 작업 시/plan- aggregate, vo, domain event, domain exception 키워드 언급 시
✅ 완료 기준 (Acceptance Criteria)
Aggregate Root
- 생성자
+ 정적 팩토리 메서드 3종 (private
,forNew
,of
)reconstitute - ID 필드
선언final - 비즈니스 메서드로 상태 변경 (Setter 금지)
-
주입으로 시간 생성 (Clock
)clock.instant() - Domain Event 등록 (
,registerEvent
)pullDomainEvents - 불변식(Invariant) 검증 로직 포함
Value Object
-
키워드 사용record - Compact Constructor에서 Self-Validation
- 정적 팩토리 메서드 (
, ID VO는of
추가)forNew - 외부 의존성 제로
Domain Exception
-
상속DomainException -
기반 생성자ErrorCode - 컨텍스트 정보 (
)Map<String, Object> args
Domain Event
-
인터페이스 구현DomainEvent -
타입 사용record -
팩토리 메서드from(Aggregate, Instant) - VO 타입 필드만 사용 (원시 타입 금지)
📋 산출물 체크리스트 (Output Checklist)
| 산출물 | 필수 | 위치 | 네이밍 규칙 |
|---|---|---|---|
| Aggregate Root | ✅ | | |
| Value Object (ID) | ✅ | | |
| Value Object (일반) | 선택 | | |
| Domain Exception | ✅ | | |
| ErrorCode Enum | ✅ | | |
| Domain Event | 선택 | | |
📝 코드 템플릿 (Code Templates)
1. Aggregate Root 템플릿
package com.ryuqq.domain.{bc}.aggregate.{name}; import com.ryuqq.domain.common.event.DomainEvent; import com.ryuqq.domain.{bc}.vo.{Name}Id; import com.ryuqq.domain.{bc}.vo.{Name}Status; import com.ryuqq.domain.{bc}.event.{Name}CreatedEvent; import com.ryuqq.domain.{bc}.exception.{Name}InvalidStateException; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; import java.util.List; /** * {Name} Aggregate Root * * <p><strong>불변식(Invariant)</strong>:</p> * <ul> * <li>TODO: 비즈니스 불변식 정의</li> * </ul> * * @author development-team * @since 1.0.0 */ public class {Name} { // ==================== 필드 ==================== private final {Name}Id id; private {Name}Status status; private final Instant createdAt; private Instant updatedAt; private final Clock clock; private final List<DomainEvent> domainEvents = new ArrayList<>(); // ==================== 생성자 (private) ==================== private {Name}({Name}Id id, {Name}Status status, Instant createdAt, Instant updatedAt, Clock clock) { if (status == null) { throw new IllegalArgumentException("Status must not be null - this is a bug"); } if (clock == null) { throw new IllegalArgumentException("Clock must not be null - this is a bug"); } this.id = id; this.status = status; this.createdAt = createdAt; this.updatedAt = updatedAt; this.clock = clock; } // ==================== 정적 팩토리 메서드 ==================== /** * 신규 생성 (Auto Increment - ID null) */ public static {Name} forNew(Clock clock) { Instant now = clock.instant(); {Name} entity = new {Name}(null, {Name}Status.CREATED, now, now, clock); entity.registerEvent({Name}CreatedEvent.from(entity, now)); return entity; } /** * ID 기반 생성 (ID 필수) */ public static {Name} of({Name}Id id, {Name}Status status, Instant createdAt, Instant updatedAt, Clock clock) { if (id == null) { throw new IllegalArgumentException("ID는 null일 수 없습니다."); } return new {Name}(id, status, createdAt, updatedAt, clock); } /** * 영속성 복원 (Mapper 전용, Event 없음) */ public static {Name} reconstitute({Name}Id id, {Name}Status status, Instant createdAt, Instant updatedAt, Clock clock) { if (id == null) { throw new IllegalArgumentException("ID는 null일 수 없습니다."); } return new {Name}(id, status, createdAt, updatedAt, clock); } // ==================== 비즈니스 메서드 ==================== /** * 상태 변경 예시 메서드 * * @throws {Name}InvalidStateException 변경 불가능한 상태인 경우 */ public void activate() { if (!canActivate()) { throw {Name}InvalidStateException.cannotActivate( this.id != null ? this.id.value() : null, this.status.name() ); } {Name}Status previousStatus = this.status; this.status = {Name}Status.ACTIVE; this.updatedAt = clock.instant(); // registerEvent(...); } // ==================== 판단 메서드 (도메인 객체가 스스로 판단) ==================== private boolean canActivate() { return this.status == {Name}Status.CREATED; } public boolean isActive() { return this.status == {Name}Status.ACTIVE; } // ==================== Event 관리 ==================== protected void registerEvent(DomainEvent event) { this.domainEvents.add(event); } public List<DomainEvent> pullDomainEvents() { List<DomainEvent> events = List.copyOf(this.domainEvents); this.domainEvents.clear(); return events; } // ==================== Getter (Setter 금지) ==================== public {Name}Id id() { return id; } public {Name}Status status() { return status; } public Instant createdAt() { return createdAt; } public Instant updatedAt() { return updatedAt; } }
2. ID Value Object 템플릿 (Long - Auto Increment)
package com.ryuqq.domain.{bc}.vo; /** * {Name} ID Value Object (Auto Increment) * * <p><strong>DB 전략</strong>: MySQL AUTO_INCREMENT - DB가 ID 할당</p> * * @param value ID 값 (null 허용: 신규 생성 시) * @author development-team * @since 1.0.0 */ public record {Name}Id(Long value) { /** * Compact Constructor (검증 로직) */ public {Name}Id { if (value != null && value <= 0) { throw new IllegalArgumentException("{Name}Id는 양수여야 합니다: " + value); } } /** * 신규 생성 - DB AUTO_INCREMENT가 ID 할당 예정 */ public static {Name}Id forNew() { return new {Name}Id(null); } /** * 기존 ID 참조 - null 금지 */ public static {Name}Id of(Long value) { if (value == null) { throw new IllegalArgumentException("기존 {Name}Id는 null일 수 없습니다"); } return new {Name}Id(value); } /** * 신규 엔티티 여부 확인 */ public boolean isNew() { return value == null; } }
3. 일반 Value Object 템플릿 (Money)
package com.ryuqq.domain.{bc}.vo; import com.ryuqq.domain.{bc}.exception.MoneyValidationException; /** * Money Value Object * * <p><strong>도메인 규칙</strong>: 금액은 0 이상이어야 한다.</p> * * @param amount 금액 (0 이상) * @author development-team * @since 1.0.0 */ public record Money(Long amount) { public static final Money ZERO = Money.of(0L); /** * Compact Constructor (검증 로직) * * @throws MoneyValidationException 금액이 음수인 경우 (400) */ public Money { if (amount == null) { throw new IllegalArgumentException("금액은 null일 수 없습니다."); } if (amount < 0) { throw new MoneyValidationException(amount); } } public static Money of(Long amount) { return new Money(amount); } public Money add(Money other) { return new Money(this.amount + other.amount); } public Money subtract(Money other) { return new Money(this.amount - other.amount); } public Money multiply(int multiplier) { return new Money(this.amount * multiplier); } public boolean isGreaterThan(Money other) { return this.amount > other.amount; } }
4. ErrorCode Enum 템플릿
package com.ryuqq.domain.{bc}.exception; import com.ryuqq.domain.common.exception.ErrorCode; /** * {BC} Bounded Context 에러 코드 * * <p><strong>에러 코드 규칙</strong>: {BC}-{3자리 숫자}</p> * <ul> * <li>0XX: 404 Not Found</li> * <li>01X: 400 Bad Request (입력 검증)</li> * <li>02X: 409 Conflict</li> * <li>03X: 400 Bad Request (비즈니스 룰)</li> * <li>05X: 500 Internal Server Error</li> * </ul> * * @author development-team * @since 1.0.0 */ public enum {BC}ErrorCode implements ErrorCode { // === 404 Not Found === {NAME}_NOT_FOUND("{BC}-001", 404, "{Name} not found"), // === 400 Bad Request (Validation) === INVALID_{NAME}_STATUS("{BC}-010", 400, "Invalid {name} status"), INVALID_MONEY_AMOUNT("{BC}-011", 400, "Invalid money amount"), // === 409 Conflict === {NAME}_ALREADY_EXISTS("{BC}-020", 409, "{Name} already exists"), // === 400 Bad Request (Business Rule) === INVALID_{NAME}_STATE("{BC}-030", 400, "Invalid {name} state for this operation"); private final String code; private final int httpStatus; private final String message; {BC}ErrorCode(String code, int httpStatus, String message) { this.code = code; this.httpStatus = httpStatus; this.message = message; } @Override public String getCode() { return code; } @Override public int getHttpStatus() { return httpStatus; } @Override public String getMessage() { return message; } }
5. Domain Exception 템플릿 (Not Found)
package com.ryuqq.domain.{bc}.exception; import com.ryuqq.domain.common.exception.DomainException; import java.util.Map; /** * {Name}NotFoundException - {Name}를 찾을 수 없을 때 발생 * * <p>HTTP 응답: 404 NOT FOUND</p> * * @author development-team * @since 1.0.0 */ public class {Name}NotFoundException extends DomainException { public {Name}NotFoundException(Long id) { super( {BC}ErrorCode.{NAME}_NOT_FOUND, String.format("{Name} not found: %d", id), Map.of("{name}Id", id) ); } }
6. Domain Exception 템플릿 (Invalid State)
package com.ryuqq.domain.{bc}.exception; import com.ryuqq.domain.common.exception.DomainException; import java.util.Map; /** * {Name}InvalidStateException - 상태 전환 불가 시 발생 * * <p>HTTP 응답: 400 BAD REQUEST</p> * * @author development-team * @since 1.0.0 */ public class {Name}InvalidStateException extends DomainException { private {Name}InvalidStateException(String message, Map<String, Object> args) { super({BC}ErrorCode.INVALID_{NAME}_STATE, message, args); } public static {Name}InvalidStateException cannotActivate(Long id, String currentStatus) { return new {Name}InvalidStateException( String.format("Cannot activate {name} %d. Current status: %s", id, currentStatus), Map.of("{name}Id", id, "currentStatus", currentStatus, "action", "activate") ); } public static {Name}InvalidStateException cannotDeactivate(Long id, String currentStatus) { return new {Name}InvalidStateException( String.format("Cannot deactivate {name} %d. Current status: %s", id, currentStatus), Map.of("{name}Id", id, "currentStatus", currentStatus, "action", "deactivate") ); } }
7. Domain Event 템플릿
package com.ryuqq.domain.{bc}.event; import com.ryuqq.domain.common.event.DomainEvent; import com.ryuqq.domain.{bc}.aggregate.{name}.{Name}; import com.ryuqq.domain.{bc}.vo.{Name}Id; import com.ryuqq.domain.{bc}.vo.{Name}Status; import java.time.Instant; /** * {Name} 생성 이벤트 * * <p>{Name}가 성공적으로 생성되었을 때 발행됩니다.</p> * * @param {name}Id {Name} ID (VO) * @param status 상태 (VO) * @param occurredAt 이벤트 발생 시각 * @author development-team * @since 1.0.0 */ public record {Name}CreatedEvent( {Name}Id {name}Id, {Name}Status status, Instant occurredAt ) implements DomainEvent { /** * Aggregate로부터 Event 생성 * * @param entity {Name} Aggregate * @param occurredAt 이벤트 발생 시각 * @return {Name} 생성 이벤트 */ public static {Name}CreatedEvent from({Name} entity, Instant occurredAt) { return new {Name}CreatedEvent( entity.id(), entity.status(), occurredAt ); } }
⚠️ Zero-Tolerance Rules
🚫 절대 금지
| 규칙 | 잘못된 예 | 올바른 예 |
|---|---|---|
| Lombok 금지 | , | Plain Java 수동 작성 |
| Setter 금지 | | , 비즈니스 메서드 |
| Getter 체이닝 금지 | | |
| LocalDateTime 금지 | | |
| Instant.now() 직접 호출 | | |
| Long FK 금지 (VO 필수) | | |
| 외부 의존성 금지 | , | 순수 Java |
✅ 필수 규칙
| 규칙 | 설명 |
|---|---|
| private 생성자 | 정적 팩토리 메서드로만 생성 |
| 정적 팩토리 3종 | , , |
| Clock 주입 | 테스트 가능성 보장 |
| Compact Constructor | VO에서 Self-Validation |
| record 사용 | VO, Event는 record 필수 |
🔗 참조 문서 (Convention References)
필수 참조
| 문서 | 경로 | 용도 |
|---|---|---|
| Domain Guide | | 전체 개요 |
| Aggregate Guide | | Aggregate 설계 |
| Aggregate Test | | Aggregate 테스트 |
| Aggregate ArchUnit | | ArchUnit 규칙 |
| VO Guide | | Value Object 설계 |
| VO Test | | VO 테스트 |
| VO ArchUnit | | VO ArchUnit 규칙 |
| Exception Guide | | 예외 설계 |
| Exception Test | | 예외 테스트 |
| Event Guide | | Event 설계 |
| Event ArchUnit | | Event ArchUnit |
📦 패키지 구조
domain/ ├─ common/ # 공통 인터페이스 │ ├─ event/ │ │ └─ DomainEvent.java # 도메인 이벤트 인터페이스 │ ├─ exception/ │ │ ├─ DomainException.java # 기본 도메인 예외 │ │ └─ ErrorCode.java # 에러 코드 인터페이스 │ └─ util/ │ └─ ClockHolder.java # Clock 인터페이스 │ └─ {boundedContext}/ # 예: order ├─ aggregate/ │ └─ {aggregateName}/ # 예: order │ ├─ Order.java # Aggregate Root │ └─ OrderLineItem.java # 종속 Entity │ ├─ vo/ │ ├─ OrderId.java # ID VO │ ├─ Money.java # 일반 VO │ └─ OrderStatus.java # Enum VO │ ├─ event/ │ └─ OrderCreatedEvent.java # Domain Event │ └─ exception/ ├─ OrderErrorCode.java # ErrorCode Enum ├─ OrderNotFoundException.java └─ OrderInvalidStateException.java
🧪 테스트 체크리스트
Aggregate 테스트
-
- 신규 생성 및 Event 등록 확인forNew() -
- ID null 시 예외 발생 확인of() -
- Event 미등록 확인reconstitute() - 비즈니스 메서드 - 상태 전환 성공/실패 케이스
- 불변식 위반 시 예외 발생 확인
Value Object 테스트
-
- 유효한 값으로 생성 성공of() - Compact Constructor - 유효하지 않은 값 시 예외
-
- 값 동등성 확인equals()/hashCode() - ID VO -
,forNew()
테스트isNew()
Exception 테스트
- ErrorCode 매핑 확인
- HTTP 상태 코드 확인
- 컨텍스트 정보(args) 확인
Event 테스트
-
- Aggregate에서 Event 생성from() - 필드 값 정확성 확인
-
시간 확인occurredAt