Files
tech-log-backend/src/domain-core/README.md
T

6.4 KiB

domain-core — 설계 결정 참조

순수 도메인 계층 모듈. 패키지 루트: dev.caskeleton.domain.

허용/금지 의존, 책임 범위, 테스트 명령 같은 모듈 규칙CLAUDE.md 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 설계 결정의 근거를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 플랜/스펙 추적용 ID는 더 이상 코드 주석에 두지 않고, 그 근거를 아래에 쉬운 말로 풀어 둔다.

여기서 자주 나오는 "ArchUnit 규칙"이란, 빌드할 때 코드가 정해진 구조를 어겼는지 자동으로 검사하는 아키텍처 테스트다. 즉 아래의 "이런 건 금지"라는 결정들은 말로만 정한 약속이 아니라 빌드가 실제로 강제한다.


identifier — 리소스 식별자

ResourceId — 모든 식별자가 구현하는 마커 인터페이스

시스템의 모든 리소스 식별자 값 객체(예: ArticleId)가 구현하는 공통 인터페이스다. 식별자의 실제 문자열 값은 value() 하나로 노출한다.

  • sealed 로 닫지 않았나. 실제 식별자 구현은 각 제품 도메인이 소유하고, 이 마커는 재사용 가능한 domain-core에 있다. permits 절로 구체 구현을 못 박으면 domain-core가 바깥 도메인 구현을 의존하게 되어 모듈 의존성 규칙을 거스른다. 그래서 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).