# domain-core 상세 분석 ## SSOT identity — 2026-08-31 재검증 - registered leaf id: `domain-core` - canonical state `analysisFile`: `analysis/01-domain-core.md` (이 문서) — 이 leaf의 단일 SSOT - source path: `src/domain-core` · Gradle `:domain-core` - registry `allowed_dependencies`: **`[]`** - registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]` - coverage ledger: `FULL_READ` **10** / `STRUCTURAL_ONLY` **3** / `EXCLUDED` **1** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- ## 분석 범위와 결론 상태 - **revision:** `a24ece9cf797f7ea647e33bf846b115208ed1ba5` - **Gradle path:** `:domain-core` - **registry source path:** `src/domain-core` - **allowed project dependencies:** 없음 - **runtime memberships:** `app-bootstrap`, `sample-portfolio` - **scope status:** COMPLETE 이 문서는 `domain-core` 자체의 모든 production Java와 module-local build/docs를 읽고, 이 모듈이 제공하는 식별자 계약과 도메인 stereotype marker가 실제로 어디서 소비되고 어떤 build-time rule로 강제되는지까지 추적한 bounded analysis다. `sample-portfolio`의 구체 도메인 모델 전체나 `app-bootstrap` 전체는 이 scope의 소유 대상이 아니며, reachability/enforcement를 증명하는 관련 부분만 읽었다. ## 1. Quantified scope map ### Owned source - production Java: **7 files / 107 LOC** (`package-info.java` 2개 포함) - module build file: **1** (`build.gradle`) - module docs/instructions: **2** (`CLAUDE.md`, `README.md`) - dependency lock: **1** (`gradle.lockfile`) - Java tests: **0** - test placeholder: `.gitkeep` 2개 - production packages: `dev.caskeleton.domain`, `.identifier`, `.stereotype` - public domain contracts: `IdFactory`, `ResourceId` - runtime-retained markers: `AggregateRoot`, `DomainEvent`, `ValueObject` `build.gradle`의 `dependencies {}`는 비어 있다. production Java import도 `java.lang.annotation.*` 외 제3자/framework import가 없다. 따라서 현재 source shape 자체는 module instruction의 “pure domain layer”와 일치한다. ## 2. Coverage ledger | item/group | disposition | reason | |---|---|---| | `CLAUDE.md` | FULL_READ | module policy SSOT | | `README.md` | FULL_READ | explicit design rationale | | `build.gradle` | FULL_READ | declared dependency surface | | `gradle.lockfile` | STRUCTURAL_ONLY | inherited build/check/test tooling dependency lock; production dependency edge를 추가하지 않음 | | `identifier/IdFactory.java` | FULL_READ | public identity generation port | | `identifier/ResourceId.java` | FULL_READ | public resource-id contract | | root `package-info.java` | FULL_READ | domain package anchor | | `stereotype/AggregateRoot.java` | FULL_READ | modeling marker | | `stereotype/DomainEvent.java` | FULL_READ | modeling marker | | `stereotype/ValueObject.java` | FULL_READ | modeling marker | | stereotype `package-info.java` | FULL_READ | marker semantics | | two test `.gitkeep` files | STRUCTURAL_ONLY | no executable test content | | generated `build/` tree | EXCLUDED | source/build-output ownership evidence상 generated artifact; current source contract denominator에서 제외 | Owned source 기준 unclassified relevant item은 **0**이다. ## 3. 이 모듈이 실제로 소유하는 것 ### 관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다 현재 `domain-core`에는 WorkLog 같은 실제 aggregate가 없다. 실제 샘플 aggregate/value object/event는 `sample-portfolio`에 있다. 이 모듈에 남은 production surface는 다음 두 종류다. 1. **식별자 추상화** — `ResourceId`, `IdFactory` 2. **모델링 표식** — `@ValueObject`, `@AggregateRoot`, `@DomainEvent` 따라서 “business concepts, entities, value objects…”를 둘 수 있는 계층이라는 정책과 달리, 현재 snapshot의 실제 contents는 skeleton 전반에서 사용할 **domain-layer contract/marker**에 가깝다. 이는 현재 source에 대한 관찰이며, 향후 실제 production domain type이 이 module에 추가되지 않는다는 뜻은 아니다. ## 4. Identifier contract ### `ResourceId` `ResourceId`는 F-bounded generic marker이며 public surface는 `String value()` 하나다. README에 기록된 명시적 rationale은 실제 ID 구현이 `sample-portfolio`에 있으므로 `sealed permits`로 닫으면 `domain-core -> sample-portfolio` 역방향 의존이 생긴다는 것이다. 현재 registry에서도 `domain-core.allowed_dependencies=[]`이고, `ModuleRegistry`는 production module이 `sample-portfolio`를 allowlist에 넣는 것 자체를 거부한다. root `verifyCleanArchitectureDependencies`도 실제 project edge가 registry allowlist를 넘으면 실패한다. 따라서 **unsealed 선택의 이유는 문서와 build policy가 서로 일치한다**. 다만 `ResourceId.value()`의 Javadoc/README는 “36-character canonical UUID / RFC 9562 UUIDv7”를 계약처럼 서술하지만 interface 자체는 이를 검증하지 않는다. 실제 샘플 `WorkLogId`도 현재 regex로 `8-4-4-4-12` hex shape만 검사하며 UUID version nibble이 7인지, RFC variant인지 검사하지 않는다. property test `acceptsEveryCanonicalUuid`는 오히려 임의의 32 hex를 hyphenate한 모든 값을 허용한다고 명시적으로 검증한다. **관찰 결과:** “UUID-shaped canonical string”은 현재 검증되지만 “반드시 UUIDv7”이라는 더 강한 서술은 생성 adapter 경로에서는 성립해도 모든 `WorkLogId.of(...)` 입력 경로의 불변식으로는 강제되지 않는다. ### `IdFactory>` `IdFactory`는 `newId()` 하나를 가진다. sample에서는 `WorkLogIdFactory extends IdFactory`로 specialization하고, `UuidWorkLogIdFactory`가 Spring `@Component` adapter로 구현한다. adapter는 `UuidCreator.getTimeOrderedEpochPlus1()`을 호출하고 application use case가 factory를 주입받는 구조가 확인된다. 즉 source dependency는 안쪽의 domain port를 바깥 adapter가 구현하는 방향이며, domain-core는 concrete UUID library/Spring을 모른다. `newId()` Javadoc의 “never-before-used”는 타입/저장소 확인으로 강제되는 보장은 아니다. 현재 adapter test는 연속 두 값의 distinctness와 1,000회 monotonic ordering을 확인한다. 이 표현은 생성 전략의 기대 계약이지 전역 uniqueness를 저장소와 대조해 증명하는 메커니즘으로 읽어서는 안 된다. ## 5. Stereotype markers와 invariants 세 annotation은 모두 `@Target(TYPE)`, `@Retention(RUNTIME)`, `@Documented`인 framework-neutral marker다. ### `@ValueObject` marker 자체는 불변성을 구현하지 않는다. `CleanArchitectureTest`가 annotation 대상 또는 `..domain.vo..` package type에 public no-arg constructor가 없어야 한다고 강제한다. sample의 `WorkLogId`, `Period`, `WorkLogOwner`, `PosterId` 등이 실제 production consumer다. 따라서 marker의 의미는 **“이 annotation을 붙이면 ArchUnit guardrail의 subject가 된다”**는 build-time qualification이다. 실제 field 불변성/defensive copy 등 모든 value-object 속성을 자동 검증하는 것은 아니다. ### `@AggregateRoot` sample의 `WorkLog`, `Poster`가 실제 production consumer다. `CleanArchitectureTest`는 `set.*` 이름의 method가 public이면 실패시킨다. README와 test description 모두 이 rule이 이름 패턴 밖의 mutator(`applyXxx` 등)는 포착하지 못한다고 명시한다. 따라서 이 marker는 aggregate consistency를 자동으로 보장하는 annotation이 아니라 **특정 위험 surface(public raw setter)를 정적으로 제한하는 qualification marker**다. ### `@DomainEvent` sample의 `WorkLogReserved`, `PosterCreated/Archived/...` 등이 사용한다. ArchUnit은 annotation type이 record인지 검사하고 Kafka/Spring HTTP/JAX-RS package dependency를 금지한다. production `LiveEventStompBroadcaster`는 runtime reflection으로 event class가 `@DomainEvent`인지 검사하므로 `RUNTIME` retention은 ArchUnit 외 실제 runtime consumer에도 필요하다. transport-free rule의 forbidden package list는 exhaustive transport taxonomy가 아니다. test 설명 자체가 “새 broker/transport가 도입되면 list를 확장”해야 하는 구현상 한계를 명시한다. ## 6. Purity / dependency enforcement ### source-level observation 현재 domain-core production code는 Java standard annotation API 외 외부 import가 없다. module `dependencies {}`도 비어 있다. ### project-edge enforcement `src/settings.gradle`은 `ca.architecture-registry` settings plugin을 통해 `modules.json`을 읽고 각 registered leaf를 include/mapping한다. `ModuleRegistry`는 다음을 settings time에 fail-closed 검증한다. - root/module field set 정확성 - nonblank id/path - duplicate id/Gradle path/canonical source directory - source path가 repository root 밖으로 escape하지 않음 - runtime composition 값 - self dependency - unknown allowed dependency - production module의 `sample-portfolio` 허용 금지 root `verifyCleanArchitectureDependencies`는 이후 실제 Gradle project dependencies를 registry allowlist와 비교한다. 즉 domain-core에 project dependency가 추가되면 `allowed=[]`과 불일치해 verification failure가 된다. ### class dependency enforcement `CleanArchitectureTest.DOMAIN_IS_PURE`는 `..domain..` classes가 Spring/JPA/Hibernate/Lombok/application/adapter/bootstrap/service/infra/presentation/cmd 계열에 의존하지 못하게 한다. 별도 `DOMAIN_HAS_NO_LOGGER`도 logging framework dependency를 금지한다. 중요한 구분은 이 ArchUnit rule이 **domain-core module만이 아니라 package name에 `domain`이 들어가는 production classes 전체**를 subject로 한다는 점이다. 따라서 sample-portfolio domain model도 같은 purity/modeling guardrail의 대상이다. ## 7. Runtime reachability / wiring `domain-core` 자체에는 Spring bean/configuration/entry point가 없다. Registry상 `app-bootstrap`, `sample-portfolio` 두 runtime composition에 membership이 있고, concrete consumers가 compile-time type/annotation으로 이 module을 참조한다. - `ResourceId`: application-core messaging contract 및 sample IDs에서 참조 - `IdFactory`: sample factory/use-case/identifier adapter에서 참조 - `AggregateRoot`: sample aggregate에서 사용 - `DomainEvent`: sample events와 websocket broadcaster qualification에서 사용 - `ValueObject`: sample IDs/value objects에서 사용 따라서 major public abstraction이 완전히 dead/unwired인 상태는 아니다. 반대로 `domain-core`가 runtime service를 직접 수행한다는 근거도 없다. ## 8. Success / failure mechanics 이 module의 runtime executable behavior는 매우 작다. annotation 자체에는 success/failure path가 없고, interface도 implementation을 가지지 않는다. 주요 failure mechanics는 **build-time architecture violation**이다. - forbidden framework/domain dependency → `DOMAIN_IS_PURE` - domain logger dependency → `DOMAIN_HAS_NO_LOGGER` - public no-arg value object → `VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR` - public `set*` aggregate mutator → `AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC` - non-record domain event → `DOMAIN_EVENTS_ARE_RECORDS` - enumerated transport dependency → `DOMAIN_EVENTS_ARE_TRANSPORT_FREE` - `id` field raw type not assignable to `ResourceId` → `NO_LONG_ID_PK` - project dependency not in registry → `verifyCleanArchitectureDependencies` - production -> `sample-portfolio` edge → settings registry validation and root dependency verification, plus cross-module ArchUnit rule ## 9. Tests as evidence ### `:domain-core:test` 현재 module에는 executable Java test가 없으므로 이 task의 green result는 domain semantic behavior를 검증한 것이 아니라 **module compile/test task가 현재 build에서 정상 구성되고 완료됨**을 보여준다. 이번 실행 raw evidence는 `evidence/raw/003-domain-core-test.txt`에 보존했다. ### `CleanArchitectureTest` 도메인 purity와 marker-specific rules의 실제 enforcement owner다. 별도 `app-bootstrap` test task로 실행했고 **BUILD SUCCESSFUL / exit code 0**을 확인했다. raw output은 `evidence/raw/004-clean-architecture-test.txt`에 저장했다. 이 테스트는 production class import option을 사용해 `dev.caskeleton` production class graph를 분석한다. ### Sample ID tests `WorkLogIdPropertyTest`는 UUID **shape** invariant를 property-based로 검증하지만 UUIDv7 version/variant invariant는 검증하지 않는다. `UuidWorkLogIdFactoryTest`는 factory output canonical shape, pairwise distinctness, 1,000회 strict lexical monotonicity를 검증한다. 따라서 “factory가 time-ordered UUIDv7 generator를 사용한다”와 “어떤 ResourceId 입력도 v7만 허용한다”는 서로 다른 claim이다. ## 10. Explicit rationale vs inference ### 문서로 명시된 rationale - `ResourceId`를 sealed로 만들지 않은 이유: sample module을 production core가 역참조하지 않기 위해서. - ID generation 책임(contract)과 concrete generation을 분리한 이유: domain purity 유지. - stereotype annotation을 둔 이유: brittle naming convention 대신 explicit declaration을 ArchUnit 기준으로 사용. - runtime retention 이유: ArchUnit/reflection reader가 annotation을 볼 수 있게 하기 위해서. - aggregate public setter rule 한계는 의도적으로 문서화되어 있음. ### 분석 inference - 현재 domain-core는 구체 business model repository라기보다 skeleton-level domain modeling contract module의 성격이 강하다. 이는 현재 7개 production Java의 실제 내용에서 도출한 해석이다. ## 11. Improvement backlog ### P1 — UUIDv7 계약과 실제 validation의 불일치 확인/정렬 **Fact:** `ResourceId.value()`와 README는 RFC 9562 UUIDv7을 서술하지만 `WorkLogId` regex와 property test는 version/variant를 가리지 않는 모든 canonical UUID-shaped hex 문자열을 허용한다. **Why it matters:** 외부/rehydration 경로에서 `WorkLogId.of()`로 non-v7 UUID가 들어가도 domain invariant가 거부하지 않는다. 생성 adapter가 v7을 만들기 때문에 정상 create path에서 가려질 수 있다. **Verification:** `WorkLogId.of("00000000-0000-4000-8000-000000000000")`가 현재 성공하는지 focused test로 고정하고, 계약 의도가 “shape only”인지 “v7 only”인지 결정한다. **Candidate options:** 1. 계약 문서를 “canonical UUID shape”로 낮춘다. 2. value object가 UUID version 7 + RFC variant를 실제 검증하고 property test를 수정한다. **Later record candidate:** OPEN QUESTION 또는 DECISION. 의도 확인 전 자동 refactor candidate로 단정하지 않는다. ### P3 — `IdFactory.newId()`의 “never-before-used” 문구 정밀화 **Fact:** interface는 저장소 collision check를 요구하지 않고 sample test도 전역 uniqueness를 증명하지 않는다. **Why it matters:** API doc을 강한 guarantee로 읽을 가능성이 있다. **Verification:** identifier 설계 문서/역사에서 uniqueness 의미가 probabilistic UUID uniqueness인지 persistence-level uniqueness인지 확인한다. **Later record candidate:** REFERENCE 또는 OPEN QUESTION. ## 12. Limitations / exclusions - sample-portfolio business invariants 전체는 이 scope에서 분석하지 않았다. 위 consumer들은 domain-core contract의 reachability/guardrail 의미를 검증하는 데 필요한 부분만 읽었다. - `CleanArchitectureTest` 2,792라인 전체의 다른 architecture rules는 해당 future scope에서 분석한다. 여기서는 domain-core contract와 직접 연관된 rule bodies를 읽었다. - runtime composition closure 전체와 conditional startup wiring은 아직 분석하지 않았다. - 이 scope의 COMPLETE는 프로젝트 전체 COMPLETE를 의미하지 않는다. ## Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **9개** (main 5 · test 0 · 기타 4). ``` src/domain-core/build.gradle src/config/architecture/modules.json (domain-core 항목) main: src/main/java/dev/caskeleton/domain/identifier/IdFactory.java src/main/java/dev/caskeleton/domain/identifier/ResourceId.java src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java 기타: CLAUDE.md README.md src/build.gradle src/settings.gradle 해석되지 않은 인용 (4종) — 외부 타입·문서상 약칭 등: package-info.java modules.json evidence/raw/003-domain-core-test.txt evidence/raw/004-clean-architecture-test.txt ```