22
13

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に入門して、ついでにクリーンアーキテクチャに入門した ーその2

Last updated at Posted at 2025-04-01

はじめに

前回のつづきです。

その1ではGoのインターフェースについて学んだので、今回はそれを踏まえて実際にクリーンアーキテクチャに則りシンプルなTodoアプリの処理を書いてみます。

また、ところどころGoの基礎知識や、GoのフレームワークGinなどについても触れながら進めていきたいと思います。

思考整理のためのアウトプットですので、情報の正確性などは保証できません!ご容赦ください:cop_tone2:

ディレクトリ構成

今回作成するtodoアプリのディレクトリ構成はこんな感じです。

todo-app/
├── cmd/
│   └── main.go
├── domain/
│   └── todo.go
├── handler/
│   └── todo_handler.go
├── repository
│   └── todo_repository.go
├── usecase/
    └── todo_usecase.go

MVCしか書いたことがない身からすると、「modelやcontrollerはどこへ行った??」という感じですが、似たような概念も出ていきますので、ちょこちょこ比較しながら実装していきたいと思います。

また、各ディレクトリ(クリーンアーキテクチャではレイヤー(層)といいます)でなにを担当するかという責任を決めて、それに基づいたコードを書いていきます。

No ディレクトリ 責務
1 domain アプリの中核となるルールや概念(エンティティ)の定義
2 usecase アプリでやりたい操作(ユースケース)の実装
3 repository データの保存・取得方法の実装(DBやメモリへのアクセス)
4 handler リクエストの受け取りと処理の振り分け(APIの入口)
5 cmd アプリの起動処理(エントリーポイント)

Domain

todo.go
package domain

type Todo struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type TodoRepository interface {
	Create(todo *Todo) error
	FindAll() ([]*Todo, error)
}

Domain層ではまずTodoの構造体を定義しています。

フィールドはシンプルにID・タイトル・実行済みのフラグのみです。


その下にさっそくTodoRepositoryというインターフェースが登場していますが、ここが最初のポイントになります。

クリーンアーキテクチャでは、内側の層(domain・エンティティ)にインターフェースを定義して、その実装をするのは外側の層というのが望ましいです。

そのため、ここではCreate・FindAllという2つのメソッドを準備するだけに留めておきます。

Usecase

todo_usecase.go
package usecase

import (
	"todo-app/domain"
)

type TodoUsecase struct {
	Repo domain.TodoRepository
}

func NewTodoUsecase(repo domain.TodoRepository) *TodoUsecase {
	return &TodoUsecase{Repo: repo}
}

func (u *TodoUsecase) Create(todo *domain.Todo) error {
	return u.Repo.Create(todo)
}

func (u *TodoUsecase) FindAll() ([]*domain.Todo, error) {
	return u.Repo.FindAll()
}

Usecase層でやることは「Todoの作成と取得」です。

ただし、Todoの作成・取得するメソッドを定義しますが、実際にTodoを作成して保存したり、保存されているTodoを取得するのはrepositoryの役割です。

そのため、具体的な処理についてはRepositoryから渡してもらう必要があります。

画面収録 2025-03-30 15.35.54 (1).gif

このように、必要なものを外部から引数として渡してもらうことを依存性の注入(Dependency Injection) といいます。

クリーンアーキテクチャでは特に大事なキーワードですね。

func NewTodoUsecase(repo domain.TodoRepository) *TodoUsecase {
	return &TodoUsecase{Repo: repo}
}

そのため、Usecaseの初期化時にRepositoryを渡している、ということです。


そして、Usecaseの各メソッドはインターフェースを通じて具体的な処理を呼び出しているだけです。
その具体的な実装はこの後のRepository層に任せます。

func (u *TodoUsecase) Create(todo *domain.Todo) error {
	return u.Repo.Create(todo)
}

func (u *TodoUsecase) FindAll() ([]*domain.Todo, error) {
	return u.Repo.FindAll()
}

前回の記事で似たような話をしましたが、Usecase層の立場からすると、Todoを「DBに保存する」のかもっと具体的に「MySQLに保存する」のか、はたまた「メモリ上に保存する」のか、そんなことは"知らなくてよい"ということですね。

Repository

Usecaseで説明したとおり、Repositoryでは具体的なデータの保存・取得の処理内容を定義します。

todo_repository.go
package repository

import (
	"go-clean-architecture/domain"
	"sync"
)

type InMemoryTodoRepository struct {
	todos []*domain.Todo
	mu    sync.Mutex
	idSeq int
}

func NewInMemoryTodoRepository() *InMemoryTodoRepository {
	return &InMemoryTodoRepository{}
}

func (r *InMemoryTodoRepository) Create(todo *domain.Todo) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	todo.ID = r.idSeq
	r.idSeq++
	r.todos = append(r.todos, todo)
	return nil
}

func (r *InMemoryTodoRepository) FindAll() ([]*domain.Todo, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	result := make([]*domain.Todo, len(r.todos))
	copy(result, r.todos)
	return result, nil
}

今回は簡単にDBではなくメモリ上でデータを処理するため、InMemoryと分かりやすく命名しています。

とはいえ、後からDB実装や、テスト用のモックを実装してもRepositoryの中で完結できて他の層に影響が出ないので、拡張性・テストしやすいというGoの特徴がよくわかりますね。


import (
	"go-clean-architecture/domain"
	"sync"
)

type InMemoryTodoRepository struct {
	todos []*domain.Todo
	mu    sync.Mutex
	idSeq int
}

また、構造体にsync.Mutexという見慣れないフィールドを持たせていますが、これはGoの並行処理と関係するものです。

クリーンアーキテクチャとは直接関係ありませんので、↓で補足説明としておきます。

Mutexについての補足 Mutexは簡単にいうと複数の処理が同時にCreateを呼んだときに、データの不整合を防ぐための仕組みです。

(公式ドキュメント)

Goでは並行処理を行うとき、複数の処理を同時に走らせることができます(ゴルーチン=goroutnie)
例えば以下のようにCreateが同時に2回呼ばれたとします。

go repo.Create(todo1)
go repo.Create(todo2)

todoアプリでは、CreateするたびにユニークIDを採番する必要がありますが、Createが同時に呼ばれるとIDが重複してしまうかもしれません。

そこでr.mu.Lock()で他の処理がCreateに入れないようにして、defer r.mu.Unlock()で処理の最後にロックを解除する、ということをしています。


さらっと書きましたが、並行処理はまだ全然理解できていないので、別途記事にできたらと思っています:runner_tone2:

また、このMutexは今回のようにメモリ上で処理を行うときに必要なことであって、DBで処理する際はDB側がうまいこと判断して、IDが重複しないようにしてくれるので、Mutexは不要なようです。


具体的なメソッドの内容については、Create()はインクリメントでIDの採番をしており、既存のtodosに新しくtodoを追加しているだけです。

todo.ID = r.idSeq
r.idSeq++
r.todos = append(r.todos, todo)

FindAll()については、Go特有のスライスや組み込み関数がいくつか出てくるので、少し丁寧に説明します。

result := make([]*domain.Todo, len(r.todos))
copy(result, r.todos)
return result, nil

まずmake関数でr.todosと同じ要素数を持つスライスを生成します。

(例としてメモリ上にTodoが3つ作成されているものとします)

r.todos = [ &Todo{ID: 1}, &Todo{ID: 2}, &Todo{ID: 3} ]

スクリーンショット 2025-03-30 20.36.34.png

そしてcopy関数を使って、makeで生成したresult(スライス)の要素に対して、r.todos(スライス)の要素をコピーしています。

スクリーンショット 2025-03-30 20.36.46.png

(注意点として、resultとr.todosのそれぞれの要素(*Todo)は同じアドレスを指しています。)

一見回りくどく見えてしまいますが、ここでcopyを使う目的は「スライス構造自体の分離」です。

仮にcopyを行わず、result := r.todosとしてしまったとします。

すると、FindAll()でTodo一覧を見ようとしたけど、その前にappendなどで要素が差し込まれた場合、見たかったTodo一覧とは別物がユーザーに表示されてしまいます。

それを防ぐためにr.todosをコピーして、resultに入れて返しているということですね。

Handler

HandlerはMVCのコントローラーに近いイメージです。

また、ここからはGinというWeb APIを作るためのフレームワークを使用しています。
ルーティングをURLに応じて直感的に書けたりします。

↓アイコンが好きです


todo_handler.go
package handler

import (
	"go-clean-architecture/domain"
	"go-clean-architecture/usecase"
	"net/http"

	"github.com/gin-gonic/gin"
)

type TodoHandler struct {
	Usecase *usecase.TodoUsecase
}

func NewTodoHandler(u *usecase.TodoUsecase) *TodoHandler {
	return &TodoHandler{Usecase: u}
}

func (h *TodoHandler) CreateTodo(c *gin.Context) {
	var todo domain.Todo
	if err := c.ShouldBindJSON(&todo); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
		return
	}

	if err := h.Usecase.Create(&todo); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create TODO"})
		return
	}

	c.JSON(http.StatusCreated, todo)
}

func (h *TodoHandler) GetTodos(c *gin.Context) {
	todos, err := h.Usecase.FindAll()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch TODOs"})
		return
	}

	c.JSON(http.StatusOK, todos)
}

まず、ginが使用されている箇所にフォーカスして見ていきましょう。

func (h *TodoHandler) CreateTodo(c *gin.Context) {...}
func (h *TodoHandler) GetTodos(c *gin.Context) {...}

HandlerにはCreateTodo()とGetTodos()というメソッドを定義しており、どちらもc *gin.Contextという引数をとりますが、Ginが内部で渡してくれるので、呼び出すときには特に意識しなくてOKです!

main.go
r.POST("/todos", h.CreateTodo) // 引数不要
r.GET("/todos", h.GetTodos)    // 引数不要

(↑このようにURLに応じてハンドラー関数を呼び出す。)


例えばユーザーが/todosというURLを指定してPOSTリクエストを送信した場合、GinがPOSTのリクエスト情報をContextに詰めてハンドラに渡してくれます。

そして、CreateTodo(c *gin.Context)では、ShouldBindJSONを使ってリクエストボディ(JSON)をdomain.Todo(構造体)にマッピングします。

func (h *TodoHandler) CreateTodo(c *gin.Context) {
	var todo domain.Todo
	if err := c.ShouldBindJSON(&todo); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
		return
	}
    ...
}

例えば、リクエストにTodoのtitleがなかったりしてマッピングに失敗した場合は、ステータスコードと共にエラーメッセージを返すようにします。

c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})

このあたりもGinの便利な書き方ですね。


そして、マッピングが問題なければ、Usecaseから渡されたCreateメソッドを使ってTodoを作成します。

if err := h.Usecase.Create(&todo); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create TODO"})
		return
	}

c.JSON(http.StatusCreated, todo)

クリーンアーキテクチャの観点では、usecaseのメソッドを次のように呼び出しています。

h.Usecase.Create()

h はhandlerのインスタンスであり、そのフィールドにUsecaseを持っています。これはhandlerの初期化時に、usecaseのポインタを注入しているためです。

ただ、TodoUsecase は TodoRepository の インターフェースに依存していたのに対し、TodoHandler は TodoUsecase の構造体に直接依存しています。
しかし、これがNGというわけではありません。

今回のアプリケーションではusecase→repositoryは実装が複数ありえます。(DBに保存、メモリに保存、モックでテストなど)

そのため、インターフェースに依存させる必要がありましたが、handler→usecaseは1つの実装で済むため、必ずしもインターフェースにする必要はないということです。

(↑必要に応じてこのようにインターフェースを作成・依存させる)

cmd

最後に、エントリーポイントとなるmain.goのコードです。

main.go
package main

import (
	"go-clean-architecture/handler"
	"go-clean-architecture/repository"
	"go-clean-architecture/usecase"

	"github.com/gin-gonic/gin"
)

func main() {
	repo := repository.NewInMemoryTodoRepository()

	u := usecase.NewTodoUsecase(repo)

	h := handler.NewTodoHandler(u)

	r := gin.Default()

	r.POST("/todos", h.CreateTodo)
	r.GET("/todos", h.GetTodos)

	r.Run(":8080")
}

今までの集大成として、usecaseにrepositoryを、handlerにusecaseを注入して、依存関係を生成しています。

もう少しmain()の中身を詳しくみてみると...

repo := repository.NewInMemoryTodoRepository()

TodoRepositoryインターフェースを満たす具体的な実装を生成します。
これをusecase層に渡して、データ操作の実際の処理として使用します。

u := usecase.NewTodoUsecase(repo)

usecaseはrepositoryの中身を知りませんが、インターフェース経由でその機能を使います。

h := handler.NewTodoHandler(u)

そして、handlerもusecaseの中身を知りませんが、提供された機能だけを使えるようにしておきます。


r := gin.Default()

r.POST("/todos", h.CreateTodo)
r.GET("/todos", h.GetTodos)

r.Run(":8080")

あとはエンドポイントとhandlerメソッドを結びつけて、リクエストに応じてハンドラーが呼ばれます。

こういったシンプルな書き方ができるのもGinのおかげです。


最後にr.Run()でHTTPサーバーを起動しています。


では、Postmanでtodoの作成と取得が正しく動くか確認してみます。

POSTリクエストでtodo作成→OK
スクリーンショット 2025-03-27 23.31.45.png

GETリクエストでtodo取得→OK
スクリーンショット 2025-03-27 23.32.08.png

無事成功しました!!:clap:

おわりに

Goの基礎知識+クリーンアーキテクチャを詰め込んだので、けっこう長くなってしまいましたが、おかげでGoの設計思想など大枠を掴むことができました!

あとはひたすら書いてGoに慣れていきたいと思います。

引用: ほったゆみ (著)、小畑健 (作)、ヒカルの碁 (ジャンプコミックスDIGITAL)

参考

22
13
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
22
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?