Snapshot of the in-flight state that already existed, identically, in both this worktree and the main checkout before this session began: the initial HTTP Client platform implementation (previously untracked), the redis-lab removal, and the JPA / object-storage / notification integration work. Kept separate from this session's HTTP Client review response, which lands in the following commit, so the two bodies of work stay reviewable apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
domain-core — 설계 결정 참조
순수 도메인 계층 모듈. 패키지 루트: dev.caskeleton.domain.
허용/금지 의존, 책임 범위, 테스트 명령 같은 모듈 규칙은 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_portfolioArchUnit 규칙이 둘 다 금지하는 방향이다. 그래서sealed을 포기했다. - 그럼 "닫힌 집합" 보장은 어디서 하나.
sealed이 줬을 보장(아무 타입이나 식별자로 쓰지 못하게 하는 것)은 대신 빌드 타임의no_long_id_pkArchUnit 규칙(D17)이 강제한다 — 도메인의 모든id필드는 반드시ResourceId구현이어야 하고, 날것의LongPK 는 금지된다. <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).