前回はクリーンアーキテクチャで簡単な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初心者ながら、少しずつクリーンアーキテクチャのメリットがわかってきた気がします。
次回は今回実装した各レイヤーのテストを書いてみて、一旦クリーンアーキテクチャ入門シリーズは終了しようと思います〜💁






