0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

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

// 一般的によく見るコード
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 具体的な実装例

// 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層の実装

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層の実装

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層の実装

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層のテスト

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層のテスト

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 カスタムエラーの定義

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 トランザクション管理

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. クエリの最適化
func (r *TaskRepositoryImpl) FindAllWithPreload() ([]Task, error) {
    var tasks []Task
    result := r.db.
        Preload("Assignee").     // 関連データを事前読み込み
        Preload("Comments").
        Find(&tasks)
    return tasks, result.Error
}
  1. キャッシュの利用
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. 機能追加や変更が容易になった

実装のポイント:

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?