Files
llm-wiki/raw/branch-notes/feature-domain-modeling-guardrails.md

412 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-domain-modeling-guardrails
source_type: branch-note
status: raw
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-036
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-036
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
branch: feature-domain-modeling-guardrails
parent_branch:
governing_docs: [wiki/projects/ca-tmpl/privacy-file-domain-modeling, wiki/projects/ca-tmpl/clean-architecture-package-layout]
related_projects: [ca-skeleton]
tags: [branch, ca-skeleton, domain, modeling, guardrails]
created: 2026-05-22
target_merge:
status_label: in-progress
contract_packet_sha256: 12147734b0020a89b2ffd64b9840c0a563c7a44fa50b2c121596005623139fe7
---
# branch: feature-domain-modeling-guardrails
> Layer: `raw/branch-notes/` — domain layer가 framework와 persistence에 오염되지 않도록 modeling guardrail을 정의합니다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: domain model forbidden dependency fixture가 실패한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | domain-core의 framework·persistence 의존 금지 경계에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
도메인을 바로 얹을 수 있는 skeleton이 되려면 domain layer가 깨끗해야 합니다. entity, value object, domain service, domain event의 역할을 구분하고, framework annotation이나 persistence model이 domain으로 들어오는 것을 막습니다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- entity/value object/domain service/domain event 구분.
- domain invariant 위치.
- domain forbidden dependency.
- aggregate state mutation 기준.
- domain exception 범위.
### 제외 범위
- DDD 전술 패턴 전체 강제.
- 특정 aggregate 설계.
- business naming convention.
## TODO
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Decisionized Work Items" / "테스트 계약" 참조. entity/value object/domain service, invariant 위치, aggregate mutation, forbidden dependency, domain exception, domain event modeling 모두 표 row 또는 결정 라인으로 반영됨. 잔존 TODO 없음.
## Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
| --- | --- | --- |
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
## 진행 중 메모
- 2026-06-05 ground-truth 대조 (`/branch-spec`): ca-tmpl `domain_is_pure` ArchUnit rule (`CleanArchitectureTest.java:36-57`) 이 `..domain..` 의 Spring/JPA/Hibernate/Lombok/cross-layer import 를 금지 — **owner 는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3** (rule 의 `.as(...)` 주석에 명시). 본 branch 의 D1(framework-neutral) 은 이 rule 을 *재정의하지 않고 위임/재사용* 한다 (자세한 정합/drift 는 §Audit & Findings).
- 본 branch 의 modeling-specific guardrail (VO constructor / aggregate mutator 가시성 / logger ban / domain event) 은 모두 **코드 미존재** = `planned`. `@ValueObject`·`@AggregateRoot`·`@DomainEvent` annotation 도 `src/` grep 결과 미존재. domain-core 모듈에는 현재 `identifier/ResourceId`·`IdFactory` 만 존재.
- 2026-06-05 **C2 구현 완료** (`locally-verified`): 위 5개 modeling-specific guardrail 을 전부 구현. 자세한 구현 facts/검증/상태 전이는 §구현 기록 (2026-06-05) 참조. §진행 중 메모의 "코드 미존재" 서술은 2026-06-05 이전 ground-truth 기준이며, 현재는 §구현 기록이 최신 상태를 가진다.
## 구현 기록 (2026-06-05)
> Phase C2 실 코드 작성. ca-tmpl repo `feature-domain-modeling-guardrails` branch. 증거 등급: 아래 모두 `locally-verified` (focused gradle test + verifyCleanArchitectureDependencies 통과).
### 변경 파일
- **domain-core (신규 marker 패키지 `dev.caskeleton.domain.stereotype`)**:
- `ValueObject.java`, `AggregateRoot.java`, `DomainEvent.java``@Target(TYPE)`, `@Retention(RUNTIME)`, `java.lang.annotation` 만 의존 (framework-neutral 유지, `domain_is_pure` 통과).
- `package-info.java` — marker 의도 문서화.
- **app-bootstrap `CleanArchitectureTest.java` (신규 규칙 5종 + custom condition 2종)**:
- `domain_has_no_logger` (D3) — `..domain..``org.slf4j..`/`java.util.logging..`/`ch.qos.logback..`/`org.apache.logging.log4j..` import 금지. `domain_is_pure`**별도 규칙**(F1 owner 경계 보존).
- `value_objects_have_no_public_no_arg_constructor` (D5/D6) — `@ValueObject` OR `..domain.vo..` → public no-arg 생성자 부재. custom `notHaveAPublicNoArgConstructor()`.
- `aggregate_root_setters_are_not_public` (D7) — `@AggregateRoot``set.*` method `notBePublic()`.
- `domain_events_are_records` (D4/D8) — `@DomainEvent` 는 record. custom `beRecordTypes()` (`JavaClass.isRecord()`).
- `domain_events_are_transport_free` (D4/D8) — `@DomainEvent``org.apache.kafka..`/`org.springframework.http..`/`jakarta.ws.rs..` 의존 금지.
- **app-bootstrap violation fixtures (비공허 증명, violations-as-data)**: `violations/domain/LoggerUsingDomainFixture`, `AnnotatedPublicNoArgValueObjectFixture`, `vo/PackagePublicNoArgValueObjectFixture`, `PublicSetterAggregateFixture`, `event/{kafka,springhttp,jaxrs,nonrecord}/*Fixture` + `ArchitectureViolationFixtureTest` 에 11개 assertion(글로브별 격리 + over-block guard 2종).
- **app-bootstrap `build.gradle`**: `testCompileOnly kafka-clients`, `jakarta.ws.rs-api` (transport glob 격리 증명용, test scope).
- **sample-portfolio (positive coverage + Claims To Verify PoC)**:
- `WorkLog` `@AggregateRoot` + blank-title 불변식(`requireValidTitle``WorkLogInvariantException`).
- `Period`, `WorkLogId` `@ValueObject`.
- `WorkLogInvariantException`(+ safe `Reason` enum) — 도메인은 operational error code 모름(D2), logger 안 씀(D3).
- `WorkLogReserved`(`@DomainEvent` record, transport-free) → `application/event/WorkLogReservedIntegrationEvent` + `...Mapper` (경계 변환 PoC).
- 테스트: `WorkLogInvariantTest`, `WorkLogIdPropertyTest`(jqwik property-based), `WorkLogReservedIntegrationEventMapperTest`. `build.gradle``testImplementation net.jqwik:jqwik:1.9.1`.
### 검증 명령 / 결과
- `cd src && ./gradlew :domain-core:test :sample-portfolio:test :app-bootstrap:test verifyCleanArchitectureDependencies`**BUILD SUCCESSFUL**.
- `ArchitectureViolationFixtureTest` → tests=40, failures=0, skipped=0 (신규 11개 포함).
- `WorkLogIdPropertyTest` → jqwik property 3종 통과.
- ca-architect-sentinel 작업트리 감사 → **PASS** (FAIL/WARN 0; domain framework-neutral 유지, application.event 의 domain→application 방향만 의존, 불변식이 aggregate 안에 위치).
### 함정
- `@DomainEvent` record 의 component 로 `testCompileOnly` transport type 을 두자 JUnit **test discovery** 가 통째로 실패(`ClassSelector resolution failed`). record component = canonical ctor 시그니처라 reflective discovery 가 즉시 resolve. method body `.class` 참조 + subpackage `importPackages` 로 회피. → [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] 2026-06-05 addendum.
### 상태 전이 (planned → locally-verified)
- D3 logger ban, D5/D6 VO 불변식, D7 aggregate mutator, D4/D8 domain event(record + transport-free): `planned``locally-verified`.
- §Claims To Verify 의 VO property / aggregate set* / logger import / transport-free mapping 항목: `planned``locally-verified` (PoC 코드 + 테스트 존재).
- Greg Young/Vernon paraphrased 근거 검증(외부 원전 대조)은 여전히 `needs-confirmation` — 코드 구현과 무관하게 미해결.
## 결정 사항
- 2026-05-22: domain은 Spring/JPA/HTTP/Security/Logging type을 알지 않음.
- 2026-05-22: domain exception은 business invariant만 표현하고 operational error code를 직접 알지 않음.
- 2026-05-22: domain logger는 금지. invariant 위반 사유는 domain exception의 safe reason enum/value로 표현하고 application layer가 로그로 번역.
- 2026-05-22: domain event는 transport-free fact만 표현하고 integration event mapping은 application/infrastructure 경계에서 수행.
## 판정 기준
| 구분 | 기준 |
| --- | --- |
| Decision | domain model은 framework-neutral pure model로 유지 |
| Allowed | domain event/value object 내부의 순수 validation |
| Forbidden | `@Entity`, `@Service`, HTTP/JPA/Security/Logger import |
| Required checks | forbidden import, public mutable state, domain-to-response direct exposure |
| Failure condition | domain이 infrastructure/presentation/application response type을 알면 실패 |
## Decisionized Work Items
| item | Decision | Allowed | Forbidden | Required test |
| --- | --- | --- | --- | --- |
| entity/value object | pure domain types only | immutable helper libraries | JPA entity as domain | forbidden import test |
| invariant | value object/entity constructor/factory | application pre-check for UX | DB-only invariant | invalid state test |
| mutation | aggregate method controls state | package-private constructor for ORM outside domain model | public mutable fields | mutation test |
| diagnostics | safe reason enum, application logs | no reason for security-sensitive cases | domain logger | logger import test |
| domain event | transport-free fact | internal-only event | Kafka/HTTP/Slack detail | event model test |
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지 (D1~D8). Decisionized Work Items 표 row 와 1:1 매핑.
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|
| D1 | domain 은 Spring/JPA/HTTP/Security/Logging type 을 알지 않음 (framework-neutral) | `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C1`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5`, `raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog.md#WOOWA-HEX-C2` | `engineering-blog + company-case-study` | Fowler bliki 는 `engineering-blog` 등급 (개인 블로그, `official-vendor-doc` 격상 금지). Logger ban 의 직접 출처 부재 — FOWLER-ANEMIC-C5 "validations/calculations/business rules" 에서 도출 가능하나 약함 |
| D2 | domain exception 은 business invariant 만 표현, operational error code 를 직접 알지 않음 | `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5`, `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C2` | `engineering-blog + needs-confirmation` | VERNON-AGG-C2 는 paraphrased (`needs-confirmation`) — PDF 본문 verbatim 미확보. "operational error code 와 domain exception 의 분리" 직접 출처 부재 |
| D3 | domain logger 금지, invariant 위반 사유는 safe reason enum/value 로 표현 후 application layer 가 로그로 번역 | UNSUPPORTED_DECISION | (Fowler/Vernon 모두 logger ban 명시 부재 — FOWLER-ANEMIC-C5 의 "domain logic = validations/calculations/business rules" 에서 logger 부재가 도출되나 직접 인용 아님) | Logger ban 의 공식 표준 출처 없음 — ca-tmpl 자체 결정. "safe reason enum" 패턴의 reference 부재 |
| D4 | domain event 는 transport-free fact 만 표현, integration event mapping 은 application/infrastructure 경계에서 수행 | `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C4`, `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C5` | `needs-confirmation + needs-confirmation` (paraphrased) | GY-CQRS-C4 는 `needs-confirmation` (Greg Young PDF 검증 실패, Confluent corroborate 만). VERNON-AGG-C5 도 paraphrased — transport-free 의 ca-tmpl 정의는 자체 차용 |
| D5 | entity / value object 는 pure domain types only, JPA entity 를 domain 으로 두지 않음 (Vernon Option A) | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C6`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C3`, `raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog.md#WOOWA-HEX-C2` | `needs-confirmation + engineering-blog + company-case-study` | VERNON-AGG-C6 paraphrased (`needs-confirmation`). 우아한형제들 사례는 Option A (POJO domain) 와 Option B (JPA in domain) 모두 보이는 vendor-specific — Vernon 의 Option A/B 분리 자체는 본 Claim 으로 직접 증명 안 됨 |
| D6 | invariant 는 value object/entity constructor/factory 에 위치, DB-only invariant 금지 | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C2`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5` | `needs-confirmation + engineering-blog` | VERNON-AGG-C2 paraphrased — "single transaction" 의 의미가 "constructor invariant" 와 정확히 매핑되는지 PDF verbatim 확인 필요 |
| D7 | aggregate mutation 은 root method 만 controls, public mutable field 금지, ORM 외부 매핑 (Option A) 으로 package-private constructor 사용 | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C6` | `needs-confirmation` (paraphrased — IDDD Ch.10 도서 인용, 페이지/문단 미지정) | VERNON-AGG-C6 verbatim 미확보. "package-private/protected" 가 Java 외 다른 JVM 언어 (Kotlin `internal`) 에 매핑되는지 별도 검증 필요 |
| D8 | domain event modeling 은 internal-only event 허용, Kafka/HTTP/Slack detail 금지 | `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C4`, `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C3` | `needs-confirmation` (Greg Young PDF 미검증) | "transport-free" 의 ca-tmpl 정의는 차용 (GY-CQRS-C4 Does not prove: transport-free 가능성 명시 부재) — 직접 출처 없음 |
## 구현 가이드
> *결정* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*". 본 branch 의 modeling guardrail 은 전부 `planned` (코드 미존재) 이므로, 아래는 C2 진입 시 *되묻지 않고 작성할 수 있는* 사전 명세다. anchor 는 §진행 중 메모 / §Audit 에서 확인한 *실제* ca-tmpl 구조(`domain_is_pure`, domain-core 모듈, `feature-architecture-enforcement-rules` owner)에 정합시킨다.
> 3-rule (CLAUDE.md §15.5): 각 cell 은 Decision ID + Supporting Claim ID reference (R1) / 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄 (R2) / 범위 밖은 §Audit 으로 이관 (R3).
### 1. 도메인 순수성 — 기존 rule 위임 (재정의 금지)
> **Trace**: D1 ↔ `FOWLER-ANEMIC-C1/C5`, `WOOWA-HEX-C2`. 단, 정적 강제의 **owner 는 본 branch 가 아님**.
>
> - **OUT_OF_BRANCH_SCOPE (위임)**: framework-neutral 정적 강제(`..domain..` 의 Spring/JPA/Hibernate/Lombok import 금지)는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 의 `domain_is_pure` (`CleanArchitectureTest.java:36-57`, `actually-implemented`) 가 소유. 본 branch 는 이 rule 을 **재정의/복제하지 않고** 모델링 결정의 전제로 *위임 참조*. 본 branch 가 추가하는 것은 아래 2~5 의 modeling-specific rule 뿐.
| 항목 | owner | 상태 | anchor |
|---|---|---|---|
| `..domain..` Spring/JPA/Hibernate/Lombok/cross-layer import 금지 | [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 | `actually-implemented` | `domain_is_pure` (`CleanArchitectureTest.java:36`) |
| controller 가 domain/entity 타입 직접 반환 금지 | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | `actually-implemented` | `controllers_do_not_return_domain_or_entity_types` (`CleanArchitectureTest.java:321`) |
> **Option A vs B 선택 근거는 추측이 아니라 코드로 증명된다 (D5·D7 강화)**: Vernon Option B(domain class 에 `@Entity`/JPA annotation 직접 부착)는 domain 패키지에 `jakarta.persistence..` import 를 유발한다. 이는 `domain_is_pure` 의 forbidden list (`CleanArchitectureTest.java:40-42` — `jakarta.persistence..`·`javax.persistence..`) 에서 **자동 위반**되어 빌드가 깨진다 (`actually-implemented`). 따라서 ca-tmpl 에서 Option A(ORM 외부 매핑)는 *선호*가 아니라 기존 정적 강제의 **논리적 귀결** — Option B 는 코드 레벨에서 이미 금지됨. 이 체인이 VERNON-AGG-C6 의 paraphrased 약점(도서 페이지 미확보)을 코드 ground-truth(L2)로 보완한다.
### 2. 도메인 logger ban 정적 강제 (D3)
> **Trace**: D3 (`UNSUPPORTED_DECISION` — logger ban 의 공식 출처 없음, ca-tmpl 자체 결정).
>
> - **GAP / `STALE_OWNER` 위험**: 코드 확인 결과 `domain_is_pure` 의 forbidden 목록에 **logging framework 가 없다** (`org.slf4j`·`java.util.logging`·`ch.qos.logback`·`org.apache.logging.log4j` 모두 미포함; test 파일 전체 grep 상 logger ban rule 부재). 따라서 "domain 이 Logger import 시 ArchUnit 실패" 는 현재 `planned` 이며 **어떤 rule 도 강제하지 않음**.
> - **근거 등급 확정 (되묻기 방지)**: logger ban 의 *공식 표준 출처는 존재하지 않는다* — 이는 clean-architecture 통념이지 official standard 가 아니다 (D3 `UNSUPPORTED_DECISION` 유지). 구현자는 "공식 근거를 더 찾아라"가 아니라 **ca-tmpl 자체 규약으로 확정하고 착수**한다. 사실 등급은 격상하지 않으며, 외부(면접/README)에서 "표준이라 막았다"고 말하지 않는다.
> - **PRE-DECISION (메커니즘 확정)**: 별도 rule **`domain_has_no_logger` 신설** (owner = 본 branch). `domain_is_pure` forbidden list 확장(대안)을 *택하지 않는* 이유는 코드 근거가 있다 — `domain_is_pure` 의 owner 는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 (`CleanArchitectureTest.java:54-56` `.as(...)` 명시, §Audit F1). 그 list 에 logger 를 끼우면 *본 branch 의 결정이 타 branch owner rule 에 섞여* owner 경계가 깨진다(F1 회피). 별도 rule 은 위반 메시지도 "domain logger 금지(D3)"로 명확. → trade-off 가 아니라 owner-boundary 로 강제됨.
| 강제 대상 | 메커니즘(제안) | 상태 |
|---|---|---|
| `..domain..``org.slf4j..`·`java.util.logging..`·`ch.qos.logback..`·`org.apache.logging.log4j..` import | 신규 rule `domain_has_no_logger` (owner = 본 branch; forbidden list 확장 아님 — F1 owner 경계 보존) | `planned` |
| invariant 위반 사유 = safe reason enum/value (noun 형태), 로그 번역은 application layer | domain exception 의 reason enum 필드 + application 에서 error.category 매핑 | `planned` |
### 3. Value Object invariant 강제 (D5·D6)
> **Trace**: D5 ↔ `VERNON-AGG-C6`·`FOWLER-ANEMIC-C3`·`WOOWA-HEX-C2`, D6 ↔ `VERNON-AGG-C2`·`FOWLER-ANEMIC-C5`.
>
> - **PRE-DECISION (탐지 기준·명명 확정)**: annotation `@ValueObject` 를 **primary marker**, `..domain.vo..` package convention 을 **fallback**(annotation 미부착 VO 도 포착)으로 *둘 다* 사용 — ArchUnit rule 의 `.areAnnotatedWith(...).or().resideInAPackage(...)` 가 양쪽을 OR 로 묶으므로 둘 중 택일이 아니라 합집합이 자연스럽다. annotation 패키지는 `dev.caskeleton.domain.stereotype` (domain-core 신규 marker 패키지; 현재 domain-core 는 `identifier` 패키지만 보유 → marker 패키지 신설). 근거 raw(Vernon/Fowler)는 *invariant 위치*만 권고하고 명명은 권고 안 하므로 `@ValueObject`·`stereotype` 명칭은 ca-tmpl 임의 — 사실 등급 비격상, 코드 미존재이므로 `planned`.
| 강제 대상 | 메커니즘(제안) | 상태 |
|---|---|---|
| `@ValueObject` 또는 `..domain.vo..` 의 record/class 에 public no-arg constructor 부재 | `classes().that().areAnnotatedWith(ValueObject.class).or().resideInAPackage("..domain.vo..").should().notHaveAccessibleNoArgConstructor()` | `planned` |
| 모든 VO constructor 가 invalid input 에 domain exception/`IllegalArgumentException` throw | property-based test (jqwik) — null/empty/boundary × N | `planned` |
| `@ValueObject` annotation 신설 | `dev.caskeleton.domain.stereotype.ValueObject` (domain-core 신규 marker 패키지) | `planned` (annotation 미존재) |
### 4. Aggregate root mutator 가시성 (D7)
> **Trace**: D7 ↔ `VERNON-AGG-C6` (`needs-confirmation` — IDDD Ch.10 페이지 미지정).
>
> - **PRE-DECISION (탐지 범위 확정)**: ArchUnit 정적 강제 범위 = **`set.*` prefix method 만** (`notBePublic()`). 이유: ca-tmpl 은 현재 Java-only (`src/` 전부 `.java`) 이므로 Kotlin `internal`/`copy()`·record wither 우회는 *지금 범위 밖*(D7 Open Risk 로 보존, Kotlin 도입 시 재검토). `set.*` 외의 state-changing method(예: `applyXxx`, `markAsXxx`)는 ArchUnit 로 일반 강제가 불가능 → 코드리뷰 + 네이밍 컨벤션으로 보완(정적 강제 아님 명시). annotation 패키지는 §3 과 동일하게 `dev.caskeleton.domain.stereotype.AggregateRoot`. `@AggregateRoot` 명명 ca-tmpl 임의(코드 미존재, `planned`).
| 강제 대상 | 메커니즘(제안) | 상태 |
|---|---|---|
| `@AggregateRoot` class 의 `set*`/state-changing method 가 public 아님(package-private/protected) | `methods().that().haveNameMatching("set.*").and().areDeclaredInClassesThat().areAnnotatedWith(AggregateRoot.class).should().notBePublic()` | `planned` |
| ORM 재구성용 constructor 가시성 = package-private (Vernon Option A, ORM 외부 매핑) | persistence mapper 가 domain 밖에서 재구성 (`WorkLog``WorkLogJpaEntity`) | `planned` |
| `@AggregateRoot` annotation 신설 | `dev.caskeleton.domain.stereotype.AggregateRoot` | `planned` (annotation 미존재) |
### 5. Domain event transport-free 모델링 (D4·D8)
> **Trace**: D4 ↔ `GY-CQRS-C4`·`VERNON-AGG-C5` (둘 다 `needs-confirmation`), D8 ↔ `GY-CQRS-C3/C4`.
>
> - **근거 등급 확정 (되묻기 방지)**: "transport-free fact" 라는 *명칭/정의*는 ca-tmpl 차용이며 Greg Young 원전이 직접 보장하지 않는다(GY-CQRS-C4 `needs-confirmation`, D8 Open Risk). 구현자는 이 명칭의 출처를 더 추적하지 않는다 — **보수적 기본값으로 확정 후 착수**. 사실 등급 비격상.
> - **PRE-DECISION (경계 확정, 코드로 부분 강제됨)**: domain event 는 `..domain..` 의 immutable record 로 두고 integration event 변환은 application/adapter 경계의 mapper 책임. 이 경계는 *추측이 아니라 부분적으로 코드로 강제된다* — `domain_is_pure` 가 `..domain..` → `..adapter..` import 를 금지(`CleanArchitectureTest.java:47`)하므로, domain event 가 adapter 의 integration-event/transport 타입을 참조하면 자동 위반(`actually-implemented`). 단, Kafka/HTTP 클라이언트 SDK 패키지(`org.apache.kafka..` 등)는 현재 forbidden list 에 없으므로 *그 한 가지*는 본 branch 의 `domain_has_no_logger` 와 같은 추가 rule 또는 코드리뷰로 보완 (`planned`).
| 강제 대상 | 메커니즘(제안) | 상태 |
|---|---|---|
| domain event = immutable record, transport(Kafka/HTTP/Slack) 필드 부재 | `@DomainEvent` record + ArchUnit forbidden import — 금지 패키지: `org.apache.kafka..`(Kafka SDK), `org.springframework.http..`/`jakarta.ws.rs..`(HTTP), 슬랙 등 outbound client SDK. **UNSUPPORTED_IMPL_DECISION**: broker/transport 추가 시 목록 갱신 필요(현재 ca-tmpl 미사용 SDK 는 미열거) | `planned` |
| integration event 변환은 application/infrastructure 경계 | application mapper: `WorkLogReserved`(domain) → `WorkLogReservedIntegrationEvent`(application) → publish(infra) | `planned` |
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 본 branch 의 modeling guardrail 이 구현 중 부딪힐 실패/엣지/계약 의존.
- **실패·엣지 경로**:
- **ORM 재구성이 invariant 를 우회** — package-private/no-arg constructor 를 ORM(Hibernate) 이 reflection 으로 호출해 객체를 만들 때 constructor invariant 가 *호출되지 않을 수 있음*. 기대 동작: ORM 재구성은 *이미 valid 한 영속 상태*에서만 일어난다는 전제 + 매핑은 domain 밖 mapper 책임(Vernon Option A). VO no-arg constructor 금지 rule 과 ORM 요구의 충돌은 "ORM 외부 매핑"으로 회피.
- **Kotlin `data class` `copy()` 우회** — copy() 가 constructor invariant 를 호출하지 않으면 invalid VO 생성 가능. 기대 동작: D7 Open Risk 로 이미 기록 — JVM 언어별 검증 필요.
- **safe reason enum 의 정보 노출** — security-sensitive invariant 위반 사유를 enum 으로 노출하면 client 에 단서 제공 가능. 기대 동작(Decisionized Work Items): security-sensitive case 는 reason 제공 안 함, application 이 일반화된 error.category 로만 번역.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-architecture-enforcement-rules]] 의 `domain_is_pure` (D3) 에 의존 — domain framework-neutrality 의 정적 강제 owner. 이 rule 의 forbidden list/package 패턴이 바뀌면 본 branch 의 D1 전제가 흔들린다.
- operational error code SSOT = `feature-operational-error-observability-foundation` + `docs/registries/error-codes.yaml`. safe reason enum → error.category 번역은 그 계약을 consume (domain 은 operational code 를 직접 알지 않음 = D2).
- persistence 매핑(Vernon Option A) → `feature-boundary-validation-mapping-contract` / persistence adapter 의 mapper 계약에 의존.
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트의 실 동작을 자동으로 보장하지 않음.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| `..domain.vo..` package 의 모든 record/class 가 public no-arg constructor 없이 invariant 강제 가능 | VERNON-AGG-C2 paraphrased — VO 의 constructor invariant 가 모든 valid input 에서 작동하는지 property-based test 필요 | sample feature 의 VO 1개에 jqwik property-based test 적용 → null/empty/invalid input × N 종 자동 생성 → exception 확인 | `planned` |
| `@AggregateRoot` annotated class 의 모든 `set*` method 가 package-private/protected 이며 invariant 호출 포함 | VERNON-AGG-C6 paraphrased — ORM-friendly constructor 가시성의 verbatim 미확보 | ArchUnit `methodsThat().haveName("set.*").andAreDeclaredInClassesThat().areAnnotatedWith(@AggregateRoot.class).should().notBePublic()` 작성 + 위반 케이스 테스트 | `planned` |
| domain class 가 Logger import 시 ArchUnit 이 실패시킨다 | D3 UNSUPPORTED — logger ban 의 공식 출처 부재 | ArchUnit forbidden import test 작성 (slf4j, logback, log4j 모두 포함) → sample domain 에 임시 logger 추가 시 실패 케이스 capture | `planned` |
| Vernon Option A (domain ↔ JpaEntity 외부 매핑) 가 Option B (domain 에 JPA annotation) 보다 ca-tmpl 의 forbidden import 규칙과 더 정합 | VERNON-AGG-C6 paraphrased + 우아한형제들 WOOWA-HEX-C2 (Option A) + Option B reference 부재 | sample-portfolio 에 WorkLog(domain) ↔ WorkLogJpaEntity(infrastructure) 분리 PoC + MapStruct 매핑 → ArchUnit forbidden import test 통과 확인 | `needs-confirmation` |
| domain event 가 transport-free 로 정의되어도 application/infrastructure 경계에서 integration event 변환 가능 | D4 paraphrased only — Vernon eventual consistency / Greg Young event immutability 만 근거, transport mapping 패턴 직접 출처 부재 | sample feature 에 `WorkLogReserved` (domain event) → `WorkLogReservedIntegrationEvent` (application mapper) → Kafka publish (infrastructure) 흐름 PoC | `planned` |
| 한국 백엔드 현장에서 Spring 기본 튜토리얼이 anemic default 라는 메모가 ca-tmpl 강제 결정의 정당화에 충분 | FOWLER-ANEMIC-C2 의 일반 명제만 있고 "한국 현장 관찰" 의 별도 출처 없음 (메모) | 별도 raw 자료 (Inflearn / 김영한 강의 / 우아한형제들 hands-on) 의 default 패턴 추출 후 ingest | `planned` |
| Greg Young / Vernon 의 paraphrased claim 들이 PDF / IDDD 원전과 일치 | GY-CQRS-C1~C4, VERNON-AGG-C2~C6 모두 `needs-confirmation` | (a) Greg Young CQRS PDF 재페치 시도 (대안: archive.org / Fowler bliki cross-check) (b) IDDD Ch.10 도서 인용 페이지/문단 명시 추가 | `needs-confirmation` |
## 테스트 계약
- domain package가 Spring/JPA/HTTP/security/logging package를 import하면 실패.
- VO invalid state 검사: 모든 `@ValueObject` annotation이 붙은 class 또는 `features.*.domain.vo.` package의 record/class는 다음을 만족: (a) public no-arg constructor 없음 (b) 모든 constructor에서 invariant violation 시 `IllegalArgumentException` 또는 domain exception throw. 측정 방법: ArchUnit `classes().that().areAnnotatedWith(@ValueObject.class).or().resideInAPackage("..domain.vo..").should().notHaveAccessibleNoArgConstructor()` + property-based test on each VO with null/empty/invalid input → exception expected.
- aggregate mutation 검사: `@AggregateRoot` annotation이 붙은 class의 모든 mutator method (`set*` prefix 또는 state-changing method)는 (a) public이 아닌 package-private 또는 protected이고 (b) invariant 검증 로직 포함. 측정 방법: ArchUnit `methodsThat().haveName("set.*").andAreDeclaredInClassesThat().areAnnotatedWith(@AggregateRoot.class).should().notBePublic()`. setter가 public이거나 invariant 호출 없이 state 변경 시 fail.
- domain package가 Logger 또는 operational error code를 직접 알면 실패. (rule: `domain_has_no_logger`, D3 — owner = 본 branch. §구현 가이드 §2 참조. `domain_is_pure` 와 별개 rule)
## 근거 (필수, 최소 1개+)
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] | Vernon "Effective Aggregate Design" 4 rules + small aggregate + ORM-friendly constructor (ca-tmpl Option A 채택 |
| [[raw/official-docs/domain-fowler-anemic-vs-rich-model]] | Fowler "Anemic Domain Model" anti-pattern (rich model 강제의 reference |
| [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]] | 우아한형제들 초기 글 사례; ca-tmpl forbidden import 규칙 위배라 거부 |
| [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]] | Greg Young; ca-tmpl 미채택, "transport-free fact" 정의만 차용 |
| [[raw/official-docs/cqrs-fowler-bliki]] | CQRS command/query 모델 분리 정의 + Fowler 의 "very cautious" 보수적 권고 (Fowler martinfowler.com bliki — `engineering-blog` 등급, `official-standard` 아님). ca-tmpl 의 command/query use case 분리 (Out of scope: read/write 데이터 모델 분리) 의 대비 reference. ca-tmpl 은 CQRS-FOWLER-C3 (개념 모델 분리) 만 차용, CQRS-FOWLER-C5/C6 (cautious + complexity) 에 따라 read/write 저장소 분리는 미채택 |
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-J: Domain Modeling Guardrails)
본 branch의 VO with private constructor + aggregate root mutator package-private/protected + domain logger ban + safe reason enum + invariant in constructor + ORM 외부 매핑 결정에 대한 외부 source.
- **채택 결정 (Rich domain model + Vernon Aggregate Root Option A: ORM 외부 매핑)**:
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] — Vernon "Effective Aggregate Design" 4 rules + small aggregate + ORM-friendly constructor (ca-tmpl Option A 채택)
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]] — Fowler "Anemic Domain Model" anti-pattern (rich model 강제의 reference)
- **검토한 대안**:
- **대안 1: Anemic domain model** — `domain-fowler-anemic-vs-rich-model` 동일 source에서 anti-pattern으로 정의 (ca-tmpl 거부)
- **대안 2: Vernon Option B (JPA direct annotation in domain)** — [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]] (우아한형제들 초기 글 사례; ca-tmpl forbidden import 규칙 위배라 거부)
- **대안 3: Event sourcing 전환 (domain events as state)** — [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]] (Greg Young; ca-tmpl 미채택, "transport-free fact" 정의만 차용)
- **대안 4: CQRS with separate read/write models** — 동일 Greg Young source (ca-tmpl 미채택, read 분리 없이 단일 model 유지)
- **대안 5: Functional domain modeling (Scala/F#)** — JVM이지만 패러다임 차이 + 팀 학습 비용 큼
- **비교 핵심**: ca-tmpl rich model은 Fowler/Vernon reference standard 정합. ORM 외부 매핑(Vernon Option A)이 forbidden import 규칙(domain logger/JPA ban)과 정합 — 우아한형제들 Option B는 same regulation 위배라 거부. Event sourcing/CQRS는 모델 자체 교체로 scope 다름, ca-tmpl은 "transport-free fact" 정의만 차용.
## 완료 후 wiki 추출 대상
- `wiki/projects/ca-skeleton-operational-contract.md`의 domain modeling canonical section.
## Audit & Findings
> 2026-06-05 `/branch-spec` ground-truth 대조 (ca-tmpl `src/` + `CleanArchitectureTest.java`) 에서 발견한 정합/drift. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 *정합 권고만* 기록.
| ID | 유형 | 발견 | 권고 |
|---|---|---|---|
| F1 | OWNERSHIP | D1(domain framework-neutral) 의 정적 강제 `domain_is_pure` 는 본 branch 가 아니라 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 가 owner (`CleanArchitectureTest.java:36-57` `.as(...)` 주석 명시) | D1 은 본 branch 가 *복제/재정의하지 않고 위임*. §Coverage 에 `delegated` 로 표기 (완료) |
| F2 | GAP (logger ban 미강제) | D3(domain logger ban) — `domain_is_pure` forbidden list 에 logging framework 미포함 (`org.slf4j`·`java.util.logging`·`logback`·`log4j` 부재; test 전체 grep 상 logger ban rule 없음) | logger ban 은 현재 `planned`, 코드 미강제. C2 에서 별도 rule 또는 forbidden list 확장 필요 (§구현 가이드 2). "구현됐다" 로 말하면 안 됨 |
| F3 | NOT-IMPLEMENTED | `@ValueObject`·`@AggregateRoot`·`@DomainEvent` annotation 모두 `src/` grep 미존재. domain-core 모듈은 `identifier/ResourceId`·`IdFactory` 만 보유 | D5/D6/D7/D8 의 annotation-기반 ArchUnit rule 은 전부 `planned`. Claims To Verify 의 `planned` 표기와 일치 (정합 OK) |
| F4 | SCOPE 확인 | D2(domain exception 이 operational error code 를 직접 모름) 의 SSOT 는 `feature-operational-error-observability-foundation` + `error-codes.yaml` | safe reason enum → error.category 번역은 그 계약 consume. 본 branch 는 *domain 측 금지*만 소유, code enum 신설은 범위 밖 |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> `/coverage` 가 채우는 **생성물** — 손유지 금지. 기준: `rules/coverage-gate.md`. governing_docs: `privacy-file-domain-modeling` (§"Domain Modeling") + `clean-architecture-package-layout` (domain purity).
> 마지막 감사: 2026-06-05 `/branch-spec` 인라인 (정식 `coverage-auditor` 판정은 §8b 에서).
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| VO private constructor + factory, invariant in constructor | covered-here | — | — | D5·D6 (§구현 가이드 3, `planned`) |
| aggregate root mutator non-public (package-private/protected) | covered-here | — | — | D7 (§구현 가이드 4, `planned`) |
| domain layer logger ban | covered-here | — | 🟡 (F2 GAP) | D3 (`planned`, 코드 미강제 — §구현 가이드 2) |
| safe reason enum (거부 사유 noun enum, application 이 로그 번역) | covered-here | — | — | D3·D2 |
| Vernon Option A (ORM 외부 매핑) 채택, Option B 거절 | covered-here | — | — | D5·D7 |
| domain event = transport-free fact, integration mapping 은 경계 | covered-here | — | — | D4·D8 |
| domain framework-neutral (no Spring/JPA/Hibernate) 정적 강제 | delegated | [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 | — | owner `actually-implemented` (`domain_is_pure`, `CleanArchitectureTest.java:36`) |
| controller 가 domain/entity 타입 직접 반환 금지 | delegated | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | — | owner `actually-implemented` (`controllers_do_not_return_domain_or_entity_types`) |
## 마주친 문제
- 아직 없음(문서 단계).
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]]
- [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]]
- [[raw/official-docs/cqrs-fowler-bliki]]
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]]
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]]
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: interviews:start -->
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]]
<!-- GENERATED: interviews:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]]
<!-- GENERATED: blog-topics:end -->
> 2026-06-05 Phase C2 구현으로 파생 자료 누적. 아래 derived note 들과 양방향 link 유지.
### 오류 기록 (본 feature 작업 중 발생)
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] — 2026-06-05 addendum: `@DomainEvent` record component 로 `testCompileOnly` 타입을 두면 JUnit discovery 가 죽음. method body `.class` 참조 + subpackage `importPackages` 로 회피 (4번째 패턴).
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]] — stereotype 마커 + ArchUnit fitness function, owner 경계, logger ban 정직성, jqwik 불변식 검증, transport-free 이벤트.
### Blog topics (구현·트러블슈팅 글감)
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]] — DDD 전술 패턴을 빌드 깨짐으로 강제하기.
## 관련 일일 노트
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- (Phase E 외부 근거 / 대안 조사 단계 — daily note 미연결. C2 구현 진입 시 작업일 추가)
## 완료 후 정리
> 머지/종료 시점에 채움.
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목:
- `locally-verified` 항목:
- `prod-verified` 항목:
- **추출하지 않을 항목** (planned / documented-only / abandoned):