콘텐츠로 이동

도메인 메서드 계약

상태: 핵심 계약 구현·회귀 검증 완료 · 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, ...]
  • procedure Subject는 시술 하나, 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

선택 순서:

  1. 실제 요청 concept에 더 가까운 구체 규칙
  2. required enforcement
  3. 명시적 priority
  4. 더 보수적인 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

다음 계약은 자동 테스트로 고정됐다.

  1. ambiguous alias를 임의 선택하지 않고 clarification 후보로 보존
  2. 구체적인 required constraint가 parent advisory rule보다 우선
  3. 같은 specificity의 충돌 규칙은 publication 차단
  4. 미등록 pair를 allowed로 간주하지 않음
  5. prohibited safety가 recommendation을 보류
  6. claim statement 변경 시 이전 verification 무효화, history 보존
  7. unapproved revision의 release 포함 차단
  8. published release 불변성과 rollback의 새 release 생성
  9. mapping·근거 validation issue가 approval/publish를 차단하고 transaction 보존
  10. Context Pack이 하나의 published release와 rule/source ref만 사용
  11. Canonical Retrieval Service가 API schema를 import하거나 반환하지 않음

구현 순서는 Value Object → Aggregate → Domain Policy → Repository port → Application use case → Persistence adapter → API presenter로 유지한다. 새 기능도 이 순서를 건너뛰지 않는다.