前回はクリーンアーキテクチャで簡単なTODOアプリを実装してみました。
今回はより実践的にDockerで環境構築し、PostgreSQLにデータを保存する処理をクリーンアーキテクチャで実装してみたいと思います。
基本的な流れは前回とそれほど変わりませんが、今回は実際のデータベースを扱う点と、リクエストデータをDTOに変換する処理など、より実際の開発に近いものにしようと思います。
※Dockerについての解説は今回の記事の対象外とさせてください🙏
ディレクトリ構成
この記事で説明しないファイルもありますが、一旦全体像を紹介します。
your_project/
├── cmd/
│ └── server/
│ └── main.go # エントリーポイント
├── internal/
│ ├── domains/ # ドメイン層
│ │ ├── task.go # エンティティ定義
│ │ └── repository.go # Repositoryインターフェース
│ ├── dto/ # dto
│ │ └── request # リクエストDTO
│ │ │ └── task.go
│ │ └── response # レスポンスDTO
│ │ └── task.go
│ ├── usecases/ # ユースケース層
│ │ └── task_usecase.go
│ ├── interfaces/ # インターフェース層
│ │ ├── handlers/ # Ginハンドラ
│ │ │ └── task_handler.go
│ │ └── router.go # Ginのルーティング定義
│ └── infrastructures/ # 外部I/O層
│ ├── env.go # 環境変数管理
│ ├── db.go # DB接続
│ │ └── repositories/ # Repository実装
│ │ └── task_repo.go # Task用Repository
│ └── migrations/ # マイグレーション
│ └── migration.go
├── go.mod
├── go.sum
└── Dockerfile
クリーンアーキテクチャの各層の依存関係はこんなイメージ
作成するアプリケーションについて
今回はタスクやプロジェクトを管理するアプリケーションを想定して、POSTリクエストでタスクを作成する処理を実装します。
実装
それではDomain層から順番に実装していきます。
Domain
ここではdomainsディレクトリを切って、以下の2つのファイルを作成します。
-
task.go
- タスクのモデルの定義
-
repository.go
- ポート(インターフェース)の定義
package domains
import "time"
type Task struct {
ID uint
ProjectID uint
Title string
Description string
Priority uint
DueDate time.Time
CreaterID uint
AssigneeID uint
CreatedAt time.Time
UpdatedAt time.Time
}
package domains
type TaskRepository interface {
Save(task *Task) error
}
「ポート」とは?
内側の層が外側に期待する契約(interface)のこと。
今回はRepositoryポートをDomain層に定義していますが、Usecase層に配置するパターンもあります。
Domainにおく
→ 集約単位の基本的な永続化契約に留めたい、より安定した契約にしたい。
Usecaseにおく
→ ユースケース固有のクエリやアプリケーション都合の操作が入る(例: 状態や絞り込みがユースケース依存)。
そのため、Infrastructure では domains/repository.go のinterfaceをimportして実装し、usecases はそのinterfaceにだけ依存するようにします。
Usecase
usecaseはアプリケーション都合のロジックを書くレイヤーになります。
また、今まで出てこなかったDTOという概念が登場します。
package usecases
import (
"time"
"github.com/minamoto-m/project-manager-backend/internal/domains"
"github.com/minamoto-m/project-manager-backend/internal/dto/request"
)
type TaskUsecase struct {
taskRepo domains.TaskRepository
}
func NewTaskUsecase(repo domains.TaskRepository) *TaskUsecase {
return &TaskUsecase{taskRepo: repo}
}
func (u *TaskUsecase) Create(req *request.TaskCreateRequest) (*domains.Task, error) {
// DTOからドメインエンティティを生成
task := &domains.Task{
ProjectID: req.ProjectID,
Title: req.Title,
Description: req.Description,
Priority: uint(req.Priority),
DueDate: parseDueDate(req.DueDate),
CreaterID: 1, // 仮の値(後で認証システムから取得)
AssigneeID: 1, // 仮の値(後で認証システムから取得)
CreatedAt: time.Now(),
}
if err := u.taskRepo.Save(task); err != nil {
return nil, err
}
return task, nil
}
// 文字列の日付をtime.Timeに変換
func parseDueDate(dueDateStr string) time.Time {
if dueDateStr == "" {
return time.Time{}
}
parsed, err := time.Parse("2006-01-02", dueDateStr)
if err != nil {
return time.Time{}
}
return parsed
}
Createメソッドの引数のTaskCreateRequest
がDTOになります。
Create(req *request.TaskCreateRequest)
「DTO」とは?
「データをやり取りするため」のオブジェクトで、アプリケーションの異なる層やシステム間でデータを効率的に受け渡すために使用される、データの「入れ物」です
つまり、Taskモデルをそのまま使うのではなく、POSTリクエストボディ用に最適化された構造を使おう、ということです。
internal/dto/request
に DTO を定義します。
package request
type TaskCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=255"`
Description string `json:"description" binding:"max=1000"`
ProjectID uint `json:"project_id" binding:"required"`
Priority uint `json:"priority" binding:"min=1,max=3"`
DueDate string `json:"due_date,omitempty"`
}
Usecase は原則として HTTP などのプロトコルに依存しません。
そのため、Handler で I/O 変換やバリデーションを済ませ、Usecase へはアプリ内の型(ドメイン型やユースケース入力モデル)で渡します。
func (u *TaskUsecase) Create(req *request.TaskCreateRequest) (*domains.Task, error) {
// DTOからドメインエンティティを生成
task := &domains.Task{
ProjectID: req.ProjectID,
Title: req.Title,
Description: req.Description,
Priority: uint(req.Priority),
DueDate: parseDueDate(req.DueDate),
CreaterID: 1, // 仮の値(後で認証システムから取得)
AssigneeID: 1, // 仮の値(後で認証システムから取得)
CreatedAt: time.Now(),
}
if err := u.taskRepo.Save(task); err != nil {
return nil, err
}
return task, nil
}
クライアントから渡らないフィールド(例: ID、作成者、作成日時)をサーバ側で補完しています。
handler
Handler層はフロントからの HTTP リクエストを受け取り、I/O変換やバリデーションを行い、Usecase を呼び出して結果を HTTP レスポンスに整形して返します。
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/minamoto-m/project-manager-backend/internal/dto/request"
"github.com/minamoto-m/project-manager-backend/internal/usecases"
)
type TaskHandler struct {
taskUsecase *usecases.TaskUsecase
}
func NewTaskHandler(taskUsecase *usecases.TaskUsecase) *TaskHandler {
return &TaskHandler{taskUsecase: taskUsecase}
}
func (h *TaskHandler) Create(c *gin.Context) {
var req request.TaskCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
// DTOをUsecaseに渡して、ドメイン生成を実行。
// 戻り値として作成済みのドメインエンティティを受け取る。
task, err := h.taskUsecase.Create(&req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
// ドメイン → レスポンスDTOへマッピングし、Locationヘッダを付けて201で返却
response := response.TaskResponse{
ID: task.ID,
ProjectID: task.ProjectID,
Title: task.Title,
Description: task.Description,
Priority: task.Priority,
DueDate: task.DueDate.Format(time.RFC3339),
CreatorID: task.CreaterID,
AssigneeID: task.AssigneeID,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
c.Header("Location", fmt.Sprintf("/api/v1/tasks/%d", task.ID))
c.JSON(http.StatusCreated, response)
}
↓こんな感じでHTTPリクエストが飛んでくるので、
POST /tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 123
{
"project_id": 1,
"title": "新しいタスク",
"description": "タスクの詳細説明",
"priority": 3,
"due_date": "2024-12-31"
}
↓ginのShouldBindJSON
メソッドでTaskCreateRequest
(DTO)にマッピングします。
この時点で、リクエストに不正がないかバリデーションが行われます。
func (h *TaskHandler) Create(c *gin.Context) {
var req request.TaskCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
続いて、この DTO を Usecase に渡してドメインエンティティを生成し、結果を受け取ります。受け取ったドメインをレスポンスDTOにマッピングし、作成されたリソースの URI を Location ヘッダに設定して 201 を返します。
// DTOをUsecaseに渡して、ドメイン生成を実行。
// 戻り値として作成済みのドメインエンティティを受け取る。
task, err := h.taskUsecase.Create(&req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
// ドメイン → レスポンスDTOへマッピングし、Locationヘッダを付けて201で返却
response := response.TaskResponse{
ID: task.ID,
ProjectID: task.ProjectID,
Title: task.Title,
Description: task.Description,
Priority: task.Priority,
DueDate: task.DueDate.Format(time.RFC3339),
CreatorID: task.CreaterID,
AssigneeID: task.AssigneeID,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
c.Header("Location", fmt.Sprintf("/api/v1/tasks/%d", task.ID))
c.JSON(http.StatusCreated, response)
リクエストと同様に、レスポンス用のDTOも作成します。
package response
import "time"
type TaskResponse struct {
ID uint `json:"id"`
ProjectID uint `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
Priority uint `json:"priority"`
DueDate string `json:"due_date"`
CreatorID uint `json:"creator_id"`
AssigneeID uint `json:"assignee_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
このhandler層ではHTTPリクエストを検証し、問題なければDTOをUseCaseに渡します。
・リクエストの形式は正しいか(必須/形式/長さなど)?
・どのHTTPステータスコードを返すべきか?
・レスポンスをどう構築するか?
逆にusecase層ではHandlerのHTTP事情は意識せず、アプリケーションのロジックに集中できます。
この設計により、仮に要件の変更があっても柔軟に対応可能です。
「優先度の計算方法を変更したい」
→ アプリケーションロジックのためUseCaseだけを修正
「レスポンス形式を変更したい」
→ HTTP関連の処理のためHandlerだけを修正
まさにこれがクリーンアーキテクチャの利点ですね!
Infrastructure / Repository
データベース接続の準備
Go から PostgreSQL に接続するために、以下を事前にインストールしておきましょう。
- ORM: gorm
- PostgreSQL ドライバー: gorm.io/driver/postgres
データベース接続処
以下のように SetupDB()
関数を用意して、環境変数から接続情報を読み込み、gorm で接続します。
package infrastructures
import (
"fmt"
"log"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func SetupDB() *gorm.DB {
env := os.Getenv("ENV")
log.Println("ENV: ", env)
dsn := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Tokyo",
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
)
var (
db *gorm.DB
err error
)
if env == "prod" {
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
log.Println("Setup postgres connection for prod")
} else {
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
log.Println("Setup postgres connection for local")
}
if err != nil {
panic("Failed to connect to database")
}
return db
}
ここでは DSNをos.Getenv
で取得しています。
そのため、接続前に環境変数を設定しておく必要があります。
(if env == "prod" { ... }
の箇所は、本番環境と開発環境で異なるDBやdsnを設定するためのコードです。今回は一旦ログメッセージだけ変えています。)
環境変数の設定
Goは.envファイルを直接参照できないため、godotenv
パッケージを使って.envファイルの内容をプロセス環境変数にロードします。
package infrastructures
import (
"log"
"github.com/joho/godotenv"
)
func InitEnvironment() {
err := godotenv.Load()
if err != nil {
log.Println("Error: loading .env file")
return
}
}
アプリ起動時(main.go)に InitEnvironment()
を実行しておけば、SetupDB()
内でos.Getenv
を使ってDSNを組み立てられるようになります。
func main() {
infrastructures.InitEnvironment()
db := infrastructures.SetupDB()
}
Repository
Domain層のrepository.goでインターフェースを定義したので、その実装を行います。
package repositories
import (
"github.com/minamoto-m/project-manager-backend/internal/domains"
"gorm.io/gorm"
)
type TaskRepository struct {
db *gorm.DB
}
func NewTaskRepository(db *gorm.DB) *TaskRepository {
return &TaskRepository{db: db}
}
func (r *TaskRepository) Save(task *domains.Task) error {
return r.db.Create(task).Error
}
このNewTaskRepository(コンストラクタ)関数は、TaskRepository構造体のインスタンスを作成する際に、具体的なデータベース接続(*gorm.DB)を外部から注入する役割を果たしています。
func NewTaskRepository(db *gorm.DB) *TaskRepository {
return &TaskRepository{db: db}
}
これにより、TaskERepository構造体はデータベース接続を保持しているため、r.db.Create
でGORMを使ってデータベースにタスクを保存します。
repositoryの関心は「データベースにタスクを保存すること」のみで、具体的なデータベース接続に関する情報は外部から受け取る(依存注入)という形で、関心の分離を実現しています。
Router
Routerは、API の入口としてルートとエンドポイントを紐付けます。
POSTで/tasksにリクエストが飛んでくると、Handler層のCreateメソッドを呼ぶようにします。
func SetupRouter(db *gorm.DB) *gin.Engine {
r := gin.Default()
taskRepo := repositories.NewTaskRepository(db)
taskUsecase := usecases.NewTaskUsecase(taskRepo)
taskHandler := handlers.NewTaskHandler(taskUsecase)
api := r.Group("/api/v1")
{
api.POST("/tasks", taskHandler.Create)
}
return r
}
エントリーポイント
main.goでは以下の処理を行っています。
- .envファイルの内容をプロセス環境変数に設定
- DB接続
- ルーディングのセットアップ
- ポート8080で待ち受ける
package main
import (
"github.com/minamoto-m/project-manager-backend/internal/infrastructures"
"github.com/minamoto-m/project-manager-backend/internal/interfaces"
)
func main() {
infrastructures.InitEnvironment()
db := infrastructures.SetupDB()
r := interfaces.SetupRouter(db)
r.Run(":8080")
}
【補足】main.goの実行について
今回はDockerを利用しているため、DockerfileにGoの開発支援ツールであるAirを組み込み、コンテナ起動時にAirが自動で立ち上がるようにしています。これにより、docker compose up を実行するだけで main.go が実行され、コード変更時もホットリロードされます。
FROM golang:1.24.1
WORKDIR /app
RUN go install github.com/air-verse/air@latest
COPY . .
CMD ["air"]
ちなみに、ルート直下には .air.toml という Air の設定ファイルを置くことができます。ここで監視対象のファイルやビルドコマンドを指定しておくと、変更があった際にAirが自動で再ビルド・再起動してくれます。
以下は一例ですが、main.goを含むcmd/serverディレクトリを監視し、ビルド成果物をtmp/配下に出力するようにしています。
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/server"
bin = "tmp/main"
full_bin = "tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["vendor", "tmp"]
この設定により、コードを編集して保存するたびに自動でリビルドされ、手動で go run し直す必要がなくなります。
Dockerを使わない環境ではgo run main.go
でmain()関数が動作しますので、その違いを補足として解説しました。
動作確認
では実際にPOSTリクエストを送信してみましょう!
(dockerコンテナを起動していない場合は、docker compose up
を実行して、Goを立ち上げておいてください)
Postmanでリクエストを作成します。
リクエストボディに設定する値も、DTOを見れば一発でわかりますね。
実際に送信してみると...
201が返ってきました!!
では、データベースにちゃんとデータが登録されているか確認します。
↓postgreSQLの場合、psql
コマンドを使用します。
$ docker exec -it project-manager-db psql -U taskuser -d taskdb
tasksというテーブルがあるので、中身を見てみます。
$ \dt
Schema | Name | Type | Owner
--------+-------+-------+----------
public | tasks | table | taskuser
(1 row)
tasksテーブルのレコードを確認します。
$ SELECT * FROM tasks;
id | project_id | title | description | priority | due_date | creater_id | assignee_id | created_at | updated_at
----+------------+------------+----------------------------+----------+------------------------+------------+-------------+-------------------------------+------------------------------
1 | 999 | テストです | タスクの詳細説明が入ります | 1 | 2024-12-31 00:00:00+00 | 1 | 1 | 2025-09-05 16:06:51.822285+00 | 2025-09-05 16:06:51.82416+00
(1 row)
↑ちゃんと登録されていましたね!!
おつかれさまでした🍵
おわりに
Go初心者ながら、少しずつクリーンアーキテクチャのメリットがわかってきた気がします。
次回は今回実装した各レイヤーのテストを書いてみて、一旦クリーンアーキテクチャ入門シリーズは終了しようと思います〜💁