【Go】段階的に理解するクリーンアーキテクチャとリファクタリング - 実践ガイド

こんにちは！フリーランスエンジニアのこたろうです。
今回は、Goのコードをよりメンテナンスしやすく、テストしやすい構造に改善する方法について、実践的な知見を共有します。

## 1. なぜリファクタリングが必要か

現実のプロジェクトでは、以下のようなコードをよく目にします：

```go
// 一般的によく見るコード
func handleTasks(w http.ResponseWriter, r *http.Request) {
    // DB接続
    db, err := sql.Open("postgres", "...")
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    
    // データ取得
    rows, err := db.Query("SELECT * FROM tasks")
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    
    // データ変換とビジネスロジック
    var tasks []Task
    for rows.Next() {
        var task Task
        err := rows.Scan(&task.ID, &task.Title)
        if err != nil {
            http.Error(w, err.Error(), 500)
            return
        }
        // ここでビジネスロジックも混ざっている
        if task.DueDate.Before(time.Now()) {
            task.Status = "期限切れ"
        }
    }
    
    // レスポンス返却
    json.NewEncoder(w).Encode(tasks)
}
```

このコードには以下の問題があります：

1. **テストの難しさ**
   - データベース接続が必要
   - ビジネスロジックの単独テストが困難
   - HTTP部分のモック化が必要

2. **保守性の低さ**
   - 1つの関数が複数の責務を持っている
   - コードの見通しが悪い
   - 変更の影響範囲が大きい

3. **再利用性の欠如**
   - ビジネスロジックが他の場所で使えない
   - データベース処理が結合している
   - エラーハンドリングが散在

## 2. クリーンアーキテクチャの基本構造

クリーンアーキテクチャでは、アプリケーションを以下の3つの層に分けて設計します：

### 2.1 各層の役割と依存関係

```
[Controller層] → [Service層] → [Repository層] → [Database]
    ↓               ↓             ↓
 HTTPリクエスト   ビジネス     データアクセス
  レスポンス      ロジック       ロジック
```

重要なポイント：
- 依存関係は内側に向かう（Controller → Service → Repository）
- 外側の層は内側の層を知っているが、その逆はない
- インターフェースを使って依存関係を逆転させる

### 2.2 具体的な実装例

```go
// Domain（ドメインモデル）
type Task struct {
    ID          uint      `json:"id" gorm:"primaryKey"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Status      string    `json:"status"`
    DueDate     time.Time `json:"due_date"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// Repository Interface
type TaskRepository interface {
    FindAll() ([]Task, error)
    FindByID(id uint) (*Task, error)
    Create(task *Task) error
    Update(task *Task) error
    Delete(id uint) error
}

// Service Interface
type TaskService interface {
    GetAllTasks() ([]Task, error)
    GetTaskByID(id uint) (*Task, error)
    CreateTask(task *Task) error
    UpdateTask(task *Task) error
    DeleteTask(id uint) error
}
```

## 3. 層ごとの詳細実装

### 3.1 Repository層の実装

```go
type TaskRepositoryImpl struct {
    db *gorm.DB
}

func NewTaskRepository(db *gorm.DB) TaskRepository {
    return &TaskRepositoryImpl{db: db}
}

func (r *TaskRepositoryImpl) FindAll() ([]Task, error) {
    var tasks []Task
    result := r.db.Find(&tasks)
    if result.Error != nil {
        return nil, fmt.Errorf("failed to fetch tasks: %w", result.Error)
    }
    return tasks, nil
}

func (r *TaskRepositoryImpl) Create(task *Task) error {
    return r.db.Transaction(func(tx *gorm.DB) error {
        if err := tx.Create(task).Error; err != nil {
            return fmt.Errorf("failed to create task: %w", err)
        }
        return nil
    })
}

// その他のメソッド実装...
```

### 3.2 Service層の実装

```go
type TaskServiceImpl struct {
    repo TaskRepository
}

func NewTaskService(repo TaskRepository) TaskService {
    return &TaskServiceImpl{repo: repo}
}

func (s *TaskServiceImpl) GetAllTasks() ([]Task, error) {
    tasks, err := s.repo.FindAll()
    if err != nil {
        return nil, fmt.Errorf("failed to get tasks: %w", err)
    }

    // ビジネスロジックの適用
    for i := range tasks {
        if tasks[i].DueDate.Before(time.Now()) {
            tasks[i].Status = "期限切れ"
        }
    }

    return tasks, nil
}

func (s *TaskServiceImpl) CreateTask(task *Task) error {
    // バリデーション
    if err := s.validateTask(task); err != nil {
        return fmt.Errorf("validation error: %w", err)
    }

    // デフォルト値の設定
    task.Status = "未着手"
    task.CreatedAt = time.Now()

    return s.repo.Create(task)
}

func (s *TaskServiceImpl) validateTask(task *Task) error {
    if task.Title == "" {
        return errors.New("title is required")
    }
    if task.DueDate.IsZero() {
        return errors.New("due date is required")
    }
    return nil
}
```

### 3.3 Controller層の実装

