도메인 메서드 계약¶
상태: 핵심 계약 구현·회귀 검증 완료 · 2026-09-03 전환 결정 이력: ADR 0002 물리 모델: 목표 임상 데이터 ERD
1. 목적¶
이 문서는 테이블이나 화면이 아니라 어떤 객체가 어떤 판단을 소유하는지를 고정한다. Catalog·Safety·Recommendation·Knowledge·Guidance·Glossary·Publication의 순수 domain과 Publication/Retrieval application·persistence가 이 경계를 기준으로 구현됐다.
- legacy 변환은
backend/app/importing과 Safety adapter가 소유한다. - Safety domain은
allowed | conditional | prohibited만 판단한다. - Recommendation은 safety 결정을 받아 노출 가능 여부를 판단한다.
- API의
needs_confirmation | restricted | indeterminate는 presenter/application 용어다. - ORM row와 Pydantic schema는 domain entity가 아니다.
2. 메서드 배치 기준¶
| 위치 | 둘 조건 | 예시 |
|---|---|---|
| Entity/Aggregate method | 자신의 상태만으로 불변조건과 상태 변화를 결정 | package.add_item(...) |
| Domain policy | 둘 이상의 entity·aggregate snapshot을 함께 판단하지만 I/O 없음 | safety_policy.evaluate(...) |
| Application use case | repository, transaction, 권한, clock, 외부 작업 조율 | publish_release.handle(...) |
| Query service | mutation 없이 projection·Context Pack 생성 | retrieve_context.execute(...) |
| Adapter | JSON/XLSX/Pydantic/SQLAlchemy/외부 API 형식 변환 | legacy_constraint_mapper.to_domain(...) |
규칙:
- domain method 이름에
save,update,set_data,process를 사용하지 않는다. - public method는 업무 행위를 표현한다:
approve,add_alias,change_interval,publish. - entity는 SQLAlchemy session, repository, FastAPI, Pydantic, JSON 파일을 알지 못한다.
- mutation method는 성공 시 상태를 바꾸고
None또는 명시적 domain result를 반환한다. - 조회·판단 method는 immutable value object를 반환한다.
- published 데이터는 제자리 수정하지 않는다. 변경은 successor revision에서 한다.
- DB unique/FK와 domain validation을 함께 둔다. 한쪽만으로 불변조건을 보장하지 않는다.
구현은 backend/app/modules/의 도메인별 모듈에 둔다. 규칙이 있는 객체만 aggregate로
만들고, 동작 없는 master·association은 별도 domain class를 복제하지 않는다. SQLAlchemy
*Row와 aggregate의 변환은 repository 구현이 소유한다. 파일은 여러 변경 책임이 실제로
생길 때 분리한다.
Command는 aggregate와 policy로 변경을 검증하고, 조회는 필요한 SQL projection을 직접
조립한다. 읽기 위해 수정 가능한 aggregate graph 전체를 복원하지 않는다. 기존
@transactional 경계를 재사용하며 별도 Unit of Work 프레임워크를 추가하지 않는다.
3. 공통 Value Object와 오류¶
3.1 Value Object¶
| 타입 | 생성 시 검증 | 사용처 |
|---|---|---|
PublicCode |
형식, 대문자 canonicalization | M/P code, product/complication stable code |
LocalizedText |
language, 공백 제거, 빈 값 금지 | 명칭·설명·안내 |
ProcedurePair |
서로 다른 canonical concept, 요청 방향 보존 | safety·synergy·recipe |
IntervalDays |
0 <= min <= max, max nullable |
간격·회복·care plan |
MoneyRange |
currency, 0 <= min <= max |
가격 관측 |
ContentHash |
알고리즘 prefix와 digest 형식 | import·revision dedup |
SourceRef |
source ID와 locator | claim·constraint 근거 |
ActorRef |
비어 있지 않은 내부 actor 식별자 | 승인·발행·감사 |
3.2 Domain 오류¶
| 오류 | 의미 | HTTP 매핑 위치 |
|---|---|---|
InvariantViolation |
값·구성 불변조건 위반 | API adapter에서 400 |
InvalidStateTransition |
현재 상태에서 행위 불가 | API adapter에서 409 |
PolicyRejected |
여러 객체를 평가한 결과 행위 차단 | API adapter에서 409 |
AmbiguousIdentity |
alias가 둘 이상의 canonical ID 후보 | API adapter에서 422/clarification 응답 |
domain 오류는 HTTP status를 속성으로 갖지 않는다.
4. Catalog Context¶
4.1 ProcedureConcept aggregate¶
소유: canonical identity, 한국어 기본명, parent reference, alias collection, active 상태.
ProcedureConcept.register(
concept_id, public_code, concept_type, name_ko, parent_id=None
) -> ProcedureConcept
concept.rename(name_ko) -> None
concept.add_alias(alias: ProcedureAlias) -> None
concept.remove_alias(alias_key: AliasKey) -> None
concept.move_under(parent_id, parent_lineage: ConceptLineage) -> None
concept.activate() -> None
concept.deactivate() -> None
| 메서드 | 전제·불변조건 | 상태 변화/결과 |
|---|---|---|
register |
유효한 code/type/name, 자기 parent 금지 | 새 active concept |
rename |
빈 명칭 금지 | 기본명 변경 |
add_alias |
aggregate 내부 (language, normalized_alias) 중복 금지 |
alias 추가 |
remove_alias |
존재하는 alias만 제거 | alias 제거 |
move_under |
자기 자신·자손 아래로 이동 금지 | parent 변경 |
deactivate |
local 상태만 변경; 참조 사용 여부는 application policy가 선검사 | inactive |
전역 public code/alias unique는 CatalogIdentityPolicy가 repository snapshot을 받아 판단한다.
CatalogIdentityPolicy.ensure_code_available(code, matches) -> None
CatalogIdentityPolicy.resolve(term, language, candidates) -> ResolutionResult
CatalogRetirementPolicy.ensure_deactivatable(reference_summary) -> None
resolve는 resolved | ambiguous | not_found 중 하나를 반환하며 임의로 첫 후보를 고르지 않는다.
4.2 Product aggregate¶
소유: 제품 identity, 제조사 reference, 성분/재질, 국가별 허가 상태 collection.
Product.register(product_id, public_code, name_ko, manufacturer_id=None) -> Product
product.rename(name_ko) -> None
product.change_composition(ingredient=None, material=None) -> None
product.record_regulatory_status(status: RegulatoryStatus) -> None
product.retire_regulatory_status(country_code, authority, reason) -> None
product.activate() -> None
product.deactivate() -> None
동일 국가·기관에 활성 허가 상태가 둘 이상 존재할 수 없다. ProcedureProduct는 두 aggregate 사이의 관계이므로 product.link_procedure()로 숨기지 않고 application use case가 별도 relation을 관리한다.
4.3 Concern aggregate¶
소유: P-code, 명칭, complaint collection.
Concern.register(concern_id, public_code, name_ko) -> Concern
concern.rename(name_ko) -> None
concern.add_complaint(complaint) -> None
concern.rename_complaint(complaint_id, name_ko) -> None
concern.reorder_complaint(complaint_id, new_order) -> None
concern.remove_complaint(complaint_id) -> None
Concern ↔ Indication은 aggregate 간 관계다. MapConcernIndication application use case와 ConcernIndicationPolicy가 중복·활성 상태를 검사한다.
5. Safety Context¶
5.1 SafetySubject entity와 SafetyConstraint aggregate¶
원문 규칙의 endpoint는 시술로만 가정하지 않는다.
SafetySubject(
id, public_code, label, subject_type, mapping_status,
procedure_codes=(), qualifiers={}
)
constraint.change_interval(interval, raw_interval=None) -> None
constraint.change_enforcement(enforcement) -> None
constraint.change_priority(priority) -> None
constraint.replace_rationale(text) -> None
constraint.attach_evidence(source_ref) -> None
constraint.remove_evidence(source_ref) -> None
constraint.procedure_pair_projections() -> tuple[ProcedureConstraint, ...]
procedureSubject는 시술 하나,procedure_set은 둘 이상을 가리켜야 한다.- 약물·재료·상태·범위 Subject는 시술 FK를 가질 수 없다.
- 단항 endpoint는
state_constraint에서만 허용한다. - 월 단위 간격이나 조건이 섞인 원문은 임의의 일수로 바꾸지 않는다. 따라서 정확한 일수로 변환할 수 없는 규칙은 pair projection을 만들지 않고 원장에 남긴다.
5.2 ProcedureConstraint 실행 projection¶
소유: 두 concept, 규칙 종류, 주의 단계, 강제 수준, 방향·하위 적용 범위, 간격, 근거 reference.
ProcedureConstraint.same_day(...) -> ProcedureConstraint
ProcedureConstraint.ordered(first, then, ...) -> ProcedureConstraint
ProcedureConstraint.minimum_interval(first, then, interval, ...) -> ProcedureConstraint
ProcedureConstraint.prohibited(first, second, ...) -> ProcedureConstraint
constraint.change_interval(interval: IntervalDays) -> None
constraint.change_scope(*, bidirectional, applies_to_descendants) -> None
constraint.change_enforcement(enforcement) -> None
constraint.change_priority(priority) -> None
constraint.replace_rationale(text) -> None
constraint.attach_evidence(source_ref) -> None
constraint.remove_evidence(source_id) -> None
constraint.applies_to(pair, hierarchy) -> bool
constraint.required_wait_after(first, second) -> IntervalDays | None
| 규칙 종류 | 필수 값 | 금지 값 |
|---|---|---|
same_day |
pair | positive interval |
order |
방향 | bidirectional |
minimum_interval |
방향 또는 bidirectional, interval | interval 없음 |
prohibited |
pair, required enforcement | same-day/order 의미 혼합 |
published constraint를 직접 바꾸지 않는다. 위 mutation은 draft aggregate에서만 호출되고 publish된 변경은 새 revision으로 남는다.
5.3 ConstraintSpecificityPolicy¶
select(
pair: ProcedurePair,
candidates: Sequence[ProcedureConstraint],
hierarchy: ConceptHierarchy,
) -> SelectedConstraint | None
선택 순서:
- 실제 요청 concept에 더 가까운 구체 규칙
requiredenforcement- 명시적
priority - 더 보수적인 safety level
동률인데 결과가 다르면 임의 선택하지 않고 ConflictingConstraints를 반환해 import/publish를 차단한다.
5.4 CombinationSafetyPolicy¶
evaluate(
pair: ProcedurePair,
constraints: Sequence[ProcedureConstraint],
hierarchy: ConceptHierarchy,
) -> SafetyDecision
SafetyDecision:
disposition: allowed | conditional | prohibited
enforcement: advisory | required
order: [concept_id, ...] | null
interval: IntervalDays | null
rule_refs: [constraint_id, ...]
evidence_refs: [source_id, ...]
rationale: text | null
requires_patient_confirmation: true
Safety는 추천 가능 여부나 API 문구를 결정하지 않는다. 등록 규칙이 없으면 allowed로 추정하지 않고 conditional + no_rule 사유를 반환한다.
6. Recommendation Context¶
6.1 RecommendationPackage aggregate¶
RecommendationPackage.create(package_id, concern_id, package_type, tier=None) -> Self
package.add_item(item: PackageItem) -> None
package.remove_item(item_id) -> None
package.change_item_role(item_id, role) -> None
package.reorder_item(item_id, new_order) -> None
package.add_narrative(narrative: PackageNarrative) -> None
package.replace_narrative(language, audience, narrative) -> None
package.change_target(age_range=None, severity_scope=None, complaint_id=None) -> None
package.enable() -> None
package.disable() -> None
package.validate_publishable(required_languages) -> Publishability
불변조건:
- item 1개 이상
(procedure_concept_id, product_id, role)중복 금지- sort order 연속
- age min/max 역전 금지
- 동일
(language, audience)narrative 중복 금지 - 활성 package는 상담용 명칭·설명과 근거 설명을 가져야 함
6.2 CombinationRecipe aggregate¶
CombinationRecipe.create(recipe_id, public_code, name) -> Self
recipe.append_step(step: RecipeStep) -> None
recipe.insert_step(position, step) -> None
recipe.replace_step(step_id, replacement) -> None
recipe.remove_step(step_id) -> None
recipe.reorder_step(step_id, new_order) -> None
recipe.replace_rationale(text) -> None
recipe.enable() -> None
recipe.disable() -> None
recipe는 자신의 step 구성만 검증한다. 안전 충돌은 RecipeSafetyPolicy.evaluate(recipe, safety_assessments)가 판단한다.
6.3 RecommendationEligibilityPolicy¶
evaluate(
package: PackageSnapshot,
safety_assessments: Sequence[SafetyAssessment],
concern_context: ConcernContext,
) -> RecommendationDecision
우선순위:
required/prohibited safety
> required order/interval
> recipe
> synergy
> package priority
반환값은 eligible | eligible_with_conditions | withheld와 적용 rule ID·사유다. LLM은 이 결정을 변경할 수 없다.
7. Knowledge Context¶
7.1 ClinicalClaim aggregate¶
ClinicalClaim.draft(claim_id, topic, language, statement, procedure_id=None) -> Self
claim.replace_statement(statement) -> None
claim.attach_citation(citation: ClaimCitation) -> None
claim.remove_citation(source_id) -> None
claim.record_verification(result: ClaimVerification) -> None
claim.retire(reason) -> None
claim.latest_verification() -> ClaimVerification | None
검증 기록은 덮어쓰지 않고 append-only다. statement가 변경되면 기존 verification을 승계하지 않는다.
7.2 KnowledgeDocument aggregate¶
KnowledgeDocument.draft(document_id, document_type, audience, language, title) -> Self
document.rename(title) -> None
document.replace_body(body) -> None
document.include_claim(claim_id, order) -> None
document.remove_claim(claim_id) -> None
document.reorder_claim(claim_id, new_order) -> None
상담/의료/환자 관점은 claim을 복제하지 않고 audience별 document가 같은 claim을 다른 순서와 설명으로 구성한다.
7.3 EvidencePublicationPolicy¶
evaluate(claim, evidence_sources) -> EvidenceDecision
- 안전·효과 claim은 verified citation 없이는 발행 불가
- PMID/DOI mismatch·차단 source는 사용 불가
- statement 변경 뒤 재검증 전에는 발행 불가
EvidenceDecision은 eligible 여부와 거절 사유/source IDs를 반환
8. Guidance Context¶
모든 안내 테이블을 rich aggregate로 만들지 않는다. 상태 변화와 구성 규칙이 있는 두 영역만 우선 구현한다.
8.1 RecoveryTimeline¶
RecoveryTimeline.create(timeline_id, procedure_id, title) -> Self
timeline.add_phase(phase) -> None
timeline.change_phase_window(phase_id, starts_at_day, ends_at_day) -> None
timeline.reorder_phase(phase_id, new_order) -> None
timeline.add_reaction(phase_id, reaction) -> None
timeline.remove_reaction(phase_id, reaction_id) -> None
기간은 starts_at_day <= ends_at_day, phase·reaction order는 중복될 수 없다. phase overlap 자체는 의학적 의미 검토 전까지 금지하지 않는다.
8.2 ComplicationProtocolSet¶
protocols.add(protocol) -> None
protocols.replace_instruction(protocol_id, instruction) -> None
protocols.reorder(protocol_id, new_order) -> None
protocols.remove(protocol_id) -> None
protocols.validate_emergency_coverage(complication) -> CoverageDecision
emergency complication은 immediate 또는 referral protocol이 하나 이상 있어야 발행할 수 있다.
ScreeningQuestion, ConsentDisclosure, PihRiskRule, AnatomyHazard는 1차에는 validation을 가진 versioned entity/read record로 시작하고 실제 복합 변경 규칙이 확인될 때 aggregate로 승격한다.
9. Glossary Context¶
9.1 GlossaryTerm aggregate¶
GlossaryTerm.register(term_id, category, source_text, source) -> Self
term.rename_source_text(text) -> None
term.set_translation(language, text) -> None
term.remove_translation(language) -> None
term.mark_translation_reviewed(language, reviewer, checked_at) -> None
term.mark_translation_rejected(language, reviewer, reason) -> None
현재 GlossaryWorkbook.parse, GlossaryDiff.compute, content hash는 순수 adapter/value-object 로직으로 유지한다. 삭제 임계와 release 생성은 Publication application use case가 담당한다.
10. Publication Context¶
10.1 ContentRevision 상태 전이¶
draft ──submit──> in_review ──approve──> approved
└──reject───> rejected
draft ──archive──> archived
approved/rejected/archived ──create_successor──> 새 draft
ContentRevision.create_draft(domain_key, snapshot, content_hash, actor) -> Self
revision.replace_draft(snapshot, content_hash, diff_summary) -> None
revision.submit(actor, submitted_at) -> None
revision.approve(reviewer, approved_at) -> None
revision.reject(reviewer, reason, rejected_at) -> None
revision.archive(actor, reason, archived_at) -> None
revision.create_successor(new_id, actor) -> ContentRevision
불변조건:
replace_draft는 draft에서만 가능approve/reject는 in_review에서만 가능- reviewer와 creator 분리 여부는 환경 정책이 아니라
ReviewPolicy로 명시 - terminal revision을 제자리 draft로 되돌리지 않음
- 동일 domain key에서 revision number는 단조 증가
10.2 ContentRelease 상태 전이¶
draft ──publish──> published ──supersede──> superseded
└────cancel────> cancelled
ContentRelease.create_draft(release_id, version, actor, rollback_of=None) -> Self
release.include_revision(revision_ref) -> None
release.remove_revision(domain_key) -> None
release.publish(actor, published_at, policy_result) -> ReleasePublished
release.supersede(next_release_id, superseded_at) -> None
release.cancel(actor, reason, cancelled_at) -> None
불변조건:
- domain key별 revision 하나
- include된 revision은 모두 approved
- publish 후 구성 변경 금지
- 동시에 published release 하나
- rollback은 과거 release 상태를 바꾸지 않고
rollback_of를 가진 새 draft/release 생성
10.3 ImportJob¶
received → parsed → validated → completed
└────────→ failed
ImportJob.receive(job_id, source_artifact_id, domain_key, actor) -> Self
job.record_parse(parse_summary) -> None
job.record_validation(validation_result, diff_summary) -> None
job.complete(revision_id) -> None
job.fail(stage, errors) -> None
단계 역행과 terminal job 변경을 금지한다. 원본 checksum, parser version, validation error는 보존한다.
10.4 Application use cases¶
다음은 aggregate method가 아니다.
PreviewImport.handle(command) -> ImportPreview
PublicationApplication.import_initial_data(plan, actor, occurred_at) -> CanonicalImportApplied
CreateRevision.handle(command) -> RevisionRef
SubmitRevision.handle(command) -> RevisionView
ApproveRevision.handle(command) -> RevisionView
PublishRelease.handle(command) -> PublishedRelease
RollbackRelease.handle(command) -> PublishedRelease
PublishRelease만 정규화된 live table 반영, 현재 release 교체, audit 기록을 한 transaction으로 수행한다. cache invalidation과 retrieval index 갱신은 commit 성공 후 실행한다.
최초 적재 CLI도 Repository를 직접 호출하지 않는다. 기존 dependency-injector Container가
CanonicalImportRepository와 CanonicalPublicationRepository를 PublicationApplication에
주입하고, Application의 @transactional 경계에서 한 번만 적재한다.
RollbackRelease는 과거 published | superseded release의 revision 집합을 바꾸지 않고 새
release로 발행한다. 이후 draft에서 발생한 pending issue는 과거에 이미 통과한 revision 집합의
긴급 복구를 막지 않는다. draft/cancelled release나 현재 release 자신은 rollback source가 될 수 없다.
11. Retrieval Query 경계¶
현재 공개 조회는 backend/app/router/knowledge.py와
backend/app/modules/retrieval/가 구현한다.
| 구성요소 | 실제 책임 |
|---|---|
retrieve_context Router |
Request를 RetrievalCommand로 변환하고 결과를 RetrievalResponse로 표현 |
CanonicalRetrievalService.retrieve |
@transactional 안에서 게시 release 조회, 후보 검색, 안전·근거 합류, 정렬·중복 제거·페이지 구성 |
QueryInterpreter.interpret · EntityResolver.resolve |
질의 의도와 기존 entity 후보 해석, 모호성 보존 |
CanonicalRetrievalRepository |
지정 release의 검색 unit·identity·안전 규칙·검증 근거 조회 |
KnowledgeIndexRepository.prepare |
revision의 검색 unit 준비 |
classify_domain · project_revision |
검색 domain 분류와 revision의 검색 projection 생성 |
Application은 Pydantic request·response 대신 RetrievalCommand와
KnowledgeRetrievalResult를 사용한다. 공개 alias·nullability는 Router와 schema가 소유한다.
fields는 Router에서 반환할 응답 묶음만 선택한다. RetrievalCommand에는 전달하지 않으므로
검색할 domain이나 서비스의 검색 범위를 제한하지 않는다. 생략하면 전체 응답을 반환한다.
게시 release가 없으면 unavailable을 반환한다. 검색 결과는 같은 release에 한정하며,
cursor도 release와 query에 묶인다. 현재 이 API에는 답변 생성기나 생성형 답변 실행 경로가 없다.
구체적인 요청·응답은 연동 가이드, 검색 설계는
Retrieval 설계를 참조한다.
12. Repository 계약¶
generic CRUD 대신 use-case 단위 method만 둔다.
class ProcedureConceptRepository(Protocol):
async def get(self, concept_id) -> ProcedureConcept | None: ...
async def identity_candidates(self, term, language) -> list[IdentityCandidate]: ...
async def reference_summary(self, concept_id) -> ConceptReferenceSummary: ...
async def save(self, concept) -> None: ...
class ProcedureConstraintRepository(Protocol):
async def candidates_for(self, pair, release_id) -> list[ProcedureConstraint]: ...
async def save(self, constraint) -> None: ...
class ContentRevisionRepository(Protocol):
async def get(self, revision_id) -> ContentRevision | None: ...
async def next_revision_no(self, domain_key) -> int: ...
async def save(self, revision) -> None: ...
class ContentReleaseRepository(Protocol):
async def current(self, *, for_update=False) -> ContentRelease | None: ...
async def get(self, release_id) -> ContentRelease | None: ...
async def save(self, release) -> None: ...
repository는 commit하지 않는다. 현재 @transactional을 application method의 transaction 경계로 재사용한다.
13. 구현·승인 Gate¶
다음 계약은 자동 테스트로 고정됐다.
- ambiguous alias를 임의 선택하지 않고 clarification 후보로 보존
- 구체적인 required constraint가 parent advisory rule보다 우선
- 같은 specificity의 충돌 규칙은 publication 차단
- 미등록 pair를
allowed로 간주하지 않음 - prohibited safety가 recommendation을 보류
- claim statement 변경 시 이전 verification 무효화, history 보존
- unapproved revision의 release 포함 차단
- published release 불변성과 rollback의 새 release 생성
- mapping·근거 validation issue가 approval/publish를 차단하고 transaction 보존
- Context Pack이 하나의 published release와 rule/source ref만 사용
- Canonical Retrieval Service가 API schema를 import하거나 반환하지 않음
구현 순서는 Value Object → Aggregate → Domain Policy → Repository port → Application use case → Persistence adapter → API presenter로 유지한다. 새 기능도 이 순서를 건너뛰지 않는다.