見出し画像

クリーンアーキテクチャに入門してみた 後編

はじめに

  • 前回クリーンアーキテクチャの理論的な部分を学んだので今回は実際にクリーンアーキテクチャで簡単なサンプルアプリの実装をしてみたのでその解説です

実装

  • 実装にはGo言語を使用しています

構成など

実装したサンプルアプリのリポジトリ

使用技術

ディレクトリ構成

├── application  # ユースケース
├── config       # 各種設定値
├── domain        # ビジネスロジックのコア部分(エンティティ、リポジトリインターフェース)
├── infra         # データベースなど外部サービスとの通信
├── presentation # ユーザーからの入力、表示
├── server        # HTTPサーバー、ルーティングの設定
└── main.go           # エントリーポイント

各レイヤーの依存関係


  • presentation と infra ディレクトリは、インターフェースアダプターレイヤーに該当します

    • presentation の Handler は入出力のハンドリング、infra の Repository は永続化層の処理を担当します

  • application ディレクトリはユースケースレイヤーに該当しUsecaseを用いてアプリケーションのユースケースを実行します

    • インターフェースアダプターレイヤーとのやりとりにInputとOutputのDTOを用います

  • domainディレクトリはエンティティレイヤーに該当しDomainModelを用いてビジネスロジックを実装します

ドメインの実装


  • ドメインはビジネスロジックの中核となる部分でビジネスロジックの実装をします

ドメインのディレクトリ構成

domain
├── todo
     ├── mock_todo_repository.go
     ├── todo.go
     ├── todo_repository.go
     ├── todo_test.go
  • todo.goにはTodo関連のビジネスルール、todo_test.goにはそのテストコードを実装しています。

  • todo_repository.goにはTodoのドメインオブジェクトの永続化に関するインターフェースを定義しています。

ドメインの実装を解説します。

ドメインはビジネスルールを反映する部分です
今回はシンプルに以下のようなビジネスルールを設定しました。

1. Todoは一意の識別子を持つ
2. Todoのタイトルは50文字以内である必要がある
3. Todoの詳細は300文字以内である必要がある
4. Todoには作成日時と更新日時が記録される

上記のビジネスルールを適応させていきます。

Todo構造体
まずはオブジェクトの定義です

package todo

type Todo struct {
	id          int64
	title       string
	description string
	createdAt   time.Time
	updatedAt   time.Time
}
  • TodoオブジェクトはTodoのID、タイトル、詳細、作成日時、更新日時を属性として持ちます

  • これらの属性は外部から直接アクセスできないように全て小文字で定義しています

ビジネスルールを反映
次にTodoのビジネスルールを反映します

const (
	maxTitleLength       = 50
	maxDescriptionLength = 300
)

func newTodo(id int64, title, description string, createdAt, updatedAt time.Time) (*Todo, error) {
	if title == "" {
		return nil, errors.New("title is required")
	}

	if len(title) > maxTitleLength {
		return nil, errors.New("title cannot exceed 50 characters")
	}

	if len(description) > maxDescriptionLength {
		return nil, errors.New("description cannot exceed 300 characters")
	}

	return &Todo{
		id,
		title,
		description,
		createdAt,
		updatedAt,
	}, nil
}
  • 設定したビジネスルールに基づきnewTodoというファクトリー関数を用意しその中でTodoのタイトルと詳細の文字列の長さに関するルールの検証をしています

外部から利用するためのファクトリー関数

func NewTodo(title, description string, createdAt, updatedAt time.Time) (*Todo, error) {
	return newTodo(0, title, description, createdAt, updatedAt)
}

func ReConstruct(id int64, title, description string, createdAt, updatedAt time.Time) (*Todo, error) {
	return newTodo(id, title, description, createdAt, updatedAt)
}
  • newTodo関数を外部から利用するためにNewTodoとReConstruct関数という2つのファクトリー関数を定義しました

  • 2つのファクトリー関数の違いはNewTodo関数は新しいIDを生成しReConstruct関数はデータベースから取得した既存のIDを用いてオブジェクトの再構築をしています

ドメインのテスト

ここではNewTodo関数について正常系と異常系のテストケースを記述します

package todo

