😇

gin + gormでクリーンアーキテクチャなTodoアプリ Part2(クリーンアーキテクチャに修正編)

2024/04/27に公開

概要

main.goに全ての責務が集約されたTodoアプリをクリーンアーキテクチャに修正していきます。
Part1で作成したTodoアプリを修正するので、簡単に前回の記事に目を通しておくと理解しやすいかもしれません。

クリーンアーキテクチャとは

クリーンアーキテクチャ

本記事では、細かい説明は省略しますが、下記の原則に基づいたアーキテクチャとなっています。

  • ソフトウェアをレイヤーに分割することで、構成要素の各役割を明確化する。
  • ソースコードは円の内側の方向に依存する
    • 制御の流れと依存性逆転の原則に基づいて制御する

依存性の逆転とは

円の内側のレイヤーを外側のレイヤーに依存しないように設計すべきであるということです。

外側で作成したインターフェースに内側を依存させることで、依存性逆転を実現できるという訳です。

こちらの記事が設計思想をわかりやすく説明してくれています。

今回の完成系構成

下記のシンプルな構成に構築していきます。

クリーン

.
├── db
│   └── my.cnf
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
└── src
    ├── tests
    │   └── usecase
    │       └── service_todo_test.go
    ├── usecase # Application BusinessRules
    │   └── services # ビジネスロジックの実行手段を集約
    │       └── todo.go
    ├── domain # Enterprise Business Rules
    │   ├── models # DBのデーブルモデル
    │   │   └── todo.go
    │   └── repositories # repositoryのインターフェース
    │       └── todo.go
    ├── infra # Frameworks & Drivers層
    │   ├── database # DBの設定ファイル管理、接続、repositoryファイル
    │   │   ├── connection.go
    │   │   └── repositories
    │   │       └── todo.go
    │   └── http # クライアント側。htmlや遷移
    │       ├── public
    │       │   ├── edit.html
    │       │   └── index.html
    │       └── routes
    │           ├── page.go
    │           └── todo.go
    └── interface # interface Adapter層
        └── controllers # リクエスト内容の解析
            └── todo.go

Todoのクリーンアーキテクチャ再構築手順

下記に従ってtodoを修正していきます。

  1. Entities層の実装
  2. Frameworks & Drivers層の実装
  3. Application BusinessRules層の実装
  4. interface Adapter層の実装
  5. routingの分離
  6. 「Application BusinessRules層」のテストコードを作成

1. Entities層の実装

初期構築

  • htmlはinfra層にあたる為、移動しておきます。
.
├── db
│   └── my.cnf
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
└── src
    └── infra
        └── http
            └── public
                ├── edit.html
                └── index.html

行うこと

ビジネスロジックやアプリケーションの状態をカプセル化し、再利用可能なビジネスルールを作成する。

  1. src/domain/models/todo.goの作成
    • ビジネスルール・オブジェクトの切り出し
    • リポジトリ層のinterfaceは、「Frameworks & Drivers層の実装」で作成します。
  2. main.goの変更
    • Todoを使用している箇所をmodels.で使用するように変更
    • htmlの読み込みパスを変更する

1. Entities層の切り出し

src/domain/models/todo.go
package models

import (
	"errors"
	"gorm.io/gorm"
)

type Todo struct {
	*gorm.Model
	Content string
}

// Validate - Todoのバリデーションルール
func (t *Todo) Validate() error {
    if t.Content == "" {
        return errors.New("content cannot be empty")
    }
    return nil
}

2. main.goに変更を反映

main.go
import (
    ...
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
)
func main() {
    ...
    &models.Todo{} // Todoを使用している箇所をmodels.で使用するように変更
    ...
	engine.LoadHTMLGlob("src/infra/http/public/*")
}
  • go runを実施し、問題なく実行できればdomain層の切り出しが完了
DB_TABLE=go_todo DB_USER=root DB_PASSWORD=password DB_HOST=localhost DB_PORT=3306 go run .

