目次
はじめに
エンジニアとして初めてクリーンアーキテクチャを意識した設計に挑んだ際、
多くの迷いや失敗を経験しました。
本記事では、実務プロジェクトを例に
- 何を意識したか
- どこでつまずいたか
- 得られた学び
- 最終的に定着させた設計方法
を整理し、同じ壁にぶつかった方のヒントにしていただければと思います。
開発における設計の意識ポイント
-
一方向依存
各層が「外側から内側へ」だけ依存し、循環参照を絶対に起こさない -
責務の明確化 (SRP)
モジュール/クラスは「変更理由」をひとつに絞る -
抽象(ポート)と実装(アダプタ)の分離 (DIP)
ビジネスロジックはインターフェイスにのみ依存し、具体実装は外部層に閉じ込める -
YAGNI
PoC フェーズでは「必要になるまで」層も抽象も増やさない
試行錯誤フェーズとつまずき
フェーズ | つまずき/課題 | 解決策 & 理由 |
---|---|---|
1. レイヤード設計 | - usecase ↔ controller が双方向依存- service 層に I/O が混在 |
- 依存方向を整理:controller → usecase → domain ← infrastructure に再定義 |
2. インフラ配置 | - SDK や DB モックをどこに置くか迷う | - domain/ports に抽象を定義- infrastructure に具体実装をまとめ、モック注入を容易化 |
3. ドメイン層の重複 | - DTO とドメインモデルの二重定義 | - ドメイン層はエンティティ+ポートのみ - DTO は usecase 層で一元管理 |
4. CLI エントリ | - cmd/cli と cmd/server を分けるか悩む- レイヤ数が多くて重い |
- CLI 一本化 (cmd/main.go ) へ集約- サーバ起動はオプションで後付け |
5. 循環依存防止 | - infrastructure が domain を参照しきれず実装が散らばる | - DIP 徹底:domain がポートのみ持ち、infra がそれを実装 |
そこから得られた学び
-
依存性逆転 (DIP)
- Domain 層は「ポート(インターフェイス)」のみ定義
- Infrastructure 層がそのポートを実装することで、技術的詳細を隔離
-
一方向依存の継続確認
- 実装のたびに
外側 → 内側
をレビューし、逆転を防止
- 実装のたびに
-
YAGNI の体現
- PoC 段階では最小限のディレクトリ/抽象のみ用意
- 永続化やサーバ実装は「要件が発生した時点」で追加
-
SOLID 原則で責務分離
- SRP: 各クラス・レイヤはたった一つの理由で変更
- OCP: インターフェイス経由で後から機能拡張しやすく
- LSP/ISP: モック/実装を容易に交換可能に
最終的に定着した設計方法
- ディレクトリ構成
project-root/
├─ cmd/ ← CLI/サーバ起動
├─ internal/
│ └─ app/
│ ├─ controller/ ← プレゼンテーション層
│ ├─ usecase/ ← アプリケーション層 (DTO+ユースケース)
│ ├─ domain/ ← ドメイン層 (エンティティ+ポート)
│ └─ infrastructure/ ← インフラ層 (ポート実装)
└─ configs/ ← 設定読み込み
SOLID原則の適用例
1. 単一責任の原則(SRP)
Domain層はビジネスルールだけを持つ
// domain/planning.go
package domain
type Planning struct {
Name string
ProductMenu ProductMenu
// ...
}
func (p *Planning) Validate() error {
// ビジネスルール検証のみ
if p.ProductMenu != ProductMenuAudienceReach &&
p.ProductMenu != ProductMenuAIProgramMatch &&
p.ProductMenu != ProductMenuAudienceReachNonGuaranteed {
return errors.New("無効なメニュー")
}
return nil
}
2.オープン・クローズドの原則(OCP)
インターフェイス経由で拡張可能
// usecase/planning.go
package usecase
import (
"context"
"親ディレクトリ/domain"
)
type AIGenerator interface {
Generate(ctx context.Context, prompt string, schema map[string]interface{}) (PlanningDTO, error)
}
type PlanningUsecase struct {
aiGenerator AIGenerator // インターフェイスに依存している
repository domain.PlanningRepository
}
3.リスコフの置換原則(LSP)
モックと本番実装が同じインターフェイスを満たす
// infrastructure/grpc/planning.go
package grpc
import (
"context"
"親ディレクトリ/domain"
)
type PlanningClient struct {
// gRPCクラインアント設定など
}
func (c *PlanningClient) Save(ctx context.Context, planning *domain.Planning, userUUID, userEmail string) (string, error) {
if err := c.connect(); err != nil {
// 接続失敗時はモックでフォールバック
return c.saveMock(planning)
}
// プロダクション実装
return c.saveToPlanner(ctx, planning, userUUID, userEmail)
}
4.インターフェイス分離の原則(ISP)
必要最小限のメソッドだけを定義
// domain/repository.go
package domain
import "context"
type PlanningRepository interface {
Save(ctx context.Context, planning *Planning, userUUID, userEmail string) (string, error)
}
5.依存性逆転の原則(DIP)
Domain層がインターフェイスにのみ依存し、Infrastructureが実装
// domain/repository.go
package domain
type PlanningRepository interface {
Save(ctx context.Context, planning *Planning, userUUID, userEmail string) (string, error)
}
// infrastructure/grpc/planning.go
package grpc
import (
"context"
"親ディレクトリ/domain"
)
type PlanningClient struct {
// ...
}
func (c *PlanningClient) Save(ctx context.Context, planning *domain.Planning, userUUID, userEmail string) (string, error) {
// gRPC呼び出し実装
return "", nil
}
最終的に定着した設定方法
1.依存関係のルール
┌─────────────┐
│ Controller │
└──────┬──────┘
↓
┌─────────────┐
│ Usecase │
└──────┬──────┘
↓
┌─────────────┐ ┌──────────────────┐
│ Domain │ ← ─ │ Infrastructure │
└─────────────┘ └──────────────────┘
2.開発のフロー
- usecase/domainのモデル&ポートを先に定義
- CLIから入力 → controller → usecase → domainでビジネス処理
- Infrastructureがポートを実装、テスト時はモックを差し替える
まとめ
設計の初期段階で多くの失敗で得たものは、「シンプルな依存関係」と「責務の分離」を徹底することの重要性です。
- YAGNI:必要になるまで抽象と層を増やさない
- DIP&一方向依存:Domainを純粋に保ち、技術的詳細を外部層へ
- SOLID:各原則で責務を切り分け、テストしやすい構造を目指す
これらを実践すると、保守性・拡張性の高いコードやファイルディレクトリが出来上がると考えました。