37
28

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

Posted at

前回はクリーンアーキテクチャで簡単な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

クリーンアーキテクチャの各層の依存関係はこんなイメージ

スクリーンショット 2025-09-06 14.02.05.png

作成するアプリケーションについて

今回はタスクやプロジェクトを管理するアプリケーションを想定して、POSTリクエストでタスクを作成する処理を実装します。

実装

それではDomain層から順番に実装していきます。

Domain

スクリーンショット 2025-09-06 14.01.48.png

ここではdomainsディレクトリを切って、以下の2つのファイルを作成します。

  • task.go
    • タスクのモデルの定義
  • repository.go
    • ポート(インターフェース)の定義
internal/domains/task.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
}
internal/domains/repository.go
package domains

type TaskRepository interface {
	Save(task *Task) error
}

「ポート」とは?

内側の層が外側に期待する契約(interface)のこと。

今回はRepositoryポートをDomain層に定義していますが、Usecase層に配置するパターンもあります。

Domainにおく
→ 集約単位の基本的な永続化契約に留めたい、より安定した契約にしたい。
Usecaseにおく
→ ユースケース固有のクエリやアプリケーション都合の操作が入る(例: 状態や絞り込みがユースケース依存)。

そのため、Infrastructure では domains/repository.go のinterfaceをimportして実装し、usecases はそのinterfaceにだけ依存するようにします。


Usecase

スクリーンショット 2025-09-06 14.01.40.png

usecaseはアプリケーション都合のロジックを書くレイヤーになります。

また、今まで出てこなかったDTOという概念が登場します。

internal/usecases/task_usecase.go
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 を定義します。

internal/dto/request/task.go
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

スクリーンショット 2025-09-06 14.01.32.png

Handler層はフロントからの HTTP リクエストを受け取り、I/O変換やバリデーションを行い、Usecase を呼び出して結果を HTTP レスポンスに整形して返します。

internal/interfaces/handlers/task_handler.go
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

スクリーンショット 2025-09-06 14.01.56.png

データベース接続の準備

Go から PostgreSQL に接続するために、以下を事前にインストールしておきましょう。

  • ORM: gorm
  • PostgreSQL ドライバー: gorm.io/driver/postgres

データベース接続処

以下のように SetupDB()関数を用意して、環境変数から接続情報を読み込み、gorm で接続します。

internal/infrastructures/db/gorm.go
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ファイルの内容をプロセス環境変数にロードします。

internal/infrastructures/env.go
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を組み立てられるようになります。

cmd/server/main.go
func main() {
	infrastructures.InitEnvironment()
	db := infrastructures.SetupDB()
}

Repository

Domain層のrepository.goでインターフェースを定義したので、その実装を行います。

internal/infrastructures/repositories/task_repo.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

スクリーンショット 2025-09-06 13.59.32.png

Routerは、API の入口としてルートとエンドポイントを紐付けます。

POSTで/tasksにリクエストが飛んでくると、Handler層のCreateメソッドを呼ぶようにします。

internal/interfaces/router.go
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では以下の処理を行っています。

  1. .envファイルの内容をプロセス環境変数に設定
  2. DB接続
  3. ルーディングのセットアップ
  4. ポート8080で待ち受ける
cmd/server/main.go
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 が実行され、コード変更時もホットリロードされます。

Air - Live reload for Go apps

Dockerfile
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/配下に出力するようにしています。

.air.toml
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を見れば一発でわかりますね。


実際に送信してみると...

スクリーンショット 2025-09-06 1.07.22.png

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初心者ながら、少しずつクリーンアーキテクチャのメリットがわかってきた気がします。

次回は今回実装した各レイヤーのテストを書いてみて、一旦クリーンアーキテクチャ入門シリーズは終了しようと思います〜💁


参考

37
28
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
37
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?