The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
17 KiB
Markdown
261 lines
17 KiB
Markdown
# 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<SELF>`
|
|
|
|
`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<T extends ResourceId<?>>`
|
|
|
|
`IdFactory`는 `newId()` 하나를 가진다. sample에서는 `WorkLogIdFactory extends IdFactory<WorkLogId>`로 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
|
|
|
|
```
|