```go
type TaskController struct {
    service TaskService
}

func NewTaskController(service TaskService) *TaskController {
    return &TaskController{service: service}
}

func (c *TaskController) GetTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := c.service.GetAllTasks()
    if err != nil {
        c.handleError(w, err)
        return
    }

    c.respondWithJSON(w, http.StatusOK, tasks)
}

func (c *TaskController) CreateTask(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title       string    `json:"title"`
        Description string    `json:"description"`
        DueDate     time.Time `json:"due_date"`
    }

    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        c.handleError(w, fmt.Errorf("invalid request body: %w", err))
        return
    }

    task := &Task{
        Title:       input.Title,
        Description: input.Description,
        DueDate:     input.DueDate,
    }

    if err := c.service.CreateTask(task); err != nil {
        c.handleError(w, err)
        return
    }

    c.respondWithJSON(w, http.StatusCreated, task)
}

func (c *TaskController) handleError(w http.ResponseWriter, err error) {
    // エラーの種類に応じてステータスコードを変える
    statusCode := http.StatusInternalServerError
    if errors.Is(err, gorm.ErrRecordNotFound) {
        statusCode = http.StatusNotFound
    }

    c.respondWithJSON(w, statusCode, map[string]string{
        "error": err.Error(),
    })
}

func (c *TaskController) respondWithJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}
```

## 4. テストの実装

### 4.1 Repository層のテスト

```go
func TestTaskRepository_FindAll(t *testing.T) {
    // モックDBのセットアップ
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("failed to create mock db: %v", err)
    }

    gormDB, err := gorm.Open(postgres.New(postgres.Config{
        Conn: db,
    }), &gorm.Config{})
    if err != nil {
        t.Fatalf("failed to create gorm db: %v", err)
    }

    // モックの期待値を設定
    rows := sqlmock.NewRows([]string{"id", "title", "status"}).
        AddRow(1, "Task 1", "未着手").
        AddRow(2, "Task 2", "完了")

    mock.ExpectQuery(`SELECT \* FROM "tasks"`).
        WillReturnRows(rows)

    // テスト実行
    repo := NewTaskRepository(gormDB)
    tasks, err := repo.FindAll()

    // アサーション
    assert.NoError(t, err)
    assert.Len(t, tasks, 2)
    assert.Equal(t, "Task 1", tasks[0].Title)
}
```

### 4.2 Service層のテスト

```go
type MockTaskRepository struct {
    mock.Mock
}

func (m *MockTaskRepository) FindAll() ([]Task, error) {
    args := m.Called()
    return args.Get(0).([]Task), args.Error(1)
}

func TestTaskService_GetAllTasks(t *testing.T) {
    // モックリポジトリのセットアップ
    mockRepo := new(MockTaskRepository)
    service := NewTaskService(mockRepo)

    // モックの期待値を設定
    mockRepo.On("FindAll").Return([]Task{
        {ID: 1, Title: "Task 1", Status: "未着手"},
        {ID: 2, Title: "Task 2", Status: "完了"},
    }, nil)

    // テスト実行
    tasks, err := service.GetAllTasks()

    // アサーション
    assert.NoError(t, err)
    assert.Len(t, tasks, 2)
    mockRepo.AssertExpectations(t)
}
```

## 5. エラーハンドリングとトランザクション管理

### 5.1 カスタムエラーの定義

```go
type ValidationError struct {
    Field string
    Error string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Error)
}

func IsValidationError(err error) bool {
    var validationErr *ValidationError
    return errors.As(err, &validationErr)
}
```

### 5.2 トランザクション管理

```go
func (r *TaskRepositoryImpl) CreateWithRelations(task *Task, relations []Relation) error {
    return r.db.Transaction(func(tx *gorm.DB) error {
        // タスクの作成
        if err := tx.Create(task).Error; err != nil {
            return fmt.Errorf("failed to create task: %w", err)
        }

        // 関連データの作成
        for _, relation := range relations {
            relation.TaskID = task.ID
            if err := tx.Create(&relation).Error; err != nil {
                return fmt.Errorf("failed to create relation: %w", err)
            }
        }

        return nil
    })
}
```

## 6. パフォーマンス最適化のポイント

1. **クエリの最適化**
```go
func (r *TaskRepositoryImpl) FindAllWithPreload() ([]Task, error) {
    var tasks []Task
    result := r.db.
        Preload("Assignee").     // 関連データを事前読み込み
        Preload("Comments").
        Find(&tasks)
    return tasks, result.Error
}
```

2. **キャッシュの利用**
```go
type CachedTaskRepository struct {
    repo  TaskRepository
    cache *redis.Client
}

func (r *CachedTaskRepository) FindByID(id uint) (*Task, error) {
    // キャッシュから取得を試みる
    if cached, err := r.getFromCache(id); err == nil {
        return cached, nil
    }

    // DBから取得
    task, err := r.repo.FindByID(id)
    if err != nil {
        return nil, err
    }

    // キャッシュに保存
    r.saveToCache(task)
    return task, nil
}
```

## 7. まとめ

クリーンアーキテクチャの導入により：
1. コードの責務が明確に分離され、理解しやすくなった
2. テストが書きやすくなった
3. エラーハンドリングが統一された
4. 機能追加や変更が容易になった

実装のポイント：
- インターフェースを活用した依存関係の管理
- 適切なエラーハンドリング
- トランザクション管理の統一
- テストの容易さを考慮した設計

これらの改善により、長期的なメンテナンス性と拡張性が向上し、より堅牢なアプリケーションを実現できます。