2. Frameworks & Drivers層の実装

行うこと

データベースとの接続管理とアクセスロジックの実装

  1. src/domain/repositoriesの作成
    • データアクセスを抽象化するリポジトリインターフェースを作成する。
    • インターフェースの作成により、DBの永続化方法に依存しない
  2. src/infra/databaseの作成
    • データベースとの接続管理
    • リポジトリの具体的な実装
  3. main.goの変更
    • repositoryをオブジェクトとして利用

1. リポジトリのインターフェース

/src/domain/repositories/todo.go
package repositories

import (
	"context"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
)

// TodoRepository - Todoリポジトリのインターフェース
type TodoRepository interface {
	GetByID(ctx context.Context, id uint) (*models.Todo, error)
	Create(ctx context.Context, todo *models.Todo) error
	Update(ctx context.Context, todo *models.Todo) error
	Delete(ctx context.Context, id uint) error
	List(ctx context.Context) ([]*models.Todo, error)
}

2. データベースとの接続管理

src/infra/database/connection.go
package database

import (
	"fmt"
	"os"
	"strconv"

	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

type DBConfig struct {
	User string
	Password string
	Host string
	Port int
	DB string
}

// OpenDB - データベース接続を開きます
func getDBConfig() DBConfig {
    port, err := strconv.Atoi(os.Getenv("DB_PORT"))
	if err != nil {
        port = 3306 // MySQLのデフォルトポート
    }
    return DBConfig{
        User: os.Getenv("DB_USER"),
        Password: os.Getenv("DB_PASSWORD"),
        Host: os.Getenv("DB_HOST"),
        Port: port,
		DB: os.Getenv("DB"),
    }
}

func ConnectionDB() (*gorm.DB, error) {
	config := getDBConfig();
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", config.User, config.Password, config.Host, config.Port, config.DB)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		return nil, err
	}
	if err := db.AutoMigrate(&models.Todo{}); err != nil {
		return nil, err
	}
	return db, nil
}

3. repositoriesインターフェースのロジックを実装

/src/infra/database/repositories/todo.go
package repository

import (
	"context"
	"gorm.io/gorm"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"github.com/{githubユーザー名}/{project名}/src/domain/repositories"
)

// TodoRepository - GORMによるTodoリポジトリの実装
type TodoRepository struct {
	DB *gorm.DB
}

// NewTodoRepository - 新しいGormTodoRepositoryを作成します
func NewTodoRepository(db *gorm.DB) repositories.TodoRepository {
	return &TodoRepository{DB: db}
}

// 以下、インターフェースの各メソッドの実装
func (r *TodoRepository) GetByID(ctx context.Context, id uint) (*models.Todo, error) {
	var todo models.Todo
	result := r.DB.First(&todo, id)
	return &todo, result.Error
}

func (r *TodoRepository) Create(ctx context.Context, todo *models.Todo) error {
	return r.DB.Create(todo).Error
}

func (r *TodoRepository) Update(ctx context.Context, todo *models.Todo) error {
	return r.DB.WithContext(ctx).Save(todo).Error
}

func (r *TodoRepository) Delete(ctx context.Context, id uint) error {
	return r.DB.Delete(&models.Todo{}, id).Error
}

func (r *TodoRepository) List(ctx context.Context) ([]*models.Todo, error) {
	var todos []*models.Todo
	result := r.DB.Find(&todos)
	return todos, result.Error
}

4. main.goに変更を反映する

main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"github.com/{githubユーザー名}/{project名}/src/infra/database/repositories"
	"github.com/{githubユーザー名}/{project名}/src/infra/database"
)

