Hexagonal Pattern (Pragmatic)¶
Umbra 가 채택한 Hexagonal Architecture 의 실무 가이드. Core 도메인은 풀세트 Hexagonal, Supporting/Generic 은 단순 2층 구조를 쓴다 (ADR-0019). 이 문서는 각 도메인 타입별로 파일을 어디에 두고 어떻게 의존성을 관리하는지 설명한다.
Why Hexagonal¶
전통 Layered (Controller → Service → Repository) 는 의존성 방향이 단방향이지만 실무에서 몇 가지 문제가 생긴다.
- Repository 인터페이스가 Service 에 끌려 감 — ORM 의 관심사가 도메인에 침투
- 외부 시스템 (Toss, Discord, Temporal) 호출이 도메인 안에 박힘 — 테스트 불가, mock 지옥
- 도메인이 인프라를 알아야 함 — 결제 도메인이 HTTP client 를 import
Hexagonal 은 이 문제를 해결한다. 도메인이 Port (인터페이스) 만 선언하고, Adapter 가 실제 구현을 제공. 도메인은 외부 세계를 모른 채 유스케이스만 수행.
Pragmatic 의 의미¶
순수 Hexagonal 은 모든 도메인에 풀세트 (Domain / Port / App / Adapter) 를 강요한다. 실무에서 Supporting 도메인 (Identity, Guild 같은 CRUD 성) 에 적용하면 코드가 부풀어 오른다.
Umbra 는 Core 만 풀세트, 나머지는 단순 구조.
| Type | Examples | Structure |
|---|---|---|
| Core | licensing, billing, recovery | Domain + Port + App + Adapter (풀세트) |
| Supporting | identity, guild, member, notification | Service + Repository + Events (3-4 files) |
| Generic | audit, webhook | Service + Repository (2 files) |
이 선택 근거는 adr/0019-hexagonal-pragmatic.md.
Core domain layout¶
engine/billing/
├─ domain/ # 순수 entity, invariant
│ ├─ subscription.go
│ ├─ billing_key.go
│ ├─ payment_attempt.go
│ ├─ events.go # 도메인 이벤트 정의
│ └─ errors.go # 도메인 에러 (sentinel)
│
├─ port/ # 인터페이스만
│ ├─ service.go # inbound (driving) port
│ ├─ repository.go # outbound (driven) port
│ ├─ toss.go # 외부 시스템 port
│ ├─ crypto.go
│ ├─ event.go
│ └─ clock.go
│
├─ app/ # 유스케이스 구현
│ ├─ service.go # inbound port 구현 (Service)
│ ├─ start_subscription.go
│ ├─ process_recurring.go
│ ├─ retry_charge.go
│ ├─ cancel.go
│ └─ change_plan.go
│
└─ adapter/ # outbound port 구현
├─ persistence/
│ └─ sqlc/
│ ├─ subscription.go
│ ├─ billing_key.go
│ └─ payment_attempt.go
├─ toss/
│ ├─ client.go
│ └─ webhook_parser.go
├─ crypto/
│ └─ aes_gcm.go
├─ event/
│ └─ outbox.go
└─ clock/
└─ real.go
Domain layer¶
가장 안쪽. 외부 의존 0. import 가능한 패키지: 표준 라이브러리 + platform/uuid 같은 pure util.
// domain/subscription.go
package domain
import "time"
type Subscription struct {
ID SubscriptionID
LicenseID LicenseID
PayerUserID UserID
GuildID GuildID
BillingKeyID BillingKeyID
PlanID PlanID
Status SubscriptionStatus
CurrentPeriodStart time.Time
CurrentPeriodEnd time.Time
NextBillingAt time.Time
CycleCount int
RetryCount int
CancelAtPeriodEnd bool
CanceledAt *time.Time
SuspendedAt *time.Time
SuspendedReason string
}
// 도메인 메서드 (상태 전이, 규칙)
func (s *Subscription) AdvancePeriod(newEnd time.Time, nextBilling time.Time) {
s.CurrentPeriodStart = s.CurrentPeriodEnd
s.CurrentPeriodEnd = newEnd
s.NextBillingAt = nextBilling
s.CycleCount++
s.RetryCount = 0
s.Status = StatusActive
}
func (s *Subscription) MarkPastDue(nextRetry time.Time) {
s.Status = StatusPastDue
s.RetryCount++
s.NextBillingAt = nextRetry
}
- DB 의존 금지 (
sql,pgximport 금지) - 외부 API 의존 금지
- JSON tag 있으면 애매 — domain entity 는 순수, adapter 에서 DTO 로 변환
Port layer¶
인터페이스만. Adapter 가 구현.
// port/repository.go
package port
type SubscriptionRepository interface {
Insert(ctx context.Context, sub domain.Subscription) error
Update(ctx context.Context, sub domain.Subscription) error
GetByID(ctx context.Context, id domain.SubscriptionID) (*domain.Subscription, error)
GetActiveByGuild(ctx context.Context, guildID domain.GuildID) (*domain.Subscription, error)
ListDueForCharge(ctx context.Context, before time.Time) ([]*domain.Subscription, error)
}
type TossClient interface {
IssueBillingKey(ctx context.Context, customerKey, authKey string) (*IssueResult, error)
Charge(ctx context.Context, input ChargeInput) (*ChargeResult, error)
CancelBillingKey(ctx context.Context, billingKey string) error
}
type Crypto interface {
Encrypt(ctx context.Context, plaintext, aad []byte) (ciphertext, nonce []byte, err error)
Decrypt(ctx context.Context, ciphertext, nonce, aad []byte) ([]byte, error)
}
type EventPublisher interface {
Publish(ctx context.Context, event domain.Event) error
}
type Clock interface {
Now() time.Time
NextBillingTimeWithJitter(base time.Time) time.Time
}
- 이름은 역할 중심 (
TossClient보다PaymentGateway가 추상적이지만 MVP 는 Toss 전제라 직접 이름 사용 허용) - 하나의 port 는 작게 — 5개 이하 메서드 권장
- Consumer side definition (Go 관행) — port 는 도메인이 정의
App layer¶
Inbound port 를 구현하는 서비스. 유스케이스 별 파일 분리 권장.
// app/service.go
package app
type Service struct {
subs port.SubscriptionRepository
keys port.BillingKeyRepository
attempts port.PaymentAttemptRepository
toss port.TossClient
crypto port.Crypto
events port.EventPublisher
clock port.Clock
tx port.TxManager
logger *slog.Logger
}
func NewService(
subs port.SubscriptionRepository,
keys port.BillingKeyRepository,
attempts port.PaymentAttemptRepository,
toss port.TossClient,
crypto port.Crypto,
events port.EventPublisher,
clock port.Clock,
tx port.TxManager,
logger *slog.Logger,
) *Service {
return &Service{
subs: subs, keys: keys, attempts: attempts,
toss: toss, crypto: crypto, events: events,
clock: clock, tx: tx, logger: logger,
}
}
유스케이스별 파일:
// app/start_subscription.go
package app
func (s *Service) StartSubscription(ctx context.Context, input StartSubscriptionInput) (*domain.Subscription, error) {
// 1. 빌링키 발급
issueResult, err := s.toss.IssueBillingKey(ctx, input.CustomerKey, input.AuthKey)
if err != nil { return nil, fmt.Errorf("issue billing key: %w", err) }
// 2. 암호화 + 저장
encrypted, nonce, err := s.crypto.Encrypt(ctx, []byte(issueResult.BillingKey), []byte(input.CustomerKey))
if err != nil { return nil, fmt.Errorf("encrypt: %w", err) }
var sub *domain.Subscription
err = s.tx.WithTx(ctx, func(tx port.TxRepos) error {
// INSERT billing_key, subscription
// Outbox INSERT BillingKeyIssued
// First charge 는 별도 메서드
...
})
if err != nil { return nil, err }
// 3. First charge
return s.executeFirstCharge(ctx, sub, input.PlanPrice)
}
35줄 제한 지키기 어려운 유스케이스는 private helper 함수로 분리.
Adapter layer¶
Outbound port 구현. 실제 외부 시스템 접근.
// adapter/persistence/sqlc/subscription.go
package sqlc
type subscriptionRepo struct {
q *queries.Queries
}
func NewSubscriptionRepo(q *queries.Queries) port.SubscriptionRepository {
return &subscriptionRepo{q: q}
}
func (r *subscriptionRepo) Insert(ctx context.Context, sub domain.Subscription) error {
return r.q.InsertSubscription(ctx, queries.InsertSubscriptionParams{
ID: sub.ID.UUID(),
LicenseID: sub.LicenseID.UUID(),
// ...
})
}
func (r *subscriptionRepo) GetByID(ctx context.Context, id domain.SubscriptionID) (*domain.Subscription, error) {
row, err := r.q.GetSubscriptionByID(ctx, id.UUID())
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrSubscriptionNotFound
}
if err != nil { return nil, err }
return rowToDomain(row), nil
}
// DB row → domain entity 변환
func rowToDomain(row queries.Subscription) *domain.Subscription { ... }
- sqlc generated 코드 (
queries.*) 는 adapter 에만 노출 - DB row → domain entity 변환 함수 (
rowToDomain) 가 경계 - pgx/postgresql 특정 에러는 domain sentinel 로 변환
// adapter/toss/client.go
package toss
type client struct {
http *http.Client
secretKey string
webhookKey string
}
func NewClient(httpc *http.Client, secretKey, webhookKey string) port.TossClient {
return &client{http: httpc, secretKey: secretKey, webhookKey: webhookKey}
}
func (c *client) Charge(ctx context.Context, input port.ChargeInput) (*port.ChargeResult, error) {
req := buildChargeRequest(input)
resp, err := c.http.Do(req)
if err != nil { return nil, fmt.Errorf("toss charge: %w", err) }
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, parseTossError(resp)
}
return parseChargeResult(resp), nil
}
Supporting domain layout¶
풀세트 Hexagonal 없음. 3~4 파일 단순 구조.
engine/identity/
├─ entity.go # User, Session 타입
├─ service.go # Service 구조체 + 메서드
├─ repository.go # Repository 인터페이스 + sqlc 구현
├─ events.go # 도메인 이벤트
└─ adapter/
├─ discord/ # Discord OAuth2 client
│ └─ oauth2.go
└─ session/ # Redis session store
└─ redis.go
Service¶
// engine/identity/service.go
package identity
type Service struct {
repo Repository
sessions SessionStore
discord DiscordOAuth2Client
events EventPublisher
clock Clock
logger *slog.Logger
}
func (s *Service) AuthenticateWithDiscord(ctx context.Context, authCode string) (*User, *Session, error) {
// OAuth2 token exchange
token, err := s.discord.ExchangeCode(ctx, authCode)
if err != nil { return nil, nil, err }
// User info fetch
info, err := s.discord.FetchUserInfo(ctx, token.AccessToken)
if err != nil { return nil, nil, err }
// Upsert user
user, err := s.repo.UpsertUser(ctx, UpsertInput{ ... })
if err != nil { return nil, nil, err }
// Create session
session, err := s.createSession(ctx, user.ID, token)
if err != nil { return nil, nil, err }
return user, session, nil
}
Port/App/Adapter 디렉토리 나누지 않음. 대신 interface 는 service.go 또는 repository.go 에 선언.
Why simpler¶
Supporting domain 은:
- 외부 시스템 integration 수가 적음
- 유스케이스가 단순 (CRUD + 몇 가지 액션)
- 상태 머신이 단순
풀세트 구조의 간접 비용이 이득을 초과한다.
Generic domain layout¶
Audit, Webhook. 단순 2-file 구조.
Audit 은 거의 "insert-only" 라 entity 메서드도 최소.
Dependency injection wiring¶
Per-process¶
각 apps/{bot,api,worker}/cmd/main.go 에서 명시적 wiring.
// apps/api/cmd/main.go
func main() {
ctx := context.Background()
cfg := config.Load()
// Infra
pool := platform.NewPgxPool(cfg.DatabaseURL)
defer pool.Close()
redisClient := platform.NewRedisClient(cfg.RedisURL)
defer redisClient.Close()
logger := platform.NewLogger(cfg.LogLevel)
// Queries
queries := sqlcQueries.New(pool)
// Core: Billing
billingSvc := billingApp.NewService(
billingSqlc.NewSubscriptionRepo(queries),
billingSqlc.NewBillingKeyRepo(queries),
billingSqlc.NewPaymentAttemptRepo(queries),
billingToss.NewClient(httpClient, cfg.TossSecretKey, cfg.TossWebhookKey),
billingCrypto.NewAESGCM(cfg.EncryptionKey),
platform.NewOutboxPublisher(pool),
platform.NewRealClock(),
platform.NewTxManager(pool),
logger,
)
// Core: Licensing
licensingSvc := licensingApp.NewService(...)
// Supporting: Identity
identitySvc := identity.NewService(
identity.NewRepo(queries),
identity.NewRedisSessionStore(redisClient),
identity.NewDiscordOAuth2Client(cfg),
platform.NewOutboxPublisher(pool),
platform.NewRealClock(),
logger,
)
// Handlers
h := handler.NewHandler(billingSvc, licensingSvc, identitySvc, ...)
// Run
server := newEchoServer(h)
server.Start(cfg.Port)
}
- DI container (uber-fx 등) 도입은 Phase 2 에서 검토
- 수동 wiring 은 파일 당 ~100줄 수준, 명확함
Cross-process shared¶
engine/, platform/ 은 bot / api / worker 3개 프로세스가 공유한다. 각 process 의 main.go 에서 필요한 서비스만 wiring.
예: bot 은 billing 의 ProcessRecurringCharge 는 호출하지 않으므로 worker 에만 wire 하면 됨.
Testing Hexagonal¶
Unit test 에서 port mock¶
func TestService_StartSubscription(t *testing.T) {
mockSubs := &mockSubRepo{}
mockToss := &mockTossClient{
chargeResult: &port.ChargeResult{PaymentKey: "test", ApprovedAt: time.Now()},
}
svc := app.NewService(mockSubs, ..., mockToss, ...)
sub, err := svc.StartSubscription(ctx, input)
assert.NoError(t, err)
assert.Equal(t, "active", string(sub.Status))
assert.Equal(t, 1, sub.CycleCount)
assert.Len(t, mockSubs.inserted, 1)
assert.Len(t, mockToss.chargeCalls, 1)
}
type mockSubRepo struct {
inserted []domain.Subscription
}
func (m *mockSubRepo) Insert(ctx context.Context, sub domain.Subscription) error {
m.inserted = append(m.inserted, sub)
return nil
}
// ... 다른 메서드
Integration test 에서 실제 adapter¶
func TestService_Integration(t *testing.T) {
if testing.Short() { t.Skip() }
// Docker Postgres
pool := testsupport.NewPgxPool(t)
queries := sqlcQueries.New(pool)
svc := app.NewService(
sqlc.NewSubscriptionRepo(queries),
..., // 실제 repo
&mockTossClient{ ... }, // 외부 API 만 mock
...,
)
sub, err := svc.StartSubscription(ctx, input)
require.NoError(t, err)
// DB 에서 실제로 읽어서 검증
got, _ := queries.GetSubscriptionByID(ctx, sub.ID.UUID())
assert.Equal(t, "active", got.Status)
}
자세한 건 testing-strategy.md.
Cross-domain communication¶
Same process read (Port 경유)¶
다른 도메인 데이터를 읽어야 할 때, 해당 도메인의 Service 를 port interface 로 의존:
// engine/recovery/subdomain/restore/port/licensing.go
type LicensingReader interface {
Can(ctx context.Context, guildID uuid.UUID, feature string) (bool, error)
GetActiveLicense(ctx context.Context, guildID uuid.UUID) (*License, *Plan, error)
}
Wiring 시 licensing service 를 adapter 로 wrap:
// apps/api/cmd/main.go
restoreSvc := restoreApp.NewService(
...,
adapter.NewLicensingReaderFromService(licensingSvc),
...,
)
이 방식이 직접 licensing.Service 를 의존하는 것보다 테스트 용이.
Cross-domain write 금지¶
다른 도메인의 데이터를 직접 쓰지 않는다. 대신:
- 이벤트 발행 — outbox 로 이벤트, 구독자가 자기 도메인 업데이트
- 요청 기반 API 호출 — 드물지만 필요 시 (보통 이벤트로 해결)
이벤트 구독¶
// engine/licensing/subscriber.go
type subscriber struct {
svc *app.Service
}
func (s *subscriber) OnPaymentSucceeded(ctx context.Context, event billingDomain.PaymentSucceeded) error {
return s.svc.ExtendExpiresAt(ctx, event.LicenseID, event.NewPeriodEnd)
}
func (s *subscriber) OnPaymentFailedFinal(ctx context.Context, event billingDomain.PaymentFailedFinal) error {
return s.svc.Downgrade(ctx, DowngradeInput{ LicenseID: event.LicenseID, ... })
}
자세한 건 outbox-pattern.md.
Do / Don't¶
Do¶
- ✅ Core 는 풀세트 Hexagonal (Domain / Port / App / Adapter)
- ✅ Supporting / Generic 은 단순 구조
- ✅ Port interface 는 consumer 측 정의
- ✅ Domain layer 는 외부 의존 0
- ✅ Adapter 에서 domain 변환
- ✅ Cross-domain read 는 port 경유
- ✅ Cross-domain write 는 이벤트로
Don't¶
- ❌ Supporting 에 불필요한 Hexagonal 구조
- ❌ Domain 이 sqlc 나 pgx import
- ❌ Adapter 가 다른 adapter 호출 (port 경유 안 하고)
- ❌ Cross-domain 직접 write
- ❌ god interface (20+ 메서드)
- ❌ Port 와 Adapter 의 1:1 강요 (하나의 port 에 여러 adapter 구현 가능)
See also¶
project-structure.mdbackend-conventions.mddatabase-conventions.mdoutbox-pattern.mdtesting-strategy.md../adr/0019-hexagonal-pragmatic.md../adr/0018-domain-code-sharing.md