func TestNewTodo(t *testing.T) {
	type args struct {
		title       string
		description string
		createdAt   time.Time
		updatedAt   time.Time
	}
	tests := []struct {
		name    string
		args    args
		want    *Todo
		wantErr bool
	}{
		{
			name: "正常系",
			args: args{
				title:       "title",
				description: "description",
				createdAt:   time.Now(),
				updatedAt:   time.Now(),
			},
			want: &Todo{
				title:       "title",
				description: "description",
				createdAt:   time.Now(),
				updatedAt:   time.Now(),
			},
			wantErr: false,
		},
		{
			name: "異常系: titleが空",
			args: args{
				title:       "",
				description: "description",
				createdAt:   time.Now(),
				updatedAt:   time.Now(),
			},
			want:    nil,
			wantErr: true,
		},
		{
			name: "異常系: titleが長い",
			args: args{
				title:       "this title is way too long and exceeds the maximum length of 50 characters",
				description: "description",
				createdAt:   time.Now(),
				updatedAt:   time.Now(),
			},
			want:    nil,
			wantErr: true,
		},
		{
			name: "異常系: descriptionが長い",
			args: args{
				title:       "title",
				description: "this description is way too long and exceeds the maximum length of 300 characters. " +
					"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, " +
					"quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
				createdAt: time.Now(),
				updatedAt: time.Now(),
			},
			want:    nil,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := NewTodo(tt.args.title, tt.args.description, tt.args.createdAt, tt.args.updatedAt)
			if (err != nil) != tt.wantErr {
				t.Errorf("NewTodo() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(Todo{}), cmpopts.IgnoreFields(Todo{}, "createdAt", "updatedAt")); diff != "" {
				t.Errorf("NewTodo() = %v, want %v, diff: %s", got, tt.want, diff)
			}
		})
	}
}
  • エンティティレイヤーはビジネスロジックを担当する最も重要なレイヤーで、このエンティティレイヤーでのテストは必須だと考えます

  • テストによってドメインのビジネスロジックが正しく動作していることを確認することができ将来的なリファクタリングや拡張に対応しやすくなります

実装のポイント

今回のドメインでの実装のポイントとしては以下です。

  • 仕様からビジネスルールを抽出してドメインオブジェクトに反映させる

    • ビジネスルールをドメインオブジェクトに反映させることで、データの一貫性を保ち、ビジネスロジックの中心にドメインを据えることができる

  • ドメインオブジェクトのフィールドをプライベートにすることでカプセル化を実現する

    • カプセル化によって外部からの変更が制限され、より安全なオブジェクト設計が可能になる

ユースケースの実装


続いてユースケースの実装です。
ユースケースはアプリケーションの具体的なユースケース処理を実現させる役割を果たしていてエンティティレイヤー(ドメイン)のオブジェクトや関数を用いてユースケースの処理を実行していきます。
ユースケースのディレクトリ構成

application
├── todo
     ├── find_todo_use_case.go
     ├── find_todo_use_case_test.go
     ├── save_todo_use_case.go

新たなユースケースが追加されてもファイルが肥大化するのを防ぐために各ユースケースの実装に応じてファイル分けしています。
save_todo_use_case.go
今回はTodoアイテムを保存するsave_todo_use_case.goを見ていきます。

package todo

import (
	"context"
	todoDomain "go-clean-app/domain/todo"
	"time"
)

type SaveTodoUseCase struct {
	todoRepo todoDomain.TodoRepository
}

func NewSaveTodoUseCase(todoRepo todoDomain.TodoRepository) *SaveTodoUseCase {
	return &SaveTodoUseCase{
		todoRepo: todoRepo,
	}
}

type SaveTodoUseCaseDto struct {
	Title       string
	Description string
}

func (tc *SaveTodoUseCase) Run(ctx context.Context, input SaveTodoUseCaseDto) (int64, error) {
	now := time.Now()
	todo, err := todoDomain.NewTodo(input.Title, input.Description, now, now)
	if err != nil {
		return 0, err
	}
	id, err := tc.todoRepo.Save(ctx, todo)
	if err != nil {
		return 0, err
	}
	return id, nil
}
  • このユースケース専用のinputのDTOとしてSaveTodoUseCaseDto を定義しました

  • このSaveTodoUseCaseDto はインターフェースアダプターレイヤーから値を受け取る際に利用します

  • Runメソッドでは非常にシンプルで、ドメインのNewTodoを呼び出しビジネスルールに基づいたTodoオブジェクトを生成しTodoRepository を通じて永続化しています