func main() {
	engine := gin.Default()
	// データベース接続の設定
	db, err := database.ConnectionDB()
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	// リポジトリの初期化
	todoRepo := repository.NewTodoRepository(db)

	// Migrate the schema
	err = db.AutoMigrate(&models.Todo{})
	if err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	engine.Static("/static", "./static");
	engine.LoadHTMLGlob("src/infra/http/public/*")
	engine.GET("/index", func(c *gin.Context) {
		var todos []*models.Todo

		// Get all records
		todos, err := todoRepo.List(c.Request.Context())
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not retrieve todos"})
            return
        }
		c.HTML(http.StatusOK, "index.html", gin.H{
			"title": "Hello world",
			"todos": todos,
		})
	})

	//todo edit
	engine.GET("/todos/edit", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Query("id"))
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not invalid parameter"})
			return
		}
		var todo *models.Todo
		todo, _ = todoRepo.GetByID(c.Request.Context(), uint(id))
		c.HTML(http.StatusOK, "edit.html", gin.H{
			"content": "Todo",
			"todo":  todo,
		})
	})

	engine.GET("/todos/destroy", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Query("id"))
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not invalid parameter"})
		}
		// uint64型をuintに変換して代入
		todoRepo.Delete(c.Request.Context(), uint(id))
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	engine.POST("/todos/update", func(c *gin.Context) {
		id, err := strconv.Atoi(c.PostForm("id"))
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not invalid parameter"})
		}
		content := c.PostForm("content")
		var todo *models.Todo
		todo, _ = todoRepo.GetByID(c.Request.Context(), uint(id));
		todo.Content = content
		todoRepo.Update(c.Request.Context(), todo)
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	engine.POST("/todos/create", func(c *gin.Context) {
		content := c.PostForm("content")
		todoRepo.Create(c, &models.Todo{Content: content})
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	fmt.Println("Database connection and setup successful")
	engine.Run(":8080")
}

3. Application BusinessRulesの実装

行うこと

具体的なビジネスロジックを実行し、エンティティ層を使用してビジネスルールを適用します。
ユースケース層は、データの入出力を調整し、エンティティ層が提供するメソッドを使ってリクエストを処理します。

  1. src/usecase/services/todo.goの作成
    • エンティティ層にあるインターフェースを使用する
    • アプリケーションのユースケースの実装
  2. main.goの変更
    • serviceをオブジェクトとして利用

1. エンティティを用いてビジネスロジックを実行する

src/usecase/services/todo.go
package services

import (
	"context"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"github.com/{githubユーザー名}/{project名}/src/domain/repositories"
)

// TodoService - Todoアプリケーションサービス
type TodoService struct {
	repo repositories.TodoRepository
}

// NewTodoService - 新しいTodoServiceを作成
func NewTodoService(repo repositories.TodoRepository) *TodoService {
	return &TodoService{
		repo: repo,
	}
}

// CreateTodo - 新しいTodoを作成
func (s *TodoService) CreateTodo(ctx context.Context, content string) error {
	todo := &models.Todo{Content: content}
	return s.repo.Create(ctx, todo)
}

// GetTodoByID - IDによるTodoの取得
func (s *TodoService) GetTodoByID(ctx context.Context, id uint) (*models.Todo, error) {
	return s.repo.GetByID(ctx, id)
}

// UpdateTodo - Todoの更新
func (s *TodoService) UpdateTodo(ctx context.Context, todo *models.Todo) error {
	if err := todo.Validate(); err != nil {
        return err
    }
	return s.repo.Update(ctx, todo)
}

// DeleteTodo - Todoの削除
func (s *TodoService) DeleteTodo(ctx context.Context, id uint) error {
	return s.repo.Delete(ctx, id)
}

// ListTodos - Todoリストの取得
func (s *TodoService) ListTodos(ctx context.Context) ([]*models.Todo, error) {
	return s.repo.List(ctx)
}

2. main.goに変更を反映する

main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"github.com/{githubユーザー名}/{project名}/src/infra/database/repositories"
	"github.com/{githubユーザー名}/{project名}/src/infra/database"
	"github.com/{githubユーザー名}/{project名}/src/usecase/services"
)

func main() {
	engine := gin.Default()
	// データベース接続の設定
	db, err := database.ConnectionDB()
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	// リポジトリの初期化
	todoRepo := repository.NewTodoRepository(db)
	todoService := services.NewTodoService(todoRepo)

	// Migrate the schema
	err = db.AutoMigrate(&models.Todo{})
	if err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	engine.Static("/static", "./static");
	engine.LoadHTMLGlob("src/infra/http/public/*")
	engine.GET("/index", func(c *gin.Context) {
		var todos []*models.Todo

		// Get all records
		todos, err := todoService.ListTodos(c.Request.Context())
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not retrieve todos"})
            return
        }
		c.HTML(http.StatusOK, "index.html", gin.H{
			"title": "Hello world",
			"todos": todos,
		})
	})

	//todo edit
	engine.GET("/todos/edit", func(c *gin.Context) {
		id, _ := strconv.Atoi(c.Query("id"))
		var todo *models.Todo
		todo, _ = todoService.GetTodoByID(c.Request.Context(), uint(id))
		c.HTML(http.StatusOK, "edit.html", gin.H{
			"content": "Todo",
			"todo":  todo,
		})
	})

	engine.GET("/todos/destroy", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Query("id"))
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not invalid parameter"})
		}
		// uint64型をuintに変換して代入
		todoService.DeleteTodo(c.Request.Context(), uint(id))
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	engine.POST("/todos/update", func(c *gin.Context) {
		id, err := strconv.Atoi(c.PostForm("id"))
		if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not invalid parameter"})
		}
		content := c.PostForm("content")
		var todo *models.Todo
		todo, _ = todoService.GetTodoByID(c.Request.Context(), uint(id));
		todo.Content = content
		todoService.UpdateTodo(c.Request.Context(), todo)
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	engine.POST("/todos/create", func(c *gin.Context) {
		content := c.PostForm("content")
		todoService.CreateTodo(c, content)
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	fmt.Println("Database connection and setup successful")
	engine.Run(":8080")
}

4. interface Adapter層の実装

行うこと

データがアプリケーションの内部と外部のデータソース間で適切に変換されるようにします。
りクエストを受け取り、適切なユースケースを呼び出し、レスポンスをユーザーに返します。

  1. src/interface/controllers/todo.goの作成
    • エンティティ層にあるインターフェースを使用する
    • アプリケーションのユースケースの実装
  2. main.goの変更
    • コントローラーをオブジェクトとして利用

1. APIによってリクエストを変換し、適切なユースケースを呼び出す。

src/interface/controllers/todo.go
package controllers

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/{githubユーザー名}/{project名}/src/usecase/services"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
)

// TodoController - Todoに関するHTTPリクエストを処理するコントローラ
type TodoController struct {
	service *services.TodoService
}

// NewTodoController - 新しいTodoControllerを作成します
func NewTodoController(service *services.TodoService) *TodoController {
	return &TodoController{
		service: service,
	}
}

// GetTodo - Todoを一つ取得します
func (tc *TodoController) GetTodoByID(c *gin.Context) (*models.Todo, error) {
	id, err := strconv.Atoi(c.Query("id"))
	if err != nil {
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return nil, err
	}
	return tc.service.GetTodoByID(c.Request.Context(), uint(id))
}

// CreateTodo - Todoを作成します
func (tc *TodoController) CreateTodo(c *gin.Context) {
    // フォームデータからcontentを取得
    content := c.PostForm("content")
    if content == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "content is required"})
        return
    }
    err := tc.service.CreateTodo(c.Request.Context(), content)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create todo"})
        return
    }
    c.Status(http.StatusCreated)
	c.Redirect(http.StatusMovedPermanently, "/index")
}

// UpdateTodo - Todoを更新します
func (tc *TodoController) UpdateTodo(c *gin.Context) {
	idStr := c.PostForm("id")
    id, err := strconv.ParseUint(idStr, 10, 64) // idの安全な取得とエラーチェック
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"})
        return
    }

	content := c.PostForm("content")
	todo, err := tc.service.GetTodoByID(c.Request.Context(), uint(id));
	if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Content is not found"})
	}
	todo.Content = content
	if err := tc.service.UpdateTodo(c.Request.Context(), todo); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
	c.Status(http.StatusOK)
	c.Redirect(http.StatusSeeOther, "/index")
}


// DeleteTodo - Todoを削除します
func (tc *TodoController) DeleteTodo(c *gin.Context) {
    // クエリからidを取得する
    idStr, ok := c.GetQuery("id")
    if !ok {
        c.JSON(http.StatusBadRequest, gin.H{"error": "ID query parameter is required"})
        return
    }
	// 文字列のidをuintに変換
	id, err := strconv.ParseUint(idStr, 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"})
		return
	}
	err = tc.service.DeleteTodo(c.Request.Context(), uint(id))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete todo"})
		return
	}
	c.Status(http.StatusOK)
	c.Redirect(http.StatusSeeOther, "/index")
}


// ListTodos - Todoのリストを取得します
func (tc *TodoController) ListTodos(c *gin.Context) ([]*models.Todo, error) {
    return tc.service.ListTodos(c.Request.Context())
}

2. main.goに変更を反映する

main.go
package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"github.com/{githubユーザー名}/{project名}/src/infra/database/repositories"
	"github.com/{githubユーザー名}/{project名}/src/infra/database"
	"github.com/{githubユーザー名}/{project名}/src/usecase/services"
	"github.com/{githubユーザー名}/{project名}/src/interface/controllers"
)

func main() {
	engine := gin.Default()
	// データベース接続の設定
	db, err := database.ConnectionDB()
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	// リポジトリの初期化
	todoRepo := repository.NewTodoRepository(db)
	todoService := services.NewTodoService(todoRepo)
	todoController := controllers.NewTodoController(todoService)

	// Migrate the schema
	err = db.AutoMigrate(&models.Todo{})
	if err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	engine.Static("/static", "./static");
	engine.LoadHTMLGlob("src/infra/http/public/*")
	// 各ルートの設定
	engine.GET("/index", func(c *gin.Context) {
        todos, err := todoController.ListTodos(c)
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not retrieve todos"})
            return
        }
        c.HTML(http.StatusOK, "index.html", gin.H{
            "title": "やることリスト",
            "todos": todos,
        })
	})

	//todo edit
	engine.GET("/todos/edit", func(c *gin.Context) {
		todo, err := todoController.GetTodoByID(c)
		if err != nil {
			return
		}
		c.HTML(http.StatusOK, "edit.html", gin.H{
			"title": "Todoの編集",
			"todo":  todo,
		})
	})

	engine.GET("/todos/destroy", func(c *gin.Context) {
		todoController.DeleteTodo(c)
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	engine.POST("/todos/update", func(c *gin.Context) {
		todoController.UpdateTodo(c)
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	engine.POST("/todos/create", func(c *gin.Context) {
		todoController.CreateTodo(c)
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	fmt.Println("Database connection and setup successful")
	engine.Run(":8080")
}

5. ルーティング処理の分離

行うこと

現状のmain.goで持っている役割が多いため、ルーティング処理をFrameworks層に分離します。

  1. src/infra/http/routes/page.goの作成
    • ユーザー画面のルートを記載
  2. src/infra/http/routes/todo.goの作成
    • todoに関するAPIを記載
  3. main.goに変更を反映

1. ユーザー画面のルートを記載

src/infra/http/routes/page.go
package router

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"github.com/{githubユーザー名}/{project名}/src/interface/controllers"
)

// SetupRouter - ルータの設定を行います
func SetupRouterPage(engine *gin.Engine, todoController *controllers.TodoController) *gin.Engine {

    // HTMLテンプレートのロード
	engine.LoadHTMLGlob("src/infra/http/public/*")

	// 各ルートの設定
	engine.GET("/index", func(c *gin.Context) {
        todos, err := todoController.ListTodos(c)
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "could not retrieve todos"})
            return
        }
        c.HTML(http.StatusOK, "index.html", gin.H{
            "title": "やることリスト",
            "todos": todos,
        })
	})

	engine.GET("/todos/edit", func(c *gin.Context) {
		todo, err := todoController.GetTodoByID(c)
		if err != nil {
			return
		}
		c.HTML(http.StatusOK, "edit.html", gin.H{
			"title": "Todoの編集",
			"todo":  todo,
		})
	})

	return engine
}

2. todoに関するAPIを記載

src/infra/http/routes/todo.go
package router

import (
	"github.com/gin-gonic/gin"
	"github.com/{githubユーザー名}/{project名}/src/interface/controllers"
)

// SetupRouter - ルータの設定を行います
func SetupRouterTodo(engine *gin.Engine, todoController *controllers.TodoController) *gin.Engine {
    // HTMLテンプレートのロード

	engine.POST("/todos/create", todoController.CreateTodo)
	engine.POST("/todos/update", todoController.UpdateTodo)
	engine.GET("/todos/destroy", todoController.DeleteTodo)

	return engine
}

3. main.goに変更を反映

main.go
package main

import (
	"log"
	"github.com/gin-gonic/gin"
	"github.com/{githubユーザー名}/{project名}/src/infra/database/repositories"
	"github.com/{githubユーザー名}/{project名}/src/infra/database"
	"github.com/{githubユーザー名}/{project名}/src/usecase/services"
	"github.com/{githubユーザー名}/{project名}/src/interface/controllers"
	"github.com/{githubユーザー名}/{project名}/src/infra/http/routes"
)

func main() {
	// データベース接続の設定
	db, err := database.ConnectionDB()
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// リポジトリの初期化
	todoRepo := repository.NewTodoRepository(db)

	// サービス層の初期化
	todoService := services.NewTodoService(todoRepo)

	// コントローラの初期化
	todoController := controllers.NewTodoController(todoService)

	engine := gin.Default()
	// ルータの設定
	engine = router.SetupRouterTodo(engine, todoController)
	engine = router.SetupRouterPage(engine, todoController)

	// サーバを8080ポートで起動
	if err := engine.Run(":8080"); err != nil {
		log.Fatalf("Failed to run server: %v", err)
	}
}

6. テストの追加

やること

依存性逆転の思想を用いることで、各レイヤーでのテストが容易になりました。
実際にApplicationBusiness Rules層のユースケースのテストを書いていきます。

  1. /tests/service_todo_test.goの追加
    • mock用のリポジトリ層を準備
  2. テストを実施

1. テストの作成

/tests/service_todo_test.go
package usecase_services_test

import (
	"context"
	"testing"
	"gorm.io/gorm"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/{githubユーザー名}/{project名}/src/domain/models"
	"github.com/{githubユーザー名}/{project名}/src/usecase/services"
)

type MockTodoRepository struct {
	mock.Mock
}

func (m *MockTodoRepository) Create(ctx context.Context, todo *models.Todo) error {
	args := m.Called(ctx, todo)
	return args.Error(0)
}

func (m *MockTodoRepository) GetByID(ctx context.Context, id uint) (*models.Todo, error) {
	args := m.Called(ctx, id)
	return args.Get(0).(*models.Todo), args.Error(1)
}

func (m *MockTodoRepository) Update(ctx context.Context, todo *models.Todo) error {
	args := m.Called(ctx, todo)
	return args.Error(0)
}

func (m *MockTodoRepository) Delete(ctx context.Context, id uint) error {
	args := m.Called(ctx, id)
	return args.Error(0)
}

func (m *MockTodoRepository) List(ctx context.Context) ([]*models.Todo, error) {
	args := m.Called(ctx)
	return args.Get(0).([]*models.Todo), args.Error(1)
}


func TestCreateTodo(t *testing.T) {
	setup()
	todo := &models.Todo{Content: "test"}
	mockRepo.On("Create", mock.Anything, todo).Return(nil)
	err := service.CreateTodo(ctx, "test")
	assert.Nil(t, err)
	mockRepo.AssertExpectations(t)
}

func TestGetTodoByID(t *testing.T) {
	setup()
	mockTodo := &models.Todo{Model: &gorm.Model{ID: 1}, Content: "test"}
	mockRepo.On("GetByID", mock.Anything, uint(1)).Return(mockTodo, nil)
	todo, err := service.GetTodoByID(ctx, 1)
	assert.Nil(t, err)
	assert.Equal(t, mockTodo, todo)
	mockRepo.AssertExpectations(t)
}

func TestUpdateTodo(t *testing.T) {
	setup()
	todo := &models.Todo{Content: "test"}
	mockRepo.On("Update", mock.Anything, todo).Return(nil)
	err := service.UpdateTodo(ctx, todo)
	assert.Nil(t, err)
	mockRepo.AssertExpectations(t)
}

func TestDeleteTodo(t *testing.T) {
	setup()
	mockRepo.On("Delete", mock.Anything, uint(1)).Return(nil)
	err := service.DeleteTodo(ctx, 1)
	assert.Nil(t, err)
	mockRepo.AssertExpectations(t)
}

func TestListTodos(t *testing.T) {
	setup()
	mockTodos := []*models.Todo{{Content: "test"}, {Content: "clean architecture"}}
	mockRepo.On("List", mock.Anything).Return(mockTodos, nil)
	todos, err := service.ListTodos(ctx)
	assert.Nil(t, err)
	assert.Equal(t, mockTodos, todos)
	mockRepo.AssertExpectations(t)
}


var (
	mockRepo *MockTodoRepository
	service  *services.TodoService
	ctx      context.Context
)

func setup() {
	mockRepo = new(MockTodoRepository)
	service = services.NewTodoService(mockRepo)
	ctx = context.Background()
}

2. テストの実施

詳細
% go test -v ./src/tests/...
=== RUN   TestCreateTodo
--- PASS: TestCreateTodo (0.00s)
=== RUN   TestGetTodoByID
--- PASS: TestGetTodoByID (0.00s)
=== RUN   TestUpdateTodo
--- PASS: TestUpdateTodo (0.00s)
=== RUN   TestDeleteTodo
--- PASS: TestDeleteTodo (0.00s)
=== RUN   TestListTodos
--- PASS: TestListTodos (0.00s)
PASS
ok      github.com/{githubユーザー名}/{project名}/src/tests/usecase        (cached)

まとめ

main.goに集約されていたtodoのソースコードが、クリーンアーキテクチャ設計に落とし込むこむことで、下記の観点が改善されたと思います。

  • 依存性の統一
    • ビジネスルールやユースケースは、外部のフレームワークやデータベースといった技術的詳細から独立したため、コアロジックが外部の変更に影響されにくくなりました。
  • テストの容易性
    • モックを使用してコアロジックのユニットテストが容易になり、テスト駆動開発(TDD)を効果的に実践できると感じました。
  • コードの再利用性
    • エンティティ層は他のレイヤーと独立しているため、これらのコンポーネントは異なるシステムやプロジェクト間で再利用することが可能になりました。

他にもメンテナンスの容易性やスケーラビリティの観点でもかなり効果的なアーキテクチャだと感じています。
一方で、ディレクトリやファイルの数が多くなるので、「プロジェクトの複雑化」や「開発チームのアーキテクチャへの理解度」は考慮する必要があるかと感じました。

Discussion