ある日の話ですが...クリーンアーキテクチャで開発を進める中で、Service 層の循環参照や責務の肥大化にぶつかりました。その問題に対してどんなアプローチを検討し、それぞれ何が足りなかったのかを備忘録として残しておこうと思います。
最終的に「DomainComponent 層」という新しいレイヤーを発行するという案も検討しましたが、レイヤーを増やさずに既存の仕組みで解決する 結論に至りました。一種に考え方の一つとして読んでみて欲しいです。
前提: 現状のアーキテクチャ
EC サイトのバックエンドを Go で開発しています。アーキテクチャはクリーンアーキテクチャをベースにした以下の構成です。
Handler → Usecase → Domain(Service → Repository → Entity)
Service 層には以下のパッケージがあります。
pkg/domain/service/
├── order/ # 注文
├── cart/ # カート
├── product/ # 商品
├── payment/ # 決済
├── notification/ # 通知
├── tax/ # 税計算
└── inventory/ # 在庫
ぶつかった問題
Service 間の依存が増えていく
開発初期は単純でした。
// order/service.go — 注文は決済と在庫を使う
type service struct {
orderRepo repository.OrderRepository
paymentService payment.Service
inventoryService inventory.Service
}
// cart/service.go — カートは税計算と在庫を使う
type service struct {
cartRepo repository.CartRepository
taxService tax.Service
inventoryService inventory.Service
}
ここまでは問題ありません。依存は一方通行で、コンパイルも通ります。
循環参照の発生
ある日、2つの要件が追加されました。
要件1: 在庫が 0 になったら通知を送りたい
// inventory/service.go
type service struct {
inventoryRepo repository.InventoryRepository
notifyService notification.Service // ← 追加
}
要件2: 通知に注文情報を含めたい
// notification/service.go
type service struct {
notifyRepo repository.NotificationRepository
orderService order.Service // ← 追加
}
order → notification → order 💥
order → inventory → notification → order 💥
Go ではパッケージの循環 import はコンパイルエラーです。ここで開発が止まりました。
アプローチ1: Usecase 層で全部組み合わせる
最初に考えたのは「Service 間で呼び合うのをやめて、Usecase 層で組み合わせる」というアプローチです。
やり方
// usecase/order/usecase.go
type usecase struct {
orderService order.Service
paymentService payment.Service
inventoryService inventory.Service
notifyService notification.Service
taxService tax.Service
}
func (u *usecase) PlaceOrder(ctx context.Context, input PlaceOrderInput) error {
// Usecase が全 Service を呼び、組み合わせる
tax := u.taxService.Calculate(...)
u.paymentService.Charge(...)
u.inventoryService.Reserve(...)
u.orderService.Create(...)
u.notifyService.Send(...)
}
各 Service は他の Service に依存せず、Repository だけに依存する。組み合わせは Usecase の責務。
良かった点
- 循環参照は確実に解消される
- 各 Service がシンプルになる
限界にぶつかった点
Usecase が肥大化する。 注文処理は「在庫チェック → 税計算 → 決済 → 在庫引き当て → レコード作成 → 通知送信」という一連のフローで、これは ドメインロジック です。Usecase に書くとドメイン知識が流出します。
同じ組み合わせが重複する。 「在庫チェック → 引き当て → 通知」の流れは注文だけでなく、予約やギフト送付でも必要です。Usecase ごとに同じコードが散らばります。
Service の責務が薄くなりすぎる。 全ての判断を Usecase に押し上げると、Service はほぼ Repository のラッパーになります。「Service 層にビジネスロジックを書く」というクリーンアーキテクチャの原則から外れます。
判断
部分的には有効だが、全ての Service 間連携を Usecase に押し上げるのは過剰。Service 間の依存を禁止するのではなく、依存の方向を制御する 方が適切ではないか。
アプローチ2: interface を別パッケージに切り出す
次に検討したのは、Go の interface を使った依存性逆転です。
やり方
// pkg/domain/port/notification.go — 共通の interface パッケージ
package port
type NotificationSender interface {
Send(ctx context.Context, input SendInput) error
}
// inventory/service.go — interface に依存する
type service struct {
inventoryRepo repository.InventoryRepository
notifySender port.NotificationSender // notification.Service ではなく port を import
}
inventory は notification パッケージを import しないので、循環参照は起きません。
良かった点
- 循環参照を解消できる
- Go の暗黙的 interface の仕組みに乗っている
限界にぶつかった点
ファイルが散らばる。 interface 定義が port/ に、実装が notification/ に、利用側が inventory/ に分散します。
根本的な問題を隠蔽している。 interface で循環を回避しても、「在庫サービスが通知サービスを知っている」という設計上の問題は残っています。コンパイルは通るが、依存関係のグラフは複雑なままです。
判断
技術的には正しいが、「循環をすり抜ける」アプローチに近い。根本解決ではない。
アプローチ3: 1つの巨大な service パッケージにまとめる
循環参照はパッケージ間の問題なので、パッケージを1つにすれば起きません。
限界にぶつかった点
全てが密結合になる。 パッケージ内のどの型・関数からでも他の全てにアクセスできます。テストが困難になり、命名衝突も起きます。
判断
小規模なら機能するが、Service が 20 個を超えるようなプロジェクトでは現実的でない。
ここまでの振り返り: 何が本質的な問題か
3つのアプローチを検討して、見えてきたことがあります。
Service 層の中に、性質の異なる2種類のサービスが混在している。
■ 機能固有のサービス(特定のユースケースに紐づく)
order/ ─ 「注文する」ロジック
cart/ ─ 「カートに入れる」ロジック
product/ ─ 「商品を管理する」ロジック
■ 横断的なサービス(複数の機能から呼ばれる汎用ロジック)
payment/ ─ 注文でも、サブスクでも、ギフトカードでも使う
notification/ ─ 注文完了でも、在庫復活でも、配送完了でも使う
tax/ ─ カート画面でも、注文確定でも使う
inventory/ ─ 商品一覧でも、カートでも、注文でも使う
循環参照が起きるのは、横断的サービスに「いつ・なぜ」の判断(= 機能固有のロジック)を持たせてしまうからです。
アプローチ4: DomainComponent 層を導入する
2種類のサービスをパッケージレベルで分離する。
pkg/domain/
├── service/ # DomainService(機能固有)
│ ├── order/
│ ├── cart/
│ └── product/
├── component/ # DomainComponent(横断的)← 新設
│ ├── payment/
│ ├── notification/
│ ├── tax/
│ └── inventory/
└── repository/
ルール:
- DomainService は DomainComponent と Repository の両方を呼べる
- DomainComponent は Repository だけを呼べる(DomainService は呼べない)
Go のパッケージ制約で依存方向がコンパイル時に強制され、循環参照が構造的に不可能になります。
具体的なコード
DomainComponent: 税計算
// pkg/domain/component/tax/component.go
package tax
type Component interface {
Calculate(ctx context.Context, items []entity.OrderItem, addr entity.Address) (*Result, error)
}
type Result struct {
Subtotal int
TaxAmount int
Total int
}
type component struct {
taxRateRepo repository.TaxRateRepository // Repository だけに依存
}
func NewComponent(taxRateRepo repository.TaxRateRepository) Component {
return &component{taxRateRepo: taxRateRepo}
}
func (c *component) Calculate(ctx context.Context, items []entity.OrderItem, addr entity.Address) (*Result, error) {
rate, err := c.taxRateRepo.GetByPrefecture(ctx, addr.Prefecture)
if err != nil {
return nil, err
}
subtotal := 0
for _, item := range items {
subtotal += item.Price * item.Quantity
}
taxAmount := int(float64(subtotal) * rate.Rate)
return &Result{Subtotal: subtotal, TaxAmount: taxAmount, Total: subtotal + taxAmount}, nil
}
DomainComponent: 在庫管理
// pkg/domain/component/inventory/component.go
package inventory
type Component interface {
CheckAvailability(ctx context.Context, productID string, quantity int) (bool, error)
Reserve(ctx context.Context, tx repository.Tx, productID string, quantity int) error
}
type component struct {
inventoryRepo repository.InventoryRepository // Repository だけに依存
}
func NewComponent(inventoryRepo repository.InventoryRepository) Component {
return &component{inventoryRepo: inventoryRepo}
}
func (c *component) CheckAvailability(ctx context.Context, productID string, quantity int) (bool, error) {
stock, err := c.inventoryRepo.GetStock(ctx, productID)
if err != nil {
return false, err
}
return stock.Available >= quantity, nil
}
DomainComponent: 通知
// pkg/domain/component/notification/component.go
package notification
type Component interface {
Send(ctx context.Context, tx repository.Tx, input SendInput) error
}
type SendInput struct {
UserID string
Type NotificationType
Title string
Body string
}
type component struct {
notificationRepo repository.NotificationRepository // Repository だけに依存
}
func (c *component) Send(ctx context.Context, tx repository.Tx, input SendInput) error {
// 通知レコードを作成するだけ。「いつ・誰に・なぜ送るか」は知らない
return c.notificationRepo.Create(ctx, tx, &repository.Notification{
UserID: input.UserID,
Type: string(input.Type),
Title: input.Title,
Body: input.Body,
})
}
DomainService: 注文(Component を利用する側)
// pkg/domain/service/order/service.go
package order
import (
"myapp/pkg/domain/component/inventory" // ✅ Component を import できる
"myapp/pkg/domain/component/notification"
"myapp/pkg/domain/component/payment"
"myapp/pkg/domain/component/tax"
"myapp/pkg/domain/repository" // ✅ Repository も直接 import できる
)
type service struct {
orderRepo repository.OrderRepository // Repository に直接依存
paymentComp payment.Component // Component に依存
taxComp tax.Component
inventoryComp inventory.Component
notifyComp notification.Component
}
func (s *service) PlaceOrder(ctx context.Context, tx repository.Tx, userID string, input PlaceOrderInput) (*entity.Order, error) {
// 1. 在庫チェック(Component)
for _, item := range input.Items {
available, err := s.inventoryComp.CheckAvailability(ctx, item.ProductID, item.Quantity)
if err != nil {
return nil, err
}
if !available {
return nil, fmt.Errorf("商品 %s の在庫が不足しています", item.ProductID)
}
}
// 2. 税計算(Component)
taxResult, err := s.taxComp.Calculate(ctx, input.Items, input.ShippingAddress)
if err != nil {
return nil, err
}
// 3. 決済(Component)
paymentResult, err := s.paymentComp.Charge(ctx, tx, payment.ChargeInput{
UserID: userID, Amount: taxResult.Total, Method: input.PaymentMethod,
})
if err != nil {
return nil, err
}
// 4. 在庫引き当て(Component)
for _, item := range input.Items {
if err := s.inventoryComp.Reserve(ctx, tx, item.ProductID, item.Quantity); err != nil {
return nil, err
}
}
// 5. 注文レコード作成(Repository を直接利用)
order := &entity.Order{
UserID: userID, Subtotal: taxResult.Subtotal,
TaxAmount: taxResult.TaxAmount, Total: taxResult.Total, PaymentID: paymentResult.PaymentID,
}
if err := s.orderRepo.Create(ctx, tx, order); err != nil {
return nil, err
}
// 6. 注文完了通知(Component。「注文完了時に送る」という判断は Service が持つ)
_ = s.notifyComp.Send(ctx, tx, notification.SendInput{
UserID: userID,
Type: notification.TypeOrderComplete,
Title: "ご注文ありがとうございます",
Body: fmt.Sprintf("注文番号: %s / 合計: ¥%d", order.ID, order.Total),
})
return order, nil
}
逆方向は コンパイルエラー になります:
// pkg/domain/component/inventory/component.go
import "myapp/pkg/domain/service/order" // ❌ コンパイルエラー
良かった点
- 循環参照がコンパイラレベルで防止される
- Service の2種類の性質が構造として明示される
- スケーラビリティが高い
限界にぶつかった点
ここまで来て、チームで議論した結果、別の問題が見えてきました。
レイヤーが増えることの認知コスト。 クリーンアーキテクチャは Handler → Usecase → Service → Repository → Entity で十分に複雑です。ここに DomainComponent という新しい層を追加すると、新規参加者が理解すべき概念が1つ増えます。「この処理は Service に書くの? Component に書くの?」という判断が常に発生します。
ベストプラクティスから離れていく。 クリーンアーキテクチャ、オニオンアーキテクチャ、レイヤードアーキテクチャ、いずれの教科書にも DomainComponent 層は存在しません。プロジェクト固有の概念を作ることは、外部の知見(書籍、記事、採用した新メンバーの経験)が直接適用しにくくなることを意味します。
そもそもレイヤーを増やさずに解決できるのでは? 横断的サービスの実態を見ると、税計算はマスタデータを引いて計算するだけ、在庫チェックは在庫数と要求数を比較するだけ。これは本当に「Service」として独立する必要があるのか?
判断
DomainComponent 層は理論的には正しいが、レイヤーを増やすコストに見合うかを再検討すべき。既存の仕組みで解決できるなら、それに越したことはない。
アプローチ5: Facade で複数 Service を手続き的に呼び出す
アイデア
Handler に紐づかない横断的な処理フロー(複数 Service の組み合わせ)は、Facade パターン で手続き的にまとめる。
Ref: Facadeパターン
Handler → Usecase → Facade → 複数の Service を順番に呼ぶ
↓
Service A(注文)
Service B(在庫)
Service C(通知)
Usecase は1つの Handler に紐づく処理を書く場所であり、複数の Usecase から共通で呼ばれる「横断的な手続き」は Facade に切り出す。
実装例
// pkg/facade/order/facade.go
type Facade struct {
orderService order.Service
inventoryService inventory.Service
paymentService payment.Service
notifyService notification.Service
taxService tax.Service
}
// 注文確定の手続き(複数 Service の組み合わせ)
func (f *Facade) PlaceOrder(ctx context.Context, tx repository.Tx, userID string, input PlaceOrderInput) (*entity.Order, error) {
// 1. 在庫チェック
if err := f.inventoryService.CheckAvailability(ctx, input.Items); err != nil {
return nil, err
}
// 2. 税計算
taxResult, err := f.taxService.Calculate(ctx, input.Items, input.ShippingAddress)
if err != nil {
return nil, err
}
// 3. 決済
paymentResult, err := f.paymentService.Charge(ctx, tx, userID, taxResult.Total, input.PaymentMethod)
if err != nil {
return nil, err
}
// 4. 在庫引き当て
if err := f.inventoryService.Reserve(ctx, tx, input.Items); err != nil {
return nil, err
}
// 5. 注文作成
order, err := f.orderService.Create(ctx, tx, userID, taxResult, paymentResult)
if err != nil {
return nil, err
}
// 6. 通知
_ = f.notifyService.SendOrderComplete(ctx, tx, userID, order)
return order, nil
}
// usecase/order/usecase.go — Usecase は Facade を呼ぶだけ
func (u *usecase) PlaceOrder(ctx context.Context, input PlaceOrderInput) error {
return u.txFacade.Transaction(ctx, func(ctx context.Context, tx repository.Tx) error {
_, err := u.orderFacade.PlaceOrder(ctx, tx, userId, input)
return err
})
}
良かった点
- 新しいレイヤーは不要。 Facade は既にプロジェクトに存在するパターン(txFacade)を拡張するだけ
- Usecase の肥大化を防げる。 複数 Usecase から共通で呼ばれる手続きを Facade にまとめられる
- 各 Service は独立を保てる。 Service 間で呼び合わず、Facade が組み合わせる
気になる点
- Facade 自体が肥大化する可能性がある
- 「この処理は Usecase に書くの? Facade に書くの?」の判断基準が必要
Usecase と Facade の使い分け
| 層 | 基準 | 例 |
|---|---|---|
| Usecase | 1つの Handler(API エンドポイント)に紐づく処理 | 注文API、カートAPI |
| Facade | 複数の Usecase から共通で呼ばれる横断的な手続き | 注文確定フロー、入荷処理フロー |
アプローチ6: Entity + Util にロジックを持たせる
アイデア
ここでもう一つの気づきがありました。横断的サービスの中身を見ると、Entity に閉じたロジックが多い。
税計算の例:
- 税率マスタ(
TaxRateEntity)を取得 - 商品の価格に税率を掛ける
これは TaxRate Entity のメソッドにすべきではないか?
// Before: tax.Service に書いていた
func (s *taxService) Calculate(ctx context.Context, items []entity.OrderItem, addr entity.Address) (*TaxResult, error) {
rate, _ := s.taxRateRepo.GetByPrefecture(ctx, addr.Prefecture)
subtotal := 0
for _, item := range items {
subtotal += item.Price * item.Quantity
}
taxAmount := int(float64(subtotal) * rate.Rate)
return &TaxResult{Subtotal: subtotal, TaxAmount: taxAmount, Total: subtotal + taxAmount}, nil
}
// After: TaxRate Entity のメソッドにする
func (r *TaxRate) Calculate(items []entity.OrderItem) *TaxResult {
subtotal := 0
for _, item := range items {
subtotal += item.Price * item.Quantity
}
taxAmount := int(float64(subtotal) * r.Rate)
return &TaxResult{Subtotal: subtotal, TaxAmount: taxAmount, Total: subtotal + taxAmount}
}
Service が「税率マスタを取得して Entity のメソッドを呼ぶ」だけになり、独立した税計算サービスが不要になります。
Entity に持たせるもの
Entity はそもそも以下のようなメソッドを持つことが多い:
// Getter / Setter
func (u *User) GetEmail() string
func (u *User) SetEmail(email string)
// 状態判定
func (u *User) IsActive() bool
func (o *Order) IsShipped() bool
func (o *Order) IsCancellable() bool
// 自己完結するビジネスロジック
func (o *Order) Cancel()
func (i *Inventory) CanReserve(quantity int) bool
func (r *TaxRate) Calculate(items []OrderItem) *TaxResult
ドメイン知識を持たない関数は Util に置く
Entity に全てを押し込むと肥大化します。重要な区別は:
| 置き場 | 基準 | 例 |
|---|---|---|
| Entity メソッド | そのドメインの知識を必要とする |
order.IsCancellable(), taxRate.CalculateTax()
|
| Util | ドメイン知識を持たない汎用関数 |
mathutil.RoundDown(), timeutil.IsWithinDays()
|
// mathutil — ドメイン知識なし。「小数点以下を切り捨てて整数にする」だけ
func RoundDown(value float64) int
// TaxRate Entity — ドメイン知識あり。「この税率設定で、商品リストの税額を計算する」
func (r *TaxRate) CalculateTax(subtotal int) int {
return mathutil.RoundDown(float64(subtotal) * r.Rate)
}
TaxRate.CalculateTax() は「税率をどう適用するか」というドメイン知識を持ちますが、「小数点以下の切り捨て」はドメインに依存しない汎用処理なので Util に置きます。Entity は Util を呼べるが、Util は Entity を知らない。これは自然な依存方向です。
良かった点
-
新しい層が不要。 既存の
_ext.go(Entity 拡張)パターンに乗るだけ - テストが純粋。 Entity メソッドは DB もモックも不要。引数を渡すだけのテーブルドリブンテスト
- DDD の王道。 Entity に振る舞いを持たせる Rich Domain Model
- 循環参照が構造的に不可能。 Entity 層は Service 層を import しない
気になる点
- Entity が肥大化するリスク → ドメイン知識を持たない計算は Util に切り出すことで軽減
- 複数の Service で「マスタ取得 → Entity メソッド呼び出し」が繰り返される → 2行程度の重複なので許容範囲
最終判断: レイヤーを増やさない
6つのアプローチを検討した結果、DomainComponent 層は作らないという判断に至りました。
理由
-
レイヤーを増やすとベストプラクティスから離れる。 クリーンアーキテクチャの教科書に DomainComponent は存在しない。プロジェクト固有の概念は認知コストが高く、新メンバーのオンボーディングを遅くする
-
既存の仕組みで十分に解決できる。 横断的処理の大半は以下の2パターンに分解できる:
- Entity に閉じたロジック → Entity メソッド + Util で実装
- 複数 Service の手続き的な組み合わせ → Facade で実装
-
できる限りレイヤーの追加は回避したい。 追加すべき正当な理由がない限り、既存のアーキテクチャの中で解決する方が健全
最終的な設計方針
横断的なロジックの置き場:
1. Entity に閉じたロジック(判定・計算)
→ Entity の _ext.go にメソッドとして実装
→ ドメイン知識を持たない関数は util に切り出す
→ 例: ResetConfig.NeedsReset(), TaxRate.Calculate()
2. 複数 Service の組み合わせ(手続き的フロー)
→ Facade で実装
→ 例: 注文確定フロー(在庫チェック → 税計算 → 決済 → 注文作成 → 通知)
3. どちらにも収まらないもの
→ Domain Service として定義
→ 例: 複数の Entity を操作する送金処理など
各アプローチの比較(最終版)
| 観点 | Usecase で組み合わせ | interface 切り出し | 1パッケージ化 | DomainComponent | Facade + Entity |
|---|---|---|---|---|---|
| 循環参照の防止 | ✅ | ✅ | ✅ | ✅ | ✅ |
| ドメインロジックの所在 | ❌ Usecase に流出 | ✅ Service | ✅ Service | ✅ Component | ✅ Entity |
| 新しい層 | 不要 | 不要 | 不要 | 必要 | 不要 |
| 認知コスト | 低 | 中 | 低 | 高 | 低 |
| テスト容易性 | ⚠️ モック多数 | ✅ | ⚠️ 境界曖昧 | ✅ | ✅ 純粋関数テスト |
| ベストプラクティスとの一致 | ✅ | ✅ | ❌ | ❌ 独自概念 | ✅ DDD の王道 |
まとめ
❶ 循環参照にぶつかる
↓
❷ Usecase で全部組み合わせる → Usecase が肥大化、ロジック重複
↓
❸ interface を別パッケージに切り出す → ファイル散乱、根本解決でない
↓
❹ 1パッケージにまとめる → 大規模で破綻
↓
❺ Service に2種類ある(機能固有 / 横断的)ことに気づく
↓
❻ DomainComponent 層を検討 → 理論的には正しいが、レイヤー追加の認知コストが高い
↓
❼ 横断的ロジックの実態を分析 → 大半は Entity に閉じた判定/計算か、複数 Service の手続き
↓
❽ Entity + Util にロジックを持たせ、Facade で複数 Service を組み合わせる
→ レイヤーを増やさず、既存の仕組みで解決
DomainComponent 層は理論的に正しいアプローチですが、レイヤーを増やすコストを過小評価してはいけません。
横断的なロジックに直面したとき、まずは以下を試してください:
- そのロジック、Entity のメソッドにできないか? — 自己完結する判定・計算なら Entity が適切。ドメイン知識を持たない部分は Util に切り出す
- 複数 Service の組み合わせなら Facade にできないか? — 手続き的なフローは Facade がまとめる
- それでも足りなければ Domain Service にする — 複数の Entity を操作するビジネスロジック
それでもまだ足りない、Service が50個を超えて依存グラフが手に負えない、という規模になったとき、初めて DomainComponent 層のようなレイヤー追加を検討すればいいでしょう。
できる限りレイヤーの追加は回避する。追加すべき正当な理由がない限り、既存のアーキテクチャの中で解決する。 これが私たちの結論です。