ADR-0019: Pragmatic Hexagonal Architecture¶
Umbra 는 Hexagonal Architecture 를 모든 도메인에 일률적으로 적용하지 않는다. Core 컨텍스트(Recovery, Licensing, Billing)에는 풀세트로 적용하고, Supporting/Generic 컨텍스트는 단순 구조로 실용적으로 적용한다.
Status¶
Accepted
- Decided at — 2026-04-13
- Decided by — Pablo
Context¶
Umbra 는 9개 Bounded Context 를 가진다 (ADR-0020). 이 전부를 완전한 Hexagonal Architecture (Port/Adapter 분리, 도메인 코어 순수 유지)로 구현하면 다음 비용이 발생한다.
- 단순 CRUD 수준 도메인에도 Port 인터페이스 필요
- Adapter 레이어의 보일러플레이트
- 신규 팀원의 학습 부담 (모든 도메인이 3~4층 구조)
그러나 Hexagonal 의 이점(외부 의존 격리, 테스트 용이성, 미래 RPC 전환 가능성)은 비즈니스 복잡도가 높은 도메인에서는 명확하다.
즉 "모든 도메인에 동일하게 적용" 은 이론적 완결성을 추구하는 오버엔지니어링 이고, "Hexagonal 을 전혀 쓰지 않음" 은 Core 도메인의 유지보수 가치를 버리는 선택 이다.
Decision¶
Bounded Context 의 분류에 따라 Hexagonal 적용 수준을 달리한다.
Core (full Hexagonal)¶
대상: Recovery, Licensing, Billing
- domain/ — 순수 entity, value object, invariant
- port/ — Inbound (driving) + Outbound (driven) 인터페이스
- app/ — 유스케이스 구현, Port 기반으로 Service 작성
- adapter/ — Port 의 실제 구현 (persistence, external, etc.)
도메인 코어는 외부를 모른다. 테스트는 Port 의 mock/stub 으로 독립 실행 가능.
Supporting (단순 구조)¶
대상: Identity, Guild, Member, Notification
- repository + service 2층 구조
- Port/Adapter 분리 없음
*Service가 직접 DB pool, Redis client, Discord client 등 의존
Generic (단순 구조)¶
대상: Audit, Webhook
- Supporting 과 동일한 2층 구조
- Audit 은 대부분 INSERT only, Webhook 은 외부 진입점 adapter
분류 기준¶
"이 도메인이 Umbra 의 경쟁 우위에 직접 기여하는가?"
- Core — Umbra 의 차별화 영역 (복구, 권한, 결제)
- Supporting — Core 를 떠받치는 영역 (사용자, 길드, 멤버, 알림)
- Generic — 일반 솔루션으로 충분한 영역 (감사 로그, 웹훅 수신)
선택 근거:
- 비용-이점 균형 — 복잡도가 정당화되는 도메인에만 Hexagonal 적용
- 도메인 중요도 반영 — Core 는 미래 Rust 재작성의 핵심이라 언어 무관 설계 필수
- 학습 부담 분산 — 신규 팀원이 Supporting 부터 접근 후 Core 로 확장
- 리팩토링 가능성 — Supporting 이 커지면 Core 로 승격하며 Hexagonal 적용
Directory structure¶
Core context 예: Billing¶
engine/billing/
├─ domain/ ← 순수 도메인 (외부 의존 0)
│ ├─ subscription.go
│ ├─ billing_key.go
│ └─ invariants.go
├─ port/ ← 인터페이스
│ ├─ subscription_repository.go
│ ├─ toss_client.go
│ └─ event_publisher.go
├─ app/ ← 유스케이스
│ └─ service.go
└─ adapter/ ← 구현
├─ persistence/ (sqlc 연결)
├─ toss/ (Toss API client)
└─ event/ (Outbox 연결)
Supporting context 예: Member¶
engine/member/
├─ entity.go ← Member, Guild 참조 등
├─ service.go ← 유스케이스 (DB pool 직접 참조)
├─ repository.go ← sqlc 쿼리 래핑
└─ events.go ← 발행 이벤트 정의
Consequences¶
Positive¶
- Core 는 테스트와 미래 재작성이 용이
- Supporting 은 학습 부담 낮고 빠른 구현
- 도메인별 구조가 "왜 이런지" 설명 가능 (Core = 복잡/중요, Supporting = 단순/보조)
- 팀 성장에 따라 Supporting → Core 승격 경로 존재
Negative¶
- 도메인마다 구조가 달라 신규 팀원이 두 패턴을 학습
- 승격 시 리팩토링 필요 (Supporting → Core 전환 시)
- "왜 A 는 Core 이고 B 는 Supporting 인가" 논쟁 가능 (분류 기준 문서화 필요)
Neutral¶
- 분류는
architecture/context-map.md에 공식 기록 - 각 도메인의 Hexagonal 적용 수준은 도메인 문서에서 명시
Boundaries between core and supporting¶
Core 가 Supporting 을 호출하려면¶
Core 컨텍스트가 Supporting 데이터를 필요로 할 때, Core 안의 Port 로 정의하고 Adapter 가 Supporting 을 호출 한다.
예: Recovery 가 Member 데이터가 필요한 경우
// engine/recovery/port/member_reader.go
type MemberReader interface {
GetMembers(ctx context.Context, guildID uuid.UUID) ([]Member, error)
}
// engine/recovery/adapter/member/reader.go
type memberReaderImpl struct {
memberSvc *member.Service // Supporting 의 단순 Service
}
func (r *memberReaderImpl) GetMembers(...) { ... }
이렇게 하면 Core 의 domain/app 은 Supporting 을 모르고, 경계가 유지된다.
Supporting 이 Supporting 을 호출¶
Supporting 간 호출은 자유. member.Service 가 guild.Service 를 직접 import 해도 OK. 단, 순환 의존은 금지.
Supporting 이 Core 를 호출¶
일반적으로 권한 체크 목적. Licensing 은 Core 지만 Supporting 에서 직접 호출 가능 (Licensing 이 읽기 전용 쿼리만 제공하는 경우).
예: Member 의 Web Join 핸들러가 Licensing 에 "이 Guild 가 Pro 인가?" 질문.
Alternatives considered¶
Alternative 1: 모든 도메인에 Hexagonal 적용¶
Pros
- 구조 일관성
- 모든 도메인이 테스트 용이
Cons
- Supporting 수준 도메인에 overkill
- Audit 같은 단순 INSERT 에도 Port 필요
- 보일러플레이트 폭증
Why rejected — 비용이 이점을 압도. 오버엔지니어링.
Alternative 2: 전혀 Hexagonal 미적용 (단순 2층)¶
Pros
- 구조 단순, 빠른 구현
Cons
- Billing 같은 복잡 도메인에서 외부 의존(Toss) 결합이 도메인에 섞임
- 테스트 시 mock 복잡
- 미래 RPC 전환/재작성 비용 ↑
Why rejected — Core 도메인의 유지보수성이 결정적.
Alternative 3: 모든 도메인 단일 Service + 필요 시 리팩토링¶
Pros
- 시작 단순
Cons
- Core 도메인이 복잡해진 후 Hexagonal 리팩토링은 큰 부담
- 초기에 경계 설정 안 하면 도메인 간 결합 발생
Why rejected — Core 는 처음부터 Hexagonal 로 시작해야 경계가 제대로 형성됨.
Compliance¶
architecture/context-map.md에 각 Context 의 분류(Core/Supporting/Generic) 기록- Core Context 는
domain/,port/,app/,adapter/4개 하위 패키지 강제 - Supporting/Generic 은 자유 구조, 단 순환 의존 금지
- Core 의
domain/와app/는 Supporting 패키지를 직접 import 금지 (Port 경유) - 코드 리뷰에서 경계 위반 차단
Revisit triggers¶
- Supporting Context 가 복잡해져서 Hexagonal 이 필요해지면 Core 로 승격 (ADR 로 기록)
- 반대로 Core 의 특정 영역이 단순하게 유지되면 "실용주의" 대상에서 제외 검토
- 팀 규모 확장으로 구조 일관성의 가치가 커지면 재평가
References¶
- Hexagonal Architecture - Alistair Cockburn
- [Domain-Driven Design - Eric Evans]