こんにちは!フリーランスエンジニアのこたろうです。
今回は、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)
}
このコードには以下の問題があります:
-
テストの難しさ
- データベース接続が必要
- ビジネスロジックの単独テストが困難
- HTTP部分のモック化が必要
-
保守性の低さ
- 1つの関数が複数の責務を持っている
- コードの見通しが悪い
- 変更の影響範囲が大きい
-
再利用性の欠如
- ビジネスロジックが他の場所で使えない
- データベース処理が結合している
- エラーハンドリングが散在
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. パフォーマンス最適化のポイント
- クエリの最適化
func (r *TaskRepositoryImpl) FindAllWithPreload() ([]Task, error) {
var tasks []Task
result := r.db.
Preload("Assignee"). // 関連データを事前読み込み
Preload("Comments").
Find(&tasks)
return tasks, result.Error
}
- キャッシュの利用
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. まとめ
クリーンアーキテクチャの導入により:
- コードの責務が明確に分離され、理解しやすくなった
- テストが書きやすくなった
- エラーハンドリングが統一された
- 機能追加や変更が容易になった
実装のポイント:
- インターフェースを活用した依存関係の管理
- 適切なエラーハンドリング
- トランザクション管理の統一
- テストの容易さを考慮した設計
これらの改善により、長期的なメンテナンス性と拡張性が向上し、より堅牢なアプリケーションを実現できます。