はじめに
「クリーンアーキテクチャ」や「ドメイン駆動設計(DDD)」という言葉を聞いたことはありますか?
これらの設計手法は、複雑なソフトウェアを整理された形で構築するための指針です。しかし、概念だけでは理解しにくいものです。
この記事では、筆者が学習目的で作成した Go プロジェクト(logs-collector-api) を例に、クリーンアーキテクチャと DDD がどのように実装されているかを直感的に理解できるよう解説します。
プロジェクト概要
今回取り上げるのは、学習用に実装した 分散システム向けのログ収集 API です。
📋 実装リポジトリ: logs-collector-api
logs-collector-api/
├── internal/
│ ├── domain/ # ドメイン層
│ ├── app/ # アプリケーション層
│ ├── infra/ # インフラ層
│ └── pkg/ # 共通パッケージ
├── cmd/ # エントリーポイント
└── tests/ # テスト
この記事では、上記のリポジトリの実装を参照しながら、クリーンアーキテクチャと DDD の実践例を解説していきます。
1. クリーンアーキテクチャとは?
1.1 基本的な考え方
クリーンアーキテクチャは、依存関係の方向を制御する設計手法です。
┌─────────────────────────────────────┐
│ 🌐 Presentation Layer │ ← ユーザーとの接点
│ (REST/gRPC API) │
│ │ │
│ ↓ │
├─────────────────────────────────────┤
│ 🧠 Application Layer │ ← ビジネスルール
│ (ユースケース) │
│ │ │
│ ↓ │
├─────────────────────────────────────┤
│ 💎 Domain Layer │ ← ビジネス概念
│ (モデル・インターフェース) │
│ ↑ │
│ │ │
├─────────────────────────────────────┤
│ 🔧 Infrastructure Layer │ ← 外部システム
│ (DB・外部API実装) │
└─────────────────────────────────────┘
重要なポイント
- Infrastructure 層が Domain 層に依存(依存性逆転)
- Domain 層は他のどの層にも依存しない(独立性)
- 外側の層ほど具体的、内側の層ほど抽象的
- 内側の層は外側の層に依存してはいけない
💡 レストランに例えると
層 | 役割 | 説明 |
---|---|---|
🌐 Presentation 層 | ホールスタッフ | お客様の注文を受け取り、料理を提供する |
🧠 Application 層 | 店長 | ホール・厨房・仕入れの連携を調整 |
💎 Domain 層 | 料理長 | 🔥 レシピと品質基準を決定(他に依存しない) |
🔧 Infrastructure 層 | 厨房・仕入れ先 | 👨🍳 料理長のレシピに従って実際に調理 |
依存の流れ: 厨房は料理長の指示に従う(Infrastructure → Domain)
※ ホール(接客)、店長(運営)、料理長(レシピ)、厨房(調理)がそれぞれ専門性を持ち、他の部門の仕事には口出ししない。
各層の独立性:
- 単一責任の原則 – 各層は 1 つの明確な責任を持つ
- 関心の分離 – 技術的関心とビジネス関心が明確に分離
- 変更の局所化 – 1 つの層の変更が他の層に影響しない
- テスト容易性 – 各層を独立してテストできる
1.2 なぜこの設計が必要?
従来の設計では、ビジネスロジックがデータベースに依存していました:
func SaveUser(user *User) error {
if len(user.Password) 8 {
return errors.New("パスワードは8文字以上必要")
}
return db.Exec("INSERT INTO users VALUES (?, ?, ?)",
user.Name, user.Email, user.Password)
}
問題点: データベースを変更するとビジネスロジックも修正が必要
クリーンアーキテクチャでの解決
func (u *User) IsValidPassword() error {
if len(u.Password) 8 {
return errors.New("パスワードは8文字以上必要")
}
return nil
}
type UserRepository interface {
Save(user *User) error
}
func SaveUser(user *User, repo UserRepository) error {
if err := user.IsValidPassword(); err != nil {
return err
}
return repo.Save(user)
}
メリット: データベースが変わってもビジネスロジックは無変更
2. 実際のプロジェクトで見るクリーンアーキテクチャ
2.1 ドメイン層(Domain Layer)(最内側)
ドメイン層は、ビジネスの核心となる概念とルールを表現します。
type Log struct {
ID string
TraceID string
Timestamp time.Time
Level string
Service string
Message string
Metadata map[string]string
}
type LogRepository interface {
SendLog(ctx context.Context, log *model.Log) error
GetLogs(ctx context.Context, service string, level string, limit int, offset int) ([]model.Log, error)
}
重要なポイント:
- 🏛️ ビジネス概念の表現 – ログとは何か、どんな属性を持つかを定義
- 🔒 完全な独立性 – データベースや API など外部技術に一切依存しない
- 📐 抽象化されたインターフェース – 「どうやって」ではなく「何ができるか」を定義
- 💎 純粋なビジネスロジック – エンティティ、値オブジェクト、ドメインサービスなど
- 🎯 変更に最も強い層 – 外部の技術変更の影響を受けない
💡 レストランに例えると
料理長が決める「ハンバーガーとは何か」「美味しさの基準」のような、お店の核となる知識とルールがここにある。厨房の設備や仕入れ先が変わっても、この基準は変わらない。
2.2 アプリケーション層(Application Layer)(中間)
アプリケーション層は、ユースケース(ビジネスフロー)を実装し、複数のサービスを調整します。
type LogUseCase interface {
SendLog(ctx context.Context, log *model.Log) error
GetLogs(ctx context.Context, service string, level string, limit, offset int) ([]model.Log, error)
}
type LogUseCaseImpl struct {
logRepo repository.LogRepository
producer queue.LogProducer
searcher search.LogIndexer
logger logger.Logger
}
func (uc *LogUseCaseImpl) SendLog(ctx context.Context, logEntry *model.Log) error {
if err := uc.logRepo.SendLog(ctx, logEntry); err != nil {
return fmt.Errorf("%w: %w", ErrRepositoryFailure, err)
}
msg := queue.LogMessage{...}
if err := uc.producer.Publish("logs.send", msg); err != nil {
uc.logger.Error("Failed to publish log to NATS", err)
}
esDoc := map[string]interface{}{...}
if err := uc.searcher.IndexLog("logs-index", esDoc); err != nil {
uc.logger.Error("Failed to index log to Elasticsearch", err)
}
return nil
}
重要なポイント:
- 🎯 ユースケースの実装 – 「ログを送信する」という業務フローを具現化
- 🔀 オーケストレーション – DB 保存 → NATS 送信 → ES 検索という複数処理を調整
- 🔌 依存性注入 – インターフェースに依存し、具体実装は知らない
- ⚠️ エラーハンドリング – 各ステップの失敗を適切に処理
- 🏗️ トランザクション管理 – 複数操作の整合性を保つ
- 📋 ビジネスフローの表現 – 単純な CRUD を超えた業務ロジック
💡 レストランに例えると
店長が「お客様の注文」を受けて、「厨房で調理 → 盛り付け → 配膳 → 会計処理」という一連の流れを指示し、各部門を調整する役割。
2.3 インフラ層(Infrastructure Layer)(最外側)
インフラ層は、外部システムとの具体的な連携を実装し、ドメインの抽象概念を技術的に実現します。
type LogRepository struct {
db boil.ContextExecutor
logger logger.Logger
}
func (r *LogRepository) SendLog(ctx context.Context, log *model.Log) error {
logDB := models.Log{
ID: log.ID,
TraceID: null.StringFrom(log.TraceID),
Timestamp: log.Timestamp,
Level: log.Level,
Service: log.Service,
Message: log.Message,
Metadata: null.JSONFrom(metadataJSON),
}
return logDB.Insert(ctx, r.db, boil.Infer())
}
重要なポイント:
- 🔌 具体的技術の実装 – PostgreSQL、SQLBoiler など特定技術に依存
- 🔄 データ変換の責任 – ドメインモデル ↔ DB モデルの変換を担当
- 📡 外部システム連携 – データベース、API、メッセージキューとの実際の通信
- 🎭 インターフェースの実装 – ドメイン層で定義した抽象を具体化
- ⚙️ 技術詳細に特化 – SQL 文、HTTP リクエスト、ファイル I/O など
- 🔧 変更されやすい層 – 技術選択の変更時に最も影響を受ける
💡 レストランに例えると
厨房のコック達が、料理長のレシピ(ドメイン)に従って、実際にガスコンロやオーブンを使って料理を作る部分。道具や仕入れ先が変わっても、レシピ(ビジネスルール)は変わらない。
2.4 プレゼンテーション層(Presentation Layer)
プレゼンテーション層は、外部からのリクエストを受け付け、適切な形でレスポンスを返します。
type LogHandler struct {
logUseCase usecase.LogUseCase
logger logger.Logger
}
func (h *LogHandler) SendLog(echoCtx echo.Context) error {
logEntry := &model.Log{
ID: logID,
TraceID: log.GetTraceId(),
Timestamp: log.GetTimestamp().AsTime(),
Level: log.GetLevel(),
Service: log.GetService(),
Message: log.GetMessage(),
Metadata: metadata,
}
if err := h.logUseCase.SendLog(echoCtx.Request().Context(), logEntry); err != nil {
return RespondJSON(echoCtx, http.StatusInternalServerError,
ErrorResponse{Error: err.Error()})
}
return RespondJSON(echoCtx, http.StatusOK, SuccessResponse{Status: "success"})
}
重要なポイント:
- 🌐 外部との接点 – REST API、gRPC、WebUI などのエンドポイントを提供
- 🔄 データ変換 – HTTP リクエスト ↔ ドメインモデルの変換を担当
- ✅ 入力検証 – リクエストデータのバリデーションを実行
- 📤 レスポンス生成 – 適切な形式(JSON、XML 等)でレスポンスを返却
- 🎭 プロトコル依存 – HTTP、gRPC など特定の通信プロトコルに依存
- ➡️ ユースケース呼び出し – ビジネスロジックの実行をアプリケーション層に委譲
💡 レストランに例えると
ホールスタッフがお客様から注文を受け取り、「ハンバーガー 1 つ、ポテト 1 つ」と正確に確認してから、店長(アプリケーション層)に伝える接客係。
3. 依存関係の流れ
実際の依存関係を見てみましょう:
┌─────────────────────────────────────┐
│ Handler (REST/gRPC) │ ← HTTP/gRPC フレームワークに依存
│ ↓ │
├─────────────────────────────────────┤
│ UseCase (ビジネスロジック) │ ← ドメインに依存
│ ↓ │
├─────────────────────────────────────┤
│ Domain (モデル・インターフェース) │ ← 外部依存なし
│ ↑ │
├─────────────────────────────────────┤
│ Repository (DB実装) │ ← PostgreSQL + ドメインに依存
│ Queue (NATS実装) │ ← NATS + ドメインに依存
│ Search (Elasticsearch実装) │ ← Elasticsearch + ドメインに依存
└─────────────────────────────────────┘
依存の流れ:
[Presentation] → [Application] → [Domain]
↑
[Infrastructure]
重要なポイント:
- 外側の層は内側の層に依存するが、その逆はない
- Infrastructure 層が Domain 層に依存(依存性逆転の原則)
- ドメイン層は外部技術に依存しない
💡 レストランに例えると
ホール → 店長 → 料理長(レシピ) ← 厨房 という流れで、厨房は料理長のレシピに従う。料理長はホールや厨房の事情を知らない。
**依存性注入(DI)**により、各層は抽象(インターフェース)に依存し、具体的な実装には依存しません。
4. DDD(ドメイン駆動設計)との組み合わせ
4.1 ドメインモデルの重要性
type Log struct {
ID string
TraceID string
Timestamp time.Time
Level string
Service string
Message string
Metadata map[string]string
}
このモデルは、ログ収集システムのビジネス要件を表現しています:
🎯 ビジネス概念の具現化
- 分散システムでのトレーシング
- サービス別のログ管理
- 重要度による分類
- 柔軟なメタデータ
💡 DDD の価値
- ドメインエキスパート(運用・SRE チーム)の知識をコードで表現
- ユビキタス言語 – 開発者と運用者が同じ用語でコミュニケーション
- ビジネスロジックの中央集約 – ログに関するルールが一箇所に集まる
- 技術詳細からの独立 – データベースや API が変わってもビジネス概念は不変
💡 レストランに例えると
「ハンバーガー = バン + パティ + 野菜」というレシピ(ドメインモデル)は、フライパンで焼こうがグリルで焼こうが変わらない。調理器具(技術)が変わっても、料理の定義(ビジネス概念)は不変。
4.2 ユビキタス言語
開発者と運用チームが同じ用語でコミュニケーションを取れるよう、コード全体で一貫した用語を使用:
type LogRepository interface {
SendLog(ctx context.Context, log *model.Log) error
GetLogs(ctx context.Context, service string, level string, limit int, offset int) ([]model.Log, error)
}
type LogUseCase interface {
SendLog(ctx context.Context, log *model.Log) error
GetLogs(ctx context.Context, service string, level string, limit, offset int) ([]model.Log, error)
}
重要なポイント:
-
業務用語とコードの一致 – 運用チーム「ログを送信する」→ コード
SendLog
- チーム間の共通言語 – 会議での用語とコードの用語が完全に一致
- 理解の統一 – 誰が読んでも同じ意味で理解できる
- コミュニケーション効率化 – 翻訳作業なしで業務とコードを議論
💡 レストランに例えると
「注文を受ける」「料理を作る」「お会計する」という言葉を、ホール・厨房・経理すべてで統一して使う。誰もが同じ用語で業務を理解できる。
5. 実際のメリット
5.1 テスタビリティ
従来の問題:
func TestSaveLogBadExample(t *testing.T) {
db, err := sql.Open("postgres", "host=localhost...")
if err != nil {
t.Fatal(err)
}
defer db.Close()
setupTestTables(db)
defer cleanupTestTables(db)
nc, err := nats.Connect("nats://localhost:4222")
if err != nil {
t.Fatal(err)
}
defer nc.Close()
service := NewLogService(db, nc)
err = service.SaveLog(&Log{...})
assert.NoError(t, err)
}
クリーンアーキテクチャでの解決:
func TestLogUseCase_SendLog(t *testing.T) {
mockRepo := &mock.LogRepository{}
mockProducer := &mock.LogProducer{}
mockSearcher := &mock.LogIndexer{}
useCase := NewLogUseCase(mockRepo, mockProducer, mockSearcher, logger)
}
メリット:
- 🚀 高速実行 – データベースやネットワーク不要
- 🔒 安定性 – 外部サービスの影響を受けない
- 🎯 焦点の明確化 – ビジネスロジックのみをテスト
- ⚡ 並列実行可能 – 各テストが完全に独立
従来との比較:
- 従来:実際の DB + 外部 API → セットアップ複雑、実行遅い
- クリーンアーキテクチャ:モックのみ → 軽量、高速、安定
💡 レストランに例えると
料理長のレシピ(ビジネスロジック)をテストするのに、実際の厨房や仕入れ先は不要。模擬的な材料と道具があれば十分。
5.2 技術の変更が容易
従来の問題: データベース変更時に全コード修正が必要
クリーンアーキテクチャでの解決:
データベースを PostgreSQL から MongoDB に変更する場合:
type LogUseCaseImpl struct {
logRepo repository.LogRepository
}
mongoRepo := mongodb.NewLogRepository(mongoClient, logger)
useCase := NewLogUseCase(mongoRepo, producer, searcher, logger)
メリット: ビジネスロジックの修正なし、新しい Repository 実装のみ追加
5.3 ビジネスロジックの独立性
func (uc *LogUseCaseImpl) SendLog(ctx context.Context, logEntry *model.Log) error {
if err := uc.logRepo.SendLog(ctx, logEntry); err != nil {
return fmt.Errorf("%w: %w", ErrRepositoryFailure, err)
}
if err := uc.producer.Publish("logs.send", msg); err != nil {
uc.logger.Error("Failed to publish log to NATS", err)
}
return nil
}
メリット: ビジネスルールが技術選択に左右されない
6. リクエスト → レスポンスの全体フロー(SendLog / REST)
┌───────────────────────────────┐
│ クライアント(REST) │
│ POST /api/v1/logs │
└───────────────┬───────────────┘
│ ① リクエスト送信
▼
┌───────────────────────────────┐
│ プレゼンテーション層(Handler)│
│ - JSONパース │
│ - ドメインモデル(Log)生成 │
│ - usecase.SendLog 呼び出し │
└───────────────┬───────────────┘
│ ② ユースケース実行
▼
┌───────────────────────────────┐
│ アプリケーション層(UseCase) │
│ - Repository.SendLog (DB保存) │ ←★必須(失敗で打ち切り)
│ - Producer.Publish (NATS) │ ←非同期的:失敗はログのみ
│ - Search.IndexLog (ES) │ ←非同期的:失敗はログのみ
└───────────────┬───────────────┘
│ ③ 抽象IF越しに依頼
▼
┌───────────────────────────────┐
│ ドメイン層(Model / IF) │
│ - Log エンティティ │
│ - Repository/Producer/Search │
│ のインターフェース │
└───────────────┬───────────────┘
│ ④ 具体実装へ委譲
▼
┌───────────────────────────────┐
│ インフラ層(具体実装) │
│ - PostgreSQL: INSERT │
│ - NATS: Publish │
│ - Elasticsearch: Index │
└───────────────────────────────┘
│
┌──────────┴──────────┐
│成功(DB保存OK) │失敗(DB保存NG)
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Handlerに成功戻り │ │ Handlerにエラー戻り│
└───────┬─────────┘ └───────┬─────────┘
│⑤ レスポンス決定 │⑤ レスポンス決定
▼ ▼
┌───────────────┐ ┌────────────────┐
│ 200 OK │ │ 500 Internal │
│ {"status":"ok"}│ │ Server Error │
└───────┬─────────┘ └───────┬─────────┘
│⑥ クライアントへ返却 │⑥ クライアントへ返却
▼ ▼
┌───────────────────────────────┐
│ クライアント(REST) │
│ レスポンス受け取り │
└───────────────────────────────┘
💡 重要なポイント
- DB 保存(必須) – 失敗時は即座にエラーレスポンス
- NATS/ES(非同期) – 失敗してもメイン処理は継続
- 依存性逆転 – UseCase → Interface → 具体実装の流れ
💡 レストラン版例え
[お客様] が注文票を提出(①)
↓
[ホールスタッフ] が注文票を作成し料理長へ(②)
↓
[料理長] が必須工程(食材の仕入れ)を実行。失敗したら提供中止。
その後、デザートや宣伝(NATS/ES)は任意で実行(失敗は記録のみ)。
↓
[レシピ] に従って調理指示(③〜④)
↓
[厨房・仕入れ先] が食材を用意&調理(PostgreSQL/NATS/ES)
↓
成功:料理を提供(⑤ 成功)
失敗:お詫びと返金(⑤ 失敗)
↓
[お客様] が料理またはエラーメッセージを受け取る(⑥)
7. まとめ
クリーンアーキテクチャと DDD の組み合わせにより:
- 依存関係が明確:内側から外側への一方向
- ビジネスロジックが独立:技術の変更に影響されない
- テスタビリティが向上:モックを使った単体テストが容易
- 保守性が向上:各層の責任が明確
- 拡張性が向上:新しい機能の追加が容易
実際のプロジェクトでは:
- データベースの変更が容易
- 新しい外部サービスの追加が簡単
- ビジネスルールの変更が局所的
- チーム開発での並行作業が可能
💡 レストランに例えると
各部門(ホール・店長・料理長・厨房)が独立しているため、新しい調理器具の導入や、メニューの追加も他部門に影響なく実施できる。
これらの設計原則により、長期的に保守しやすいソフトウェアを構築できます。
Views: 0