ユースケースのテスト
ユースケースのテストを書くことで将来的な仕様の複雑化への対応が可能になります
今回は特定のTodoアイテムを検索するfind_todo_use_case.goのテストを実装しました

package todo

import (
	"context"
	"errors"
	"testing"
	"time"

	todoDomain "go-clean-app/domain/todo"

	"github.com/stretchr/testify/assert"
	"go.uber.org/mock/gomock"
)

func TestFindTodoUseCase_Run(t *testing.T) {
	// usecaseの準備
  ctrl := gomock.NewController(t)
	mockTodoRepo := todoDomain.NewMockTodoRepository(ctrl)
	uc := NewFindTodoUseCase(mockTodoRepo)
	
	// テストデータの準備
  now := time.Now()
	tests := []struct {
		name     string
		mockFunc func()
		id       int64
		want     *FindTodoUseCaseDto
		wantErr  bool
	}{
		{
			name: "Todoを正常に取得できること",
			mockFunc: func() {
				todo, _ := reconstructTodo(1, "Test Title", "Test Description", now, now)
				mockTodoRepo.EXPECT().FindById(gomock.Any(), int64(1)).Return(todo, nil)
			},
			id: 1,
			want: &FindTodoUseCaseDto{
				ID:          1,
				Title:       "Test Title",
				Description: "Test Description",
				CreatedAt:   now,
				UpdatedAt:   now,
			},
			wantErr: false,
		},
		{
			name: "存在しないTodoを取得するとエラーが返ること",
			mockFunc: func() {
				mockTodoRepo.EXPECT().FindById(gomock.Any(), int64(2)).Return(nil, errors.New("not found"))
			},
			id:      2,
			want:    nil,
			wantErr: true,
		},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			tt.mockFunc()

			got, err := uc.Run(context.Background(), tt.id)
			if (err != nil) != tt.wantErr {
					t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr)
					return
			}

			assert.Equal(t, tt.want, got)
		})
	}
}

func reconstructTodo(id int64, title, description string, createdAt, updatedAt time.Time) (*todoDomain.Todo, error) {
  return todoDomain.ReConstruct(id, title, description, createdAt, updatedAt)
}
  • ユースケースの実装にはRepositoryを使用しますがテスト実行時にはモック化しています

    • 今回のモック化は uber-go/mock を使用しています

  • NewFindTodoUseCase関数でrepositoryのモックを注入しています

  • モックを使用することでユースケースのロジックのみに焦点を当てたテストが可能となりテストの記述がシンプルになります

実装のポイント

ユースケースの実装のポイントとしては以下です。

  • DTOの使用

    • SaveTodoUseCaseDtoのようなDTOを定義して、インターフェースアダプターレイヤーからユースケースへのデータの受け渡しをシンプルにし、データの整合性を保つ

  • ビジネスロジックの実装

    • Runメソッドのように入力されたデータをもとにドメインのNewTodo関数を呼び出して、新しいTodoオブジェクトを作成

    • 作成したTodoオブジェクトをリポジトリを通じて永続化する

    • この一連流れによりユースケースがアプリケーションのビジネスロジックを具体的に反映し実際の操作として具現化される

リポジトリの実装


リポジトリはビジネスロジックとデータベース操作ロジックを分離しオブジェクトの永続化を担当します。
これにより、アプリケーションの他の部分はデータベースの詳細を気にすることなく、ビジネスロジックに集中することができます。
リポジトリのディレクトリ構成

infra
├── mysql
     ├── db
         ├── connect_db.go
     ├── repository
         ├── todo_repository.go
  • db/connect_db.go : データベース接続周りの設定を実装しています

  • repository/todo_repository.go : ドメインで定義したリポジトリインターフェースの実際の実装を行います

domain/todo_repository.go
まずはドメイン層でリポジトリのインターフェースを定義します

type TodoRepository interface {
	Save(ctx context.Context, todo *Todo) (int64, error)
	FindById(ctx context.Context, id int64) (*Todo, error)
}

todo_repository.go
具体的なリポジトリの実装を行います。

package repository

import (
	"context"
	"database/sql"
	"errors"
	"time"

	todoDomain "go-clean-app/domain/todo"
)

type TodoRepository struct {
	db *sql.DB
}

func NewTodoRepository(db *sql.DB) *TodoRepository {
	return &TodoRepository{db: db}
}

// インターフェースを満たしているかを検証
var _ todoDomain.TodoRepository = new(TodoRepository)

// domain/repositoryのSaveの具体的な実装
func (tr *TodoRepository) Save(ctx context.Context, todo *todoDomain.Todo) (int64, error) {
	query := `
		INSERT INTO todos (title, description, created_at, updated_at)
		VALUES (?, ?, ?, ?)
	`

	result, err := tr.db.ExecContext(ctx, query, todo.Title(), todo.Description(), todo.CreatedAt(), todo.UpdatedAt())
	if err != nil {
		return 0, err
	}

	id, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}

	return id, nil
}

// // domain/repositoryのFindByIdの具体的な実装
func (tr *TodoRepository) FindById(ctx context.Context, id int64) (*todoDomain.Todo, error) {
	query := `
		SELECT id, title, description, created_at, updated_at
		FROM todos
		WHERE id = ?
	`

	row := tr.db.QueryRowContext(ctx, query, id)

	var todoData struct {
		ID          int64
		Title       string
		Description string
		CreatedAt   time.Time
		UpdatedAt   time.Time
	}

	err := row.Scan(&todoData.ID, &todoData.Title, &todoData.Description, &todoData.CreatedAt, &todoData.UpdatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, todoDomain.ErrTodoNotFound
		}
		return nil, err
	}

	return todoDomain.ReConstruct(todoData.ID, todoData.Title, todoData.Description, todoData.CreatedAt, todoData.UpdatedAt)
}
  • domainで定義したリポジトリインターフェースの具体的な実装をしています

  • NewTodoRepository関数はsql.DBのインスタンスを受け取り新しいTodoRepositoryのインスタンスを返します

    • これによりリポジトリの依存関係が明確になり、テスト時にはモックを注入することが容易になります

  • var _ todoDomain.TodoRepository = new(TodoRepository) でdomainで定義したリポジトリインターフェースを満たしているかを検証しています

実装のポイント

  • ビジネスロジックとデータ操作の分離

    • リポジトリはビジネスロジックからデータ操作の詳細を隠しビジネスロジックがデータベースの詳細に依存しないようにしています

    • これによりビジネスロジックの開発とテストをそれぞれ独立して行うことができます

  • 依存性の管理

    • コンストラクタを使って必要な依存関係(今回だとデータベース接続)を注入することでテスト時にモックを容易に注入できるようになります

  • インターフェースの使用

    • リポジトリインターフェースを使用することでビジネスロジックとデータアクセス層の間で統一された操作が保証されます

    • これにより異なるデータソースやストレージ技術を採用する場合(ex. DBをMySQLからPostgreSQLに変更するなど)でもリポジトリの実装を変更するだけで、ビジネスロジックの変更を最小限に抑えることができます

プレゼンテーションの実装


最後にプレゼンテーションの実装です。
プレゼンテーションは、外部からの入力(リクエスト)をアプリケーションが処理できる形に変換し、結果(レスポンス)を外部に返却するという架け橋の役割を果たします。
一般的にプレゼンテーションはコントローラーとプレゼンターに分けられます。
コントローラーは外部からのリクエストを受け取りそれをユースケースへと受け渡す役割
プレゼンターはユースケースからデータを受け取り、それを外部で使えるようにフォーマットして返却するという役割をしています
プレゼンテーションのディレクトリ構成

presentation
├── todo
     ├── handler.go
     ├── request.go
     ├── response.go

handler.goはコントローラーの役割、request.goとresponse.goはプレゼンターの役割をしています。
プレゼンター
まずリクエスト(入力データ)とレスポンス(出力データ)を明確に分離するためにリクエストデータはrequest.go レスポンスデータはresponse.goにそれぞれ定義します。
request.go

package todo

type saveTodoParams struct {
	Title       string `json:"title"`
	Description string `json:"description"`
}

response.go

package todo

import "time"

type getTodoResponse struct {
	Todo todoResponseModel `json:"todo"`
}

type todoResponseModel struct {
	ID          int64  `json:"id"`
	Title       string `json:"title"`
	Description string `json:"description"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

type createTodoResponse struct {
	ID int64 `json:"id"`
}

コントローラー
外部からのリクエストを受け取り、ユースケースにデータを渡し、その結果を受け取ってレスポンスを返すコントローラーを実装していきます。
handler.go

package todo

import (
	todoApp "go-clean-app/application/todo"
	"strconv"

	"github.com/labstack/echo/v4"
)

type Handler struct {
	findTodoUseCase *todoApp.FindTodoUseCase
	saveTodoUseCase *todoApp.SaveTodoUseCase
}

func NewTodoHandler(findTodoUseCase *todoApp.FindTodoUseCase, saveTodoUseCase *todoApp.SaveTodoUseCase) *Handler {
	return &Handler{
		findTodoUseCase: findTodoUseCase,
		saveTodoUseCase: saveTodoUseCase,
	}
}

func (h *Handler) GetTodoByID(c echo.Context) error {
	idParam := c.Param("id")
  id, err := strconv.ParseInt(idParam, 10, 64)
	if err != nil {
		return c.JSON(400, err.Error())
	}

   // ユースケース呼び出し
	dto, err := h.findTodoUseCase.Run(c.Request().Context(), id)
	if err != nil {
		return c.JSON(500, err.Error())
	}
	
	// プレゼンターで定義したレスポンスを使用して返却
	res := getTodoResponse{
		Todo:  todoResponseModel{
			ID:          dto.ID,
			Title:       dto.Title,
			Description: dto.Description,
			CreatedAt:   dto.CreatedAt,
			UpdatedAt:   dto.UpdatedAt,
		},
	}
	return c.JSON(200, res)
}

func (h *Handler) SaveTodo(c echo.Context) error {
	// プレゼンターで定義したリクエストの構造体にバインド
	var params saveTodoParams
	if err := c.Bind(&params); err != nil {
		return c.JSON(400, err.Error())
	}

	// ユースケースを呼び出し
	input := todoApp.SaveTodoUseCaseDto{
		Title:       params.Title,
		Description: params.Description,
	}
	
	// ユースケースの実行
	id, err := h.saveTodoUseCase.Run(c.Request().Context(), input)
	if err != nil {
		return c.JSON(500, err.Error())
	}

	// プレゼンターで定義したレスポンスを使用して返却
	res := createTodoResponse{
		ID: id,
	}

	return c.JSON(200, res)
}

全体の流れとしては以下です。

  • リクエストを受けとる

    • request.goでのsaveTodoParamsを使用(Saveの場合)

  • ユースケースを呼び出し実行しデータの加工を行う

    • TodoUseCaseDtoを使用してユースケースを呼び出し実行

  • レスポンスとして返却

    • ユースケースの結果をresponse.goでの構造体を使用してレスポンスの形に変換しクライアントに返却

このように、プレゼンテーションはリクエストを受け取り、ユースケースでデータを加工し、最終的にレスポンスとして返却する役割を果たします

実装のポイント

  • 責任の分離

    • handler.go、request.go、response.goを分けることで、責任を明確にしています。リクエストデータのバリデーションや変換はrequest.goで行い、レスポンスデータの整形はresponse.goで行います。これにより、コードの可読性が向上し、メンテナンスが容易になります

  • ユースケースの呼び出し

    • プレゼンテーションでは、ビジネスロジックを持たず、ユースケースに委譲しています

    • これにより、プレゼンテーション層はシンプルに保たれ、ユースケースに変更があった場合でも影響を最小限に抑えることができます

まとめ

前回理論的に学んだクリーンアーキテクチャついて実際に手を動かしながら実装をしてみました。実装を進める中でソフトウェアアーキテクチャの重要性や効果を改めて実感することができました。

特にアーキテクチャの各レイヤーの責務については実際に手を動かして実装することでこれはどこで担当すべきことなのかと考えることが多く普段はわかった気になっていただけなんだなと感じました、、、

やはり本などで必要知識をインプットした上でアウトプットとして手を動かすことは理解する上では必須ですね!これからも続けていきたいです!

参考