97 lines
6.6 KiB
Markdown
97 lines
6.6 KiB
Markdown
# domain-core — 설계 결정 참조
|
|
|
|
순수 도메인 계층 모듈. 패키지 루트: `dev.caskeleton.domain`.
|
|
|
|
허용/금지 의존, 책임 범위, 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다.
|
|
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
|
|
"왜 이렇게 했나"가 궁금할 때 본다. 플랜/스펙 추적용 ID는
|
|
더 이상 코드 주석에 두지 않고, 그 근거를 아래에 쉬운 말로 풀어 둔다.
|
|
|
|
여기서 자주 나오는 "ArchUnit 규칙"이란, 빌드할 때 코드가 정해진 구조를 어겼는지 자동으로
|
|
검사하는 아키텍처 테스트다. 즉 아래의 "이런 건 금지"라는 결정들은 말로만 정한 약속이 아니라
|
|
빌드가 실제로 강제한다.
|
|
|
|
---
|
|
|
|
## identifier — 리소스 식별자
|
|
|
|
### `ResourceId` — 모든 식별자가 구현하는 마커 인터페이스
|
|
|
|
시스템의 모든 리소스 식별자 값 객체(예: `WorkLogId`)가 구현하는 공통 인터페이스다.
|
|
식별자의 실제 문자열 값은 `value()` 하나로 노출한다.
|
|
|
|
- **왜 `sealed` 로 닫지 않았나.** 원래 레퍼런스 설계는 `permits WorkLogId` 처럼 허용 구현을
|
|
못 박는 닫힌 집합(`sealed`)으로 만들려 했다. 그런데 `WorkLogId` 같은 실제 식별자는
|
|
`sample-portfolio`(샘플 코드)에 있고, 이 마커는 재사용 가능한 `domain-core`에 있다.
|
|
`permits` 절을 쓰는 순간 `domain-core`가 `sample-portfolio`를 의존하게 되는데, 이건
|
|
모듈 의존성 규칙과 `production_code_does_not_depend_on_sample_portfolio` ArchUnit 규칙이
|
|
둘 다 금지하는 방향이다. 그래서 `sealed`을 포기했다.
|
|
- **그럼 "닫힌 집합" 보장은 어디서 하나.** `sealed`이 줬을 보장(아무 타입이나 식별자로
|
|
쓰지 못하게 하는 것)은 대신 빌드 타임의 `no_long_id_pk` ArchUnit 규칙(D17)이 강제한다 —
|
|
도메인의 모든 `id` 필드는 반드시 `ResourceId` 구현이어야 하고, 날것의 `Long` PK 는 금지된다.
|
|
- **`<SELF>` 타입 파라미터(F-bounded generic).** 제네릭이 자기 자신을 다시 가리키는
|
|
재귀 구조다. 구현 타입이 "나는 나 자신 타입의 `ResourceId`다"라고 선언하게 해서, 자기
|
|
타입을 그대로 돌려주는 self-typed API 를 타입 안전하게 만들 수 있다.
|
|
- **`value()` 계약.** 36자 canonical UUID(RFC 9562 UUIDv7) 문자열을 반환한다(D2/D3).
|
|
|
|
### `IdFactory` — 식별자 생성 포트
|
|
|
|
새 식별자를 발급하는 도메인 포트(인터페이스)다.
|
|
|
|
- **"발급할 책임"과 "실제 생성 행위"를 분리한다.** 식별자를 발급할 *책임* 은 도메인(이 포트)이
|
|
소유한다. 하지만 실제로 식별자를 *만드는 행위* 는 인프라 어댑터(예: UUIDv7 생성기)가
|
|
수행하고, 애플리케이션 유스케이스가 그 둘을 조율한다. 이렇게 나누면 도메인은 구체적인
|
|
난수·시계 같은 소스를 전혀 알지 못한 채, 식별자에 대한 계약만 소유한 순수한 상태로 남는다.
|
|
|
|
---
|
|
|
|
## stereotype — 도메인 모델링 스테레오타입 마커
|
|
|
|
`@ValueObject`, `@AggregateRoot`, `@DomainEvent` 세 개의 마커 애너테이션 묶음이다.
|
|
|
|
- **왜 이름 규칙이 아니라 마커 애너테이션인가.** 이 애너테이션들은 동작도, 프레임워크 의존도
|
|
없는 순수 POJO 마커다. 아키텍처 테스트가 "클래스 이름이 `~VO`로 끝나는가" 같은 깨지기 쉬운
|
|
네이밍 규칙 대신, `@ValueObject` 처럼 **명시적이고 의도가 드러나는 선언**을 기준으로 모델링
|
|
가드레일을 걸 수 있게 한다.
|
|
- **왜 프레임워크 중립으로 두나.** 일부러 어떤 프레임워크에도 의존하지 않게 만들어서
|
|
`domain-core`가 순수 라이브러리로 남게 한다(`domain_is_pure` 가드레일).
|
|
|
|
### `@ValueObject` — 값 객체
|
|
|
|
불변이고, 개념적 식별자가 없으며, 자기 불변식을 스스로 검증하는 값 타입을 표시한다(D5/D6).
|
|
|
|
- **불변식 검증은 유일한 생성 경로에서만.** 값 객체는 record canonical 생성자 또는 팩토리라는
|
|
단 하나의 생성 경로에서 자기 불변식을 검증한다.
|
|
- **강제 규칙 — public 무인자 생성자 금지.** `@ValueObject` 타입과 `..domain.vo..` 아래 모든
|
|
타입은 public 무인자 생성자를 노출하면 안 된다. 빈 생성자는 검증을 건너뛰고 객체를 만들 수
|
|
있는 "불변식 우회 뒷문"이기 때문이다(`value_objects_have_no_public_no_arg_constructor`).
|
|
- **왜 `RUNTIME` 리텐션인가.** ArchUnit(바이트코드 검사)과 리플렉션 기반 테스트가 둘 다
|
|
이 애너테이션을 읽을 수 있어야 해서 런타임까지 유지한다.
|
|
|
|
### `@AggregateRoot` — 애그리거트 루트
|
|
|
|
애그리거트의 일관성 경계이자, 그 상태를 바꿀 수 있는 유일한 진입점인 타입을 표시한다
|
|
(D7, Vernon "Effective Aggregate Design").
|
|
|
|
- **상태 변경은 의도가 드러나는 메서드로만.** 상태 변경은 불변식을 강제하는, 의도가 분명한
|
|
애그리거트 메서드를 통해서만 일어나야 한다. 날것의 `setXxx` 세터를 public 으로 열어두면 안 된다.
|
|
- **강제 규칙 — public 세터 금지.** `@AggregateRoot` 타입의 모든 `set*` 메서드는
|
|
package-private 또는 protected 여야 한다(`aggregate_root_setters_are_not_public`). 이
|
|
가시성은 Vernon Option A(ORM 외부에서 매핑하는 방식)로 객체를 재구성할 때 의존하는 지점이다.
|
|
- **이 규칙의 한계.** ArchUnit 은 정적으로 `set*` 라는 이름 패턴까지만 잡을 수 있다.
|
|
`applyXxx` 처럼 이름이 다른 상태 변경 메서드는 자동 검사가 닿지 않으므로 코드 리뷰가 잡아야
|
|
할 몫으로 남는다(spec §4 PRE-DECISION).
|
|
|
|
### `@DomainEvent` — 도메인 이벤트
|
|
|
|
도메인에서 일어난 사실을 담은, 불변이고 전송수단에 독립적인(transport-free) 타입을 표시한다
|
|
(D4/D8).
|
|
|
|
- **"transport-free"가 무슨 뜻인가.** 이벤트는 도메인 데이터만 담고, 메시지 브로커·와이어
|
|
포맷·HTTP 같은 전송 계층 타입을 절대 참조하지 않는다는 뜻이다. 도메인 이벤트를 통합 이벤트나
|
|
와이어 이벤트로 번역하는 일은 애플리케이션/인프라 경계의 책임이지 도메인의 책임이 아니다.
|
|
- **강제 규칙 두 가지.** 모든 `@DomainEvent` 타입은 (1) `record` 여야 하고
|
|
(`domain_events_are_records`, 불변성 보장), (2) 어떤 전송 패키지(`org.apache.kafka..`,
|
|
`org.springframework.http..`, `jakarta.ws.rs..`)도 의존하면 안 된다
|
|
(`domain_events_are_transport_free`).
|