PR

Go言語(Golang)のGinでDDD(ドメイン駆動設計)構成のバックエンドAPIを開発する方法まとめ

応用

こんにちは。Tomoyuki(@tomoyuki65)です。

これまではクリーンアーキテクチャを参考にしてGo言語のAPIの作り方を解説してきましたが、実務においてはDDD(ドメイン駆動設計)と呼ばれる方法で作られていることが多いです。

そんなDDDは、ソフトウェアが扱う対象の「ビジネス領域」や「問題領域」そのものをドメインとして定義し、その分野の専門家と開発者が協力してビジネスルールを設計に落とし込む手法です。

例えばオンラインショッピングサイトなら「商品管理」や「注文処理」、会計ソフトなら「経理」や「請求」がドメインにあたります。

このようにビジネスの中心的な課題に焦点を当て、専門家と開発者が密に連携することで、認識のズレを防ぎながらビジネスの成長に合わせてソフトウェアを柔軟に進化させていくことが可能になります。

ということでこの記事では、Go言語のGinでDDD構成のバックエンドAPIを開発する方法についてまとめます。

 

Go言語(Golang)のGinでDDD(ドメイン駆動設計)構成のバックエンドAPIを開発する方法まとめ

今回はGo言語でよく使われているGinというフレームワークを使いますが、まずは以下のコマンド実行して各種ファイルを作成します。

$ mkdir go-gin-domain && cd go-gin-domain
$ mkdir -p docker/local/go && touch docker/local/go/Dockerfile
$ mkdir src && touch src/.env src/.env.testing src/main.go
$ touch compose.yml

※いつものようにDockerを使って環境構築するため、事前にDockerが使える環境を準備して下さい。

 

次に作成したファイルをそれぞれ以下のように記述します。

・「docker/local/go/Dockerfile」

FROM golang:1.24-alpine3.21

WORKDIR /go/src

COPY ./src .

# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
      go install; \
    fi

# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@latest
RUN go install honnef.co/go/tools/cmd/staticcheck@latest
RUN go install go.uber.org/mock/mockgen@latest
RUN go install github.com/swaggo/swag/cmd/swag@latest

EXPOSE 8080

※ホットリロードに「air」、静的コード解析に「staticcheck」、テスト用のモックファイル作成に「mockgen」、OpenAPI用に「swag」をインストールしています。

 

・「src/.env」

ENV=local
PORT=8080

 

・「src/.env.testing」

ENV=testing
PORT=8080

 

・「src/main.go」

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello World !!")
}

 

・「compose.yml」

services:
  api:
    container_name: go-gin-d-api
    build:
      context: .
      dockerfile: ./docker/local/go/Dockerfile
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    ports:
      - "8080:8080"
    tty: true
    stdin_open: true

 

次に以下のコマンドを実行し、コンテナのビルドをします。

$ docker compose build --no-cache

 

次に以下のコマンドを実行し、goの初期化をします。

$ docker compose run --rm api go mod init go-gin-domain
$ docker compose run --rm api go mod tidy
$ docker compose run --rm api air init

 

共通設定のファイルを追加

次に以下のコマンドを実行し、共通設定のファイルを追加します。

$ mkdir -p src/internal/presentation/middleware && touch src/internal/presentation/middleware/middleware.go
$ mkdir -p src/internal/application/usecase/logger && touch src/internal/application/usecase/logger/logger.go
$ mkdir -p src/internal/infrastructure/logger && touch src/internal/infrastructure/logger/logger_slog.go
$ mkdir -p src/internal/infrastructure/database && touch src/internal/infrastructure/database/dummy.go

※今回は直接DBは使わないので、ダミーの設定ファイルを作成して使います。

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/presentation/middleware/middleware.go」

package middleware

import (
    "context"
    "fmt"
    "net/http"
    "strings"

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

type contextKey string

const (
    RequestId contextKey = "Request-Id"
    XRequestSource contextKey = "X-Request-Source"
    UID contextKey = "UID"
)

type Middleware struct{}

func NewMiddleware() *Middleware {
    return &Middleware{}
}

// リクエスト用
func (m *Middleware) Request() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 一意のIDを取得
        uuid := uuid.New().String()

        // 共通コンテキストにX-Request-Idを設定
        ctx := c.Request.Context()
        ctx = context.WithValue(ctx, RequestId, uuid)

        // リクエストヘッダーからX-Request-Sourceを取得
        xRequestSource := c.GetHeader(string(XRequestSource))
        if xRequestSource == "" {
            xRequestSource = "-"
        }

        // 共通コンテキストにX-Request-Sourceを設定
        ctx = context.WithValue(ctx, XRequestSource, xRequestSource)

        // 共通コンテキストの設定
        c.Request = c.Request.WithContext(ctx)

        c.Next()
    }
}

// カスタムロガー
func (m *Middleware) CustomLogger() gin.HandlerFunc {
    return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
        // Request-Idの取得
        requestID, ok := param.Request.Context().Value(RequestId).(string)
        if !ok {
            requestID = "-"
        }

        // カラーの取得
        var statusColor, methodColor, resetColor string
        if param.IsOutputColor() {
            statusColor = param.StatusCodeColor()
            methodColor = param.MethodColor()
            resetColor = param.ResetColor()
        }

        // ログのフォーマットを定義
        // [GIN] 2006/01/02 - 15:04:05 | {Request-Id} | 200 | 1.2345ms | 127.0.0.1 | GET /path
        return fmt.Sprintf("[GIN] %v| %s |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s",
            param.TimeStamp.Format("2006/01/02 - 15:04:05"),
            requestID,
            statusColor,
            param.StatusCode,
            resetColor,
            param.Latency,
            param.ClientIP,
            methodColor,
            param.Method,
            resetColor,
            param.Path,
            param.ErrorMessage,
        )
    })
}

// 認証用
func (m *Middleware) Auth() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Bearerトークン取得
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "message": "認証用トークンが設定されていません。",
            })
            return
        }
        token := strings.TrimPrefix(authHeader, "Bearer ")
        if token == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "message": "認証用トークンが設定されていません。",
            })
            return
        }

        // TODO: 認証チェックを追加する
        // TODO: 認証済みならuidを取得
        uid := "-"

        // 共通コンテキストにuidを設定
        ctx := c.Request.Context()
        ctx = context.WithValue(ctx, UID, uid)

        // 共通コンテキストの設定
        c.Request = c.Request.WithContext(ctx)

        c.Next()
    }
}

※リクエスト用、Ginのロガー用、認証用のミドルウェアの例を定義しています。

 

・「src/internal/application/usecase/logger/logger.go」

package logger

import (
    "context"
)

type Logger interface {
    Info(ctx context.Context, message string)
    Warn(ctx context.Context, message string)
    Error(ctx context.Context, message string)
}

※ここではインターフェースのみ定義します。

 

・「src/internal/infrastructure/logger/logger_slog.go」

package logger

import (
    "context"
    "log/slog"
    "os"

    logger_usecase "go-gin-domain/internal/application/usecase/logger"
    "go-gin-domain/internal/presentation/middleware"
)

// slogの設定
type SlogHandler struct {
    slog.Handler
}

func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
    requestId, ok := ctx.Value(middleware.RequestId).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "requestId", Value: slog.String("requestId", requestId).Value})
    }

    xRequestSource, ok := ctx.Value(middleware.XRequestSource).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "xRequestSource", Value: slog.String("xRequestSource", xRequestSource).Value})
    }

    uid, ok := ctx.Value(middleware.UID).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "UID", Value: slog.String("UID", uid).Value})
    }

    return h.Handler.Handle(ctx, r)
}

var slogHandler = &SlogHandler{
    slog.NewTextHandler(os.Stdout, nil),
}
var logger = slog.New(slogHandler)

// ロガーの設定
type slogLogger struct{}

func NewSlogLogger() logger_usecase.Logger {
    return &slogLogger{}
}

func (l *slogLogger) Info(ctx context.Context, message string) {
    logger.InfoContext(ctx, message)
}

func (l *slogLogger) Warn(ctx context.Context, message string) {
    logger.WarnContext(ctx, message)
}

func (l *slogLogger) Error(ctx context.Context, message string) {
    logger.ErrorContext(ctx, message)
}

※インフラストラクチャ層でロガーの実装を行います。そしてロガーにはslogを使います。

 

・「src/internal/infrastructure/database/dummy.go」

package database

import (
    "context"
    "fmt"

    "go-gin-domain/internal/application/usecase/logger"
)

type DummyConfig struct {
    Dummy string
}

func NewDummyConnection(cfg DummyConfig, logger logger.Logger) (string, error) {
    dsn := fmt.Sprintf("dummy=%s", cfg.Dummy)

    // ログ出力
    ctx := context.Background()
    msg := fmt.Sprintf("Successfully connected to %s", "Dummy")
    logger.Info(ctx, msg)

    return dsn, nil
}

※今回はダミーのため文字列を返していますが、実際にDBを使う場合はDBのインスタンスを返すように修正して下さい。

 

次に以下のコマンドを実行し、ロガーのインターフェース定義からテストコード用のモックファイルを作成します。

$ docker compose run --rm api mockgen -source=./internal/application/usecase/logger/logger.go -destination=./internal/application/usecase/logger/mock_logger/mock_logger.go

 

次に以下のコマンドを実行し、go.modの修正およびコンテナの再ビルドを行います。

$ docker compose run --rm api go mod tidy
$ docker compose build --no-cache

 

スポンサーリンク

DDD(ドメイン駆動設計)のディレクトリ構成について

この後にDDD(ドメイン駆動設計)でAPIを作成していきますが、ディレクトリ構成としてはDDDの思想に基づいたレイヤードアーキテクチャを採用しています。

/src
└── /internal
    ├── /application(アプリケーション層)
    |   └── usecase(ユースケース層)
    |
    ├── /domain(ドメイン層)
    |   ├── model(ドメインモデルの定義。ビジネスロジックは可能な限りドメインに集約させる。)
    |   ├── repository(リポジトリのインターフェース定義)
    |   └── (仮)service(外部サービスのインターフェース定義)
    |
    ├── /infrastructure(インフラストラクチャ層)
    |   ├── database(データベース設定)
    |   ├── logger(ロガーの実装。インターフェース部分はユースケース層で定義。)
    |   ├── persistence(リポジトリの実装。DB操作による永続化層。)
    |   ├── (仮)cache(キャッシュを含めたリポジトリの実装。インターフェースはリポジトリと同一。)
    |   └── (仮)externalapi(外部サービスの実装)
    |
    ├── /presentation(プレゼンテーション層)
    |   ├── handler(ハンドラー層)
    |   ├── middleware(ミドルウェアの定義)
    |   └── router(ルーター設定。レジストリのコントローラーを利用して設定する。)
    |
    └── /registry(レジストリ層。依存注入によるハンドラーのインスタンスをコントローラーにまとめる。)

※(仮)のものは将来的に追加する想定の例です。

 

ユーザードメインを例にAPIを作る

次に以下の手順でユーザードメインを例にAPIを作成します。

 

ドメインの定義

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir -p src/internal/domain/user
$ touch src/internal/domain/user/user_model.go src/internal/domain/user/user_model_test.go
$ touch src/internal/domain/user/user_repository.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/domain/user/user_model.go」

package user

import (
    "fmt"
    "strings"
    "time"
)

type User struct {
    ID int64 `json:"-"`
    UID string `json:"uid"`
    LastName string `json:"last_name"`
    FirstName string `json:"first_name"`
    Email string `json:"email"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    DeletedAt *time.Time `json:"deleted_at"`
}

func NewUser(uid, lastName, firstName, email string) *User {
    return &User{
        ID: 0,
        UID: uid,
        LastName: lastName,
        FirstName: firstName,
        Email: email,
        CreatedAt: time.Time{},
        UpdatedAt: time.Time{},
        DeletedAt: nil,
    }
}

// プロフィール更新
func (u *User) UpdateProfile(lastName, firstName, email string) error {
    // パラメータチェック
    var errMsg []string
    if lastName == "" {
        errMsg = append(errMsg, "last_nameは必須です。")
    }
    if firstName == "" {
        errMsg = append(errMsg, "first_nameは必須です。")
    }
    if email == "" {
        errMsg = append(errMsg, "emailは必須です。")
    }
    if len(errMsg) > 0 {
        msg := fmt.Sprintf("バリデーションエラー: %s", strings.Join(errMsg, ", "))
        return fmt.Errorf("%s", msg)
    }

    // 更新
    u.LastName = lastName
    u.FirstName = firstName
    u.Email = email
    u.UpdatedAt = time.Now()

    return nil
}

// 論理削除設定
func (u *User) SetDelete() {
    // 現在の日時を文字列で取得
    date := time.Now()
    dateString := date.Format("2006-01-02 15:04:05")

    // 更新用のemailの値を設定
    updateEmail := u.Email + dateString

    // 更新
    u.Email = updateEmail
    u.UpdatedAt = date
    u.DeletedAt = &date
}

※ドメインに関するビジネスロジックについては、可能な限りドメインのメソッドして定義するようにします。

 

・「src/internal/domain/user/user_model_test.go」

package user

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
)

func TestNewUser(t *testing.T) {
    t.Run("新規ユーザー作成", func(t *testing.T) {
        uid := "xxxx-xxxx-xxxx-0001"
        lastName := "田中"
        firstName := "太郎"
        email := "t.tanaka@example.com"

        // 処理実行
        user := NewUser(uid, lastName, firstName, email)

        // 検証
        assert.NotNil(t, user)
        assert.Equal(t, user.ID, int64(0))
        assert.Equal(t, uid, user.UID)
        assert.Equal(t, lastName, user.LastName)
        assert.Equal(t, firstName, user.FirstName)
        assert.Equal(t, email, user.Email)
        assert.True(t, user.CreatedAt.IsZero())
        assert.True(t, user.UpdatedAt.IsZero())
        assert.Nil(t, user.DeletedAt)
    })
}

func TestUser_UpdateProfile(t *testing.T) {
    baseUser := func() *User {
        return &User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
    }

    t.Run("プロフィール更新処理が正常終了", func(t *testing.T) {
        user := baseUser()
        oldUpdatedAt := user.UpdatedAt

        newLastName := "佐藤"
        newFirstName := "二郎"
        newEmail := "z.satou@example.com"

        // 処理実行
        err := user.UpdateProfile(newLastName, newFirstName, newEmail)

        // 検証
        assert.NoError(t, err)
        assert.Equal(t, newLastName, user.LastName)
        assert.Equal(t, newFirstName, user.FirstName)
        assert.Equal(t, newEmail, user.Email)
        assert.True(t, user.UpdatedAt.After(oldUpdatedAt))
    })

    t.Run("プロフィール更新処理でlast_nameが空の場合エラー", func(t *testing.T) {
        user := baseUser()

        newLastName := ""
        newFirstName := "二郎"
        newEmail := "z.satou@example.com"

        // 処理実行
        err := user.UpdateProfile(newLastName, newFirstName, newEmail)

        // 検証
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "last_nameは必須です。")
    })

    t.Run("プロフィール更新処理でfirst_nameが空の場合エラー", func(t *testing.T) {
        user := baseUser()

        newLastName := "佐藤"
        newFirstName := ""
        newEmail := "z.satou@example.com"

        // 処理実行
        err := user.UpdateProfile(newLastName, newFirstName, newEmail)

        // 検証
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "first_nameは必須です。")
    })

    t.Run("プロフィール更新処理でemailが空の場合エラー", func(t *testing.T) {
        user := baseUser()

        newLastName := "佐藤"
        newFirstName := "二郎"
        newEmail := ""

        // 処理実行
        err := user.UpdateProfile(newLastName, newFirstName, newEmail)

        // 検証
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "emailは必須です。")
    })

    t.Run("プロフィール更新処理で複数項目が空の場合エラー", func(t *testing.T) {
        user := baseUser()

        // 処理実行
        err := user.UpdateProfile("", "", "")

        // 検証
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "last_nameは必須です。")
        assert.Contains(t, err.Error(), "first_nameは必須です。")
        assert.Contains(t, err.Error(), "emailは必須です。")
    })
}

func TestUser_SetDelete(t *testing.T) {
    t.Run("論理削除設定がされること", func(t *testing.T) {
        user := &User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        initialEmail := user.Email
        initialUpdatedAt := user.UpdatedAt

        // 処理実行
        user.SetDelete()

        // 検証
        assert.NotEqual(t, initialEmail, user.Email)
        assert.True(t, user.UpdatedAt.After(initialUpdatedAt))
        assert.NotNil(t, user.DeletedAt)
        assert.Equal(t, user.UpdatedAt, *user.DeletedAt)
    })
}

 

・「src/internal/domain/user/user_repository.go」

package user

import (
    "context"
)

type UserRepository interface {
    Create(ctx context.Context, user *User) (*User, error)
    FindAll(ctx context.Context) ([]*User, error)
    FindByUID(ctx context.Context, uid string) (*User, error)
    Save(ctx context.Context, user *User) (*User, error)
}

※DB操作に関するものはリポジトリのインターフェースを定義します。実装は後述のインフラストラクチャ層で行います。

 

次に以下のコマンドを実行し、フォーマット修正および静的コード解析を行い、警告が出ないことを確認します。

$ docker compose run --rm api go mod tidy
$ docker compose run --rm api go fmt ./...
$ docker compose run --rm api staticcheck ./...

 

次に以下のコマンドでテストコードを実行します。

$ docker compose run --rm api go test -v $(docker compose run --rm api go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)

 

テスト実行後、下図のように全てのテストがPASSすればOKです。

 

次に以下のコマンドを実行し、後述のテストコード用のモックファイルを作成します。

$ docker compose run --rm api mockgen -source=./internal/domain/user/user_repository.go -destination=./internal/domain/user/mock_user_repository/mock_user_repository.go

 

リポジトリの実装

次にDB操作に関するリポジトリの実装をするため、以下のコマンドを実行してファイルを作成します。

$ mkdir -p src/internal/infrastructure/persistence/user && touch src/internal/infrastructure/persistence/user/user_repository.go

 

次に作成したファイルを以下のように記述します。

・「src/internal/infrastructure/persistence/user/user_repository.go」

package user

import (
    "context"
    "fmt"
    "time"

    logger_usecase "go-gin-domain/internal/application/usecase/logger"
    domain "go-gin-domain/internal/domain/user"
)

type userRepository struct {
    db string // 型はDummy
    logger logger_usecase.Logger
}

func NewUserRepository(db string, logger logger_usecase.Logger) domain.UserRepository {
    return &userRepository{
        db: db,
        logger: logger,
    }
}

func (r *userRepository) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
    // 戻り値の例
    createUser := &domain.User{
        ID: 1,
        UID: user.UID,
        LastName: user.LastName,
        FirstName: user.FirstName,
        Email: user.Email,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
        DeletedAt: nil,
    }

    dummy := &domain.User{}
    if createUser == dummy {
        msg := fmt.Sprintf("[%s] user is nil", r.db)
        r.logger.Error(ctx, msg)
    }

    return createUser, nil
}

func (r *userRepository) FindAll(ctx context.Context) ([]*domain.User, error) {
    // 戻り値の例
    users := []*domain.User{
        {
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        },
        {
            ID: 2,
            UID: "xxxx-xxxx-xxxx-0002",
            LastName: "佐藤",
            FirstName: "一郎",
            Email: "i.satou@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        },
    }

    if len(users) == 0 {
        msg := fmt.Sprintf("[%s] users is nil", r.db)
        r.logger.Error(ctx, msg)
    }

    return users, nil
}

func (r *userRepository) FindByUID(ctx context.Context, uid string) (*domain.User, error) {
    // 戻り値の例
    var user *domain.User
    if uid == "xxxx-xxxx-xxxx-0001" {
        user = &domain.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
    } else {
        user = nil
    }

    return user, nil
}

func (r *userRepository) Save(ctx context.Context, user *domain.User) (*domain.User, error) {
    // 戻り値の例
    return user, nil
}

※今回は直接DBは使わないため、リポジトリの実装はサンプルです。実際にはr.dbを使ってDB操作を行うのと、それに関するテストコードも書くようにして下さい。

 

ユースケースの定義

次にユースケースを定義するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/internal/application/usecase/user && touch src/internal/application/usecase/user/user.go
$ touch src/internal/application/usecase/user/user_create.go src/internal/application/usecase/user/user_create_test.go
$ touch src/internal/application/usecase/user/user_find_all.go src/internal/application/usecase/user/user_find_all_test.go
$ touch src/internal/application/usecase/user/user_find_by_uid.go src/internal/application/usecase/user/user_find_by_uid_test.go
$ touch src/internal/application/usecase/user/user_update.go src/internal/application/usecase/user/user_update_test.go
$ touch src/internal/application/usecase/user/user_delete.go src/internal/application/usecase/user/user_delete_test.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/application/usecase/user/user.go」

package user

import (
    "context"

    "go-gin-domain/internal/application/usecase/logger"
    domain_user "go-gin-domain/internal/domain/user"
)

type UserUsecase interface {
    Create(ctx context.Context, lastName, firstName, email string) (*domain_user.User, error)
    FindAll(ctx context.Context) ([]*domain_user.User, error)
    FindByUID(ctx context.Context, uid string) (*domain_user.User, error)
    Update(ctx context.Context, uid, lastName, firstName, email string) (*domain_user.User, error)
    Delete(ctx context.Context, uid string) (*domain_user.User, error)
}

type userUsecase struct {
    userRepo domain_user.UserRepository
    logger logger.Logger
}

func NewUserUsecase(userRepo domain_user.UserRepository, logger logger.Logger) UserUsecase {
    return &userUsecase{
        userRepo: userRepo,
        logger: logger,
    }
}

※このファイルでユースケースの構造体やインターフェース定義をまとめ、インターフェースに定義した各メソッドはファイルを分割して記述します。

 

・「src/internal/application/usecase/user/user_create.go」

package user

import (
    "context"

    domain_user "go-gin-domain/internal/domain/user"

    "github.com/google/uuid"
)

func (u *userUsecase) Create(ctx context.Context, lastName, firstName, email string) (*domain_user.User, error) {
    // UIDの設定(仮)
    uid := uuid.New().String()

    // 新規ユーザー作成
    user := domain_user.NewUser(uid, lastName, firstName, email)

    return u.userRepo.Create(ctx, user)
}

 

・「src/internal/application/usecase/user/user_create_test.go」

package user

import (
    "context"
    "fmt"
    "testing"
    "time"

    mockLogger "go-gin-domain/internal/application/usecase/logger/mock_logger"
    domain_user "go-gin-domain/internal/domain/user"
    mockUser "go-gin-domain/internal/domain/user/mock_user_repository"

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

// 初期処理
func init() {
    // テスト用の環境変数ファイル「.env.testing」を読み込んで使用する。
    if err := godotenv.Load("../../../../.env.testing"); err != nil {
        fmt.Println(".env.testingの読み込みに失敗しました。")
    }
}

func TestUserUsecase_Create(t *testing.T) {
    // リポジトリのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockRepo := mockUser.NewMockUserRepository(ctrl)

    // ロガーのモック
    mockLogger := mockLogger.NewMockLogger(ctrl)

    t.Run("正常終了すること", func(t *testing.T) {
        // モック化
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        lastName := "田中"
        firstName := "太郎"
        email := "t.tanaka@example.com"
        user, err := userUsecase.Create(ctx, lastName, firstName, email)

        // 検証
        assert.NoError(t, err)
        assert.NotNil(t, user)

        assert.Equal(t, expectedUser.ID, user.ID)
        assert.Equal(t, expectedUser.UID, user.UID)
        assert.Equal(t, expectedUser.LastName, user.LastName)
        assert.Equal(t, expectedUser.FirstName, user.FirstName)
        assert.Equal(t, expectedUser.Email, user.Email)
        assert.NotNil(t, user.CreatedAt)
        assert.NotNil(t, user.UpdatedAt)
        assert.Nil(t, user.DeletedAt)
    })
}

 

・「src/internal/application/usecase/user/user_find_all.go」

package user

import (
    "context"

    domain_user "go-gin-domain/internal/domain/user"
)

func (u *userUsecase) FindAll(ctx context.Context) ([]*domain_user.User, error) {
    return u.userRepo.FindAll(ctx)
}

 

・「src/internal/application/usecase/user/user_find_all_test.go」

package user

import (
    "context"
    "fmt"
    "testing"
    "time"

    mockLogger "go-gin-domain/internal/application/usecase/logger/mock_logger"
    domain_user "go-gin-domain/internal/domain/user"
    mockUser "go-gin-domain/internal/domain/user/mock_user_repository"

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

// 初期処理
func init() {
    // テスト用の環境変数ファイル「.env.testing」を読み込んで使用する。
    if err := godotenv.Load("../../../../.env.testing"); err != nil {
        fmt.Println(".env.testingの読み込みに失敗しました。")
    }
}

func TestUserUsecase_FindAll(t *testing.T) {
    // リポジトリのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockRepo := mockUser.NewMockUserRepository(ctrl)

    // ロガーのモック
    mockLogger := mockLogger.NewMockLogger(ctrl)

    t.Run("正常終了すること", func(t *testing.T) {
        // モック化
        expectedUsers := []*domain_user.User{
            {
                ID: 1,
                UID: "xxxx-xxxx-xxxx-0001",
                LastName: "田中",
                FirstName: "太郎",
                Email: "t.tanaka@example.com",
                CreatedAt: time.Time{},
                UpdatedAt: time.Time{},
                DeletedAt: nil,
            },
            {
                ID: 2,
                UID: "xxxx-xxxx-xxxx-0002",
                LastName: "佐藤",
                FirstName: "一郎",
                Email: "i.satou@example.com",
                CreatedAt: time.Time{},
                UpdatedAt: time.Time{},
                DeletedAt: nil,
            },
        }
        mockRepo.EXPECT().FindAll(gomock.Any()).Return(expectedUsers, nil)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        users, err := userUsecase.FindAll(ctx)

        // 検証
        assert.NoError(t, err)
        assert.NotNil(t, users)
        assert.Equal(t, len(expectedUsers), len(users))

        assert.Equal(t, expectedUsers[0].ID, users[0].ID)
        assert.Equal(t, expectedUsers[0].UID, users[0].UID)
        assert.Equal(t, expectedUsers[0].LastName, users[0].LastName)
        assert.Equal(t, expectedUsers[0].FirstName, users[0].FirstName)
        assert.Equal(t, expectedUsers[0].Email, users[0].Email)
        assert.Equal(t, expectedUsers[0].CreatedAt, users[0].CreatedAt)
        assert.Equal(t, expectedUsers[0].UpdatedAt, users[0].UpdatedAt)
        assert.Equal(t, expectedUsers[0].DeletedAt, users[0].DeletedAt)

        assert.Equal(t, expectedUsers[1].ID, users[1].ID)
        assert.Equal(t, expectedUsers[1].UID, users[1].UID)
        assert.Equal(t, expectedUsers[1].LastName, users[1].LastName)
        assert.Equal(t, expectedUsers[1].FirstName, users[1].FirstName)
        assert.Equal(t, expectedUsers[1].Email, users[1].Email)
        assert.Equal(t, expectedUsers[1].CreatedAt, users[1].CreatedAt)
        assert.Equal(t, expectedUsers[1].UpdatedAt, users[1].UpdatedAt)
        assert.Equal(t, expectedUsers[1].DeletedAt, users[1].DeletedAt)
    })
}

 

・「src/internal/application/usecase/user/user_find_by_uid.go」

package user

import (
    "context"

    domain_user "go-gin-domain/internal/domain/user"
)

func (u *userUsecase) FindByUID(ctx context.Context, uid string) (*domain_user.User, error) {
    return u.userRepo.FindByUID(ctx, uid)
}

 

・「src/internal/application/usecase/user/user_find_by_uid_test.go」

package user

import (
    "context"
    "fmt"
    "testing"
    "time"

    mockLogger "go-gin-domain/internal/application/usecase/logger/mock_logger"
    domain_user "go-gin-domain/internal/domain/user"
    mockUser "go-gin-domain/internal/domain/user/mock_user_repository"

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

// 初期処理
func init() {
    // テスト用の環境変数ファイル「.env.testing」を読み込んで使用する。
    if err := godotenv.Load("../../../../.env.testing"); err != nil {
        fmt.Println(".env.testingの読み込みに失敗しました。")
    }
}

func TestUserUsecase_FindByUID(t *testing.T) {
    // リポジトリのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockRepo := mockUser.NewMockUserRepository(ctrl)

    // ロガーのモック
    mockLogger := mockLogger.NewMockLogger(ctrl)

    t.Run("正常終了すること", func(t *testing.T) {
        // モック化
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        user, err := userUsecase.FindByUID(ctx, uid)

        // 検証
        assert.NoError(t, err)
        assert.NotNil(t, user)

        assert.Equal(t, expectedUser.ID, user.ID)
        assert.Equal(t, expectedUser.UID, user.UID)
        assert.Equal(t, expectedUser.LastName, user.LastName)
        assert.Equal(t, expectedUser.FirstName, user.FirstName)
        assert.Equal(t, expectedUser.Email, user.Email)
        assert.NotNil(t, user.CreatedAt)
        assert.NotNil(t, user.UpdatedAt)
        assert.Nil(t, user.DeletedAt)
    })
}

 

・「src/internal/application/usecase/user/user_update.go」

package user

import (
    "context"
    "fmt"

    domain_user "go-gin-domain/internal/domain/user"
)

func (u *userUsecase) Update(ctx context.Context, uid, lastName, firstName, email string) (*domain_user.User, error) {
    user, err := u.userRepo.FindByUID(ctx, uid)
    if err != nil {
        return nil, err
    }

    // 対象ユーザーが存在しない場合はエラー
    if user == nil {
        msg := fmt.Sprintf("対象ユーザーが存在しません。: UID=%s", uid)
        u.logger.Error(ctx, msg)
        return nil, fmt.Errorf("%s", msg)
    }

    // プロフィール更新
    err = user.UpdateProfile(lastName, firstName, email)
    if err != nil {
        return nil, err
    }

    return u.userRepo.Save(ctx, user)
}

 

・「src/internal/application/usecase/user/user_update_test.go」

package user

import (
    "context"
    "fmt"
    "testing"
    "time"

    mockLogger "go-gin-domain/internal/application/usecase/logger/mock_logger"
    domain_user "go-gin-domain/internal/domain/user"
    mockUser "go-gin-domain/internal/domain/user/mock_user_repository"

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

// 初期処理
func init() {
    // テスト用の環境変数ファイル「.env.testing」を読み込んで使用する。
    if err := godotenv.Load("../../../../.env.testing"); err != nil {
        fmt.Println(".env.testingの読み込みに失敗しました。")
    }
}

func TestUserUsecase_Update(t *testing.T) {
    // リポジトリのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockRepo := mockUser.NewMockUserRepository(ctrl)

    // ロガーのモック
    mockLogger := mockLogger.NewMockLogger(ctrl)

    t.Run("正常終了すること", func(t *testing.T) {
        // モック化
        findUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(findUser, nil)

        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "佐藤",
            FirstName: "二郎",
            Email: "z.satou@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Now(),
            DeletedAt: nil,
        }
        mockRepo.EXPECT().Save(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        lastName := "佐藤"
        firstName := "二郎"
        email := "z.satou@example.com"
        user, err := userUsecase.Update(ctx, uid, lastName, firstName, email)

        // 検証
        assert.NoError(t, err)
        assert.NotNil(t, user)

        assert.Equal(t, expectedUser.ID, user.ID)
        assert.Equal(t, expectedUser.UID, user.UID)
        assert.Equal(t, expectedUser.LastName, user.LastName)
        assert.Equal(t, expectedUser.FirstName, user.FirstName)
        assert.Equal(t, expectedUser.Email, user.Email)
        assert.NotNil(t, user.CreatedAt)
        assert.NotNil(t, user.UpdatedAt)
        assert.NotEqual(t, user.UpdatedAt, user.CreatedAt)
        assert.Nil(t, user.DeletedAt)
    })

    t.Run("対象ユーザー取得でエラーの場合にエラーを返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(nil, err)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        lastName := "佐藤"
        firstName := "二郎"
        email := "z.satou@example.com"
        user, err := userUsecase.Update(ctx, uid, lastName, firstName, email)

        // 検証
        assert.Error(t, err)
        assert.Nil(t, user)
    })

    t.Run("対象ユーザーが存在しない場合にエラーを返すこと", func(t *testing.T) {
        // モック化
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(nil, nil)
        mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Return()

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        lastName := "佐藤"
        firstName := "二郎"
        email := "z.satou@example.com"
        user, err := userUsecase.Update(ctx, uid, lastName, firstName, email)

        // 検証
        assert.Error(t, err)
        assert.Nil(t, user)
    })

    t.Run("プロフィール更新でエラーの場合にエラーを返すこと", func(t *testing.T) {
        // モック化
        findUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(findUser, nil)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        lastName := ""
        firstName := "二郎"
        email := "z.satou@example.com"
        user, err := userUsecase.Update(ctx, uid, lastName, firstName, email)

        // 検証
        assert.Error(t, err)
        assert.Nil(t, user)
    })
}

 

・「src/internal/application/usecase/user/user_delete.go」

package user

import (
    "context"
    "fmt"

    domain_user "go-gin-domain/internal/domain/user"
)

func (u *userUsecase) Delete(ctx context.Context, uid string) (*domain_user.User, error) {
    user, err := u.userRepo.FindByUID(ctx, uid)
    if err != nil {
        return nil, err
    }

    // 対象ユーザーが存在しない場合はエラー
    if user == nil {
        msg := fmt.Sprintf("対象ユーザーが存在しません。: UID=%s", uid)
        u.logger.Error(ctx, msg)
        return nil, fmt.Errorf("%s", msg)
    }

    // 論理削除設定
    user.SetDelete()

    return u.userRepo.Save(ctx, user)
}

 

・「src/internal/application/usecase/user/user_delete_test.go」

package user

import (
    "context"
    "fmt"
    "testing"
    "time"

    mockLogger "go-gin-domain/internal/application/usecase/logger/mock_logger"
    domain_user "go-gin-domain/internal/domain/user"
    mockUser "go-gin-domain/internal/domain/user/mock_user_repository"

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

// 初期処理
func init() {
    // テスト用の環境変数ファイル「.env.testing」を読み込んで使用する。
    if err := godotenv.Load("../../../../.env.testing"); err != nil {
        fmt.Println(".env.testingの読み込みに失敗しました。")
    }
}

func TestUserUsecase_Delete(t *testing.T) {
    // リポジトリのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockRepo := mockUser.NewMockUserRepository(ctrl)

    // ロガーのモック
    mockLogger := mockLogger.NewMockLogger(ctrl)

    t.Run("正常終了すること", func(t *testing.T) {
        // モック化
        findUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "佐藤",
            FirstName: "二郎",
            Email: "z.satou@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(findUser, nil)

        date := time.Now()
        dateString := date.Format("2006-01-02 15:04:05")
        updateEmail := "z.satou@example.com" + dateString
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "佐藤",
            FirstName: "二郎",
            Email: updateEmail,
            CreatedAt: time.Time{},
            UpdatedAt: date,
            DeletedAt: &date,
        }
        mockRepo.EXPECT().Save(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        user, err := userUsecase.Delete(ctx, uid)

        // 検証
        assert.NoError(t, err)
        assert.NotNil(t, user)

        assert.Equal(t, expectedUser.ID, user.ID)
        assert.Equal(t, expectedUser.UID, user.UID)
        assert.Equal(t, expectedUser.LastName, user.LastName)
        assert.Equal(t, expectedUser.FirstName, user.FirstName)
        assert.Equal(t, expectedUser.Email, user.Email)
        assert.NotNil(t, user.CreatedAt)
        assert.NotNil(t, user.UpdatedAt)
        assert.NotEqual(t, user.UpdatedAt, user.CreatedAt)
        assert.NotNil(t, user.DeletedAt)
        assert.Equal(t, *user.DeletedAt, user.UpdatedAt)
    })

    t.Run("対象ユーザー取得でエラーの場合にエラーを返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(nil, err)

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        user, err := userUsecase.Delete(ctx, uid)

        // 検証
        assert.Error(t, err)
        assert.Nil(t, user)
    })

    t.Run("対象ユーザーが存在しない場合にエラーを返すこと", func(t *testing.T) {
        // モック化
        mockRepo.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(nil, nil)
        mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Return()

        // ユースケースのインスタンス化
        userUsecase := NewUserUsecase(mockRepo, mockLogger)

        // テストの実行
        ctx := context.Background()
        uid := "xxxx-xxxx-xxxx-0001"
        user, err := userUsecase.Delete(ctx, uid)

        // 検証
        assert.Error(t, err)
        assert.Nil(t, user)
    })
}

 

次に以下のコマンドを実行し、フォーマット修正および静的コード解析を行い、警告が出ないことを確認します。

$ docker compose run --rm api go mod tidy
$ docker compose run --rm api go fmt ./...
$ docker compose run --rm api staticcheck ./...

 

次に以下のコマンドでテストコードを実行します。

$ docker compose run --rm api go test -v $(docker compose run --rm api go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)

 

テスト実行後、下図のように全てのテストがPASSすればOKです。

 

次に以下のコマンドを実行し、後述のテストコード用のモックファイルを作成します。

$ docker compose run --rm api mockgen -source=./internal/application/usecase/user/user.go -destination=./internal/application/usecase/user/mock_user/mock_user.go

 

ハンドラーの定義

次にハンドラーを定義するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/internal/presentation/handler/user
$ touch src/internal/presentation/handler/user/user_handler.go src/internal/presentation/handler/user/user_handler_test.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/presentation/handler/user/user_handler.go」

package user

import (
    "fmt"
    "net/http"
    "strings"

    usecase "go-gin-domain/internal/application/usecase/user"

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

type UserHandler interface {
    Create(c *gin.Context)
    FindAll(c *gin.Context)
    FindByUID(c *gin.Context)
    Update(c *gin.Context)
    Delete(c *gin.Context)
}

type userHandler struct {
    userUsecase usecase.UserUsecase
}

func NewUserHandler(
    userUsecase usecase.UserUsecase,
) UserHandler {
    return &userHandler{
        userUsecase: userUsecase,
    }
}

type CreateUserRequestBody struct {
    LastName string `json:"last_name" binding:"required"`
    FirstName string `json:"first_name" binding:"required"`
    Email string `json:"email" binding:"required,email"`
}

type UpdateUserRequestBody struct {
    LastName string `json:"last_name" binding:"required"`
    FirstName string `json:"first_name" binding:"required"`
    Email string `json:"email" binding:"required,email"`
}

func (h *userHandler) Create(c *gin.Context) {
    // 共通コンテキスト
    ctx := c.Request.Context()

    // バリデーションチェック
    var reqBody CreateUserRequestBody
    if err := c.ShouldBindJSON(&reqBody); err != nil {
        msg := fmt.Sprintf("バリデーションエラー: %s", err.Error())
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "message": msg,
        })
        return
    }

    user, err := h.userUsecase.Create(ctx, reqBody.LastName, reqBody.FirstName, reqBody.Email)
    if err != nil {
        msg := fmt.Sprintf("Internal Server Error: %s", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{
            "message": msg,
        })
        return
    }

    c.JSON(http.StatusCreated, user)
}

func (h *userHandler) FindAll(c *gin.Context) {
    // 共通コンテキスト
    ctx := c.Request.Context()

    users, err := h.userUsecase.FindAll(ctx)
    if err != nil {
        msg := fmt.Sprintf("Internal Server Error: %s", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{
            "message": msg,
        })
        return
    }

    c.JSON(http.StatusOK, users)
}

func (h *userHandler) FindByUID(c *gin.Context) {
    // 共通コンテキスト
    ctx := c.Request.Context()

    // バリデーションチェック
    uid := c.Param("uid")
    if strings.TrimSpace(uid) == "" {
        msg := fmt.Sprintf("バリデーションエラー: %s", "uid is required")
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "message": msg,
        })
        return
    }

    user, err := h.userUsecase.FindByUID(ctx, uid)
    if err != nil {
        msg := fmt.Sprintf("Internal Server Error: %s", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{
            "message": msg,
        })
        return
    }

    // userがnilの場合に空のオブジェクトを返す
    if user == nil {
        c.JSON(http.StatusOK, map[string]interface{}{})
        return
    }

    c.JSON(http.StatusOK, user)
}

func (h *userHandler) Update(c *gin.Context) {
    // 共通コンテキスト
    ctx := c.Request.Context()

    // バリデーションチェック
    uid := c.Param("uid")
    if strings.TrimSpace(uid) == "" {
        msg := fmt.Sprintf("バリデーションエラー: %s", "uid is required")
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "message": msg,
        })
        return
    }

    var reqBody UpdateUserRequestBody
    if err := c.ShouldBindJSON(&reqBody); err != nil {
        msg := fmt.Sprintf("バリデーションエラー: %s", err.Error())
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "message": msg,
        })
        return
    }

    user, err := h.userUsecase.Update(ctx, uid, reqBody.LastName, reqBody.FirstName, reqBody.Email)
    if err != nil {
        msg := fmt.Sprintf("Internal Server Error: %s", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{
            "message": msg,
        })
        return
    }

    c.JSON(http.StatusOK, user)
}

func (h *userHandler) Delete(c *gin.Context) {
    // 共通コンテキスト
    ctx := c.Request.Context()

    // バリデーションチェック
    uid := c.Param("uid")
    if strings.TrimSpace(uid) == "" {
        msg := fmt.Sprintf("バリデーションエラー: %s", "uid is required")
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "message": msg,
        })
        return
    }

    user, err := h.userUsecase.Delete(ctx, uid)
    if err != nil {
        msg := fmt.Sprintf("Internal Server Error: %s", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{
            "message": msg,
        })
        return
    }

    c.JSON(http.StatusOK, user)
}

 

・「src/internal/presentation/handler/user/user_handler_test.go」

package user

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    mockUser "go-gin-domain/internal/application/usecase/user/mock_user"
    domain_user "go-gin-domain/internal/domain/user"
    "go-gin-domain/internal/presentation/middleware"

    "github.com/gin-gonic/gin"
    "github.com/joho/godotenv"
    "github.com/stretchr/testify/assert"
    "go.uber.org/mock/gomock"
)

// 初期処理
func init() {
    // テスト用の環境変数ファイル「.env.testing」を読み込んで使用する。
    if err := godotenv.Load("../../../../.env.testing"); err != nil {
        fmt.Println(".env.testingの読み込みに失敗しました。")
    }
}

// テスト用Ginの初期化処理
func initTestGin() (*gin.Engine, *gin.RouterGroup) {
    r := gin.New()

    // ミドルウェアの設定
    m := middleware.NewMiddleware()
    r.Use(m.Request())
    r.Use(m.CustomLogger())
    r.Use(gin.Recovery())

    apiV1 := r.Group("/api/v1")

    return r, apiV1
}

func TestUserHandler_Create(t *testing.T) {
    // Ginのテストモードに設定
    gin.SetMode(gin.TestMode)

    // ユースケースのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockUserUsecase := mockUser.NewMockUserUsecase(ctrl)

    t.Run("ステータス201で正常終了すること", func(t *testing.T) {
        // モック化
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockUserUsecase.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.POST("/user", h.Create)

        // リクエスト設定
        path := "/api/v1/user"
        reqBody := CreateUserRequestBody{
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPost, path, bytes.NewBuffer(jsonReqBody))

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusCreated, w.Code)

        var data map[string]interface{}
        err = json.Unmarshal(w.Body.Bytes(), &data)
        assert.NoError(t, err)

        assert.NotContains(t, data, "id")
        assert.NotNil(t, data["uid"])
        assert.Equal(t, expectedUser.LastName, data["last_name"])
        assert.Equal(t, expectedUser.FirstName, data["first_name"])
        assert.Equal(t, expectedUser.Email, data["email"])
        assert.NotNil(t, data["created_at"])
        assert.NotNil(t, data["updated_at"])
        assert.Nil(t, data["deleted_at"])
    })

    t.Run("ユースケースでエラーが発生した場合にステータス500を返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockUserUsecase.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, err)

        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.POST("/user", h.Create)

        // リクエスト設定
        path := "/api/v1/user"
        reqBody := CreateUserRequestBody{
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPost, path, bytes.NewBuffer(jsonReqBody))

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusInternalServerError, w.Code)
        assert.Contains(t, w.Body.String(), "Internal Server Error")
    })

    t.Run("バリデーションチェックでエラーの場合にステータス422を返すこと", func(t *testing.T) {
        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.POST("/user", h.Create)

        // リクエスト設定
        path := "/api/v1/user"
        reqBody := CreateUserRequestBody{
            LastName: "",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPost, path, bytes.NewBuffer(jsonReqBody))

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
        assert.Contains(t, w.Body.String(), "バリデーションエラー")
    })
}

func TestUserHandler_FindAll(t *testing.T) {
    // Ginのテストモードに設定
    gin.SetMode(gin.TestMode)

    // ユースケースのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockUserUsecase := mockUser.NewMockUserUsecase(ctrl)

    t.Run("ステータス200で正常終了すること", func(t *testing.T) {
        // モック化
        expectedUsers := []*domain_user.User{
            {
                ID: 1,
                UID: "xxxx-xxxx-xxxx-0001",
                LastName: "田中",
                FirstName: "太郎",
                Email: "t.tanaka@example.com",
                CreatedAt: time.Time{},
                UpdatedAt: time.Time{},
                DeletedAt: nil,
            },
            {
                ID: 2,
                UID: "xxxx-xxxx-xxxx-0002",
                LastName: "佐藤",
                FirstName: "一郎",
                Email: "i.satou@example.com",
                CreatedAt: time.Time{},
                UpdatedAt: time.Time{},
                DeletedAt: nil,
            },
        }
        mockUserUsecase.EXPECT().FindAll(gomock.Any()).Return(expectedUsers, nil)

        // ルーター設定
        r, apiV1 := initTestGin()
        m := middleware.NewMiddleware()
        h := NewUserHandler(mockUserUsecase)
        apiV1.GET("/users", m.Auth(), h.FindAll)

        // リクエスト設定
        path := "/api/v1/users"
        req := httptest.NewRequest(http.MethodGet, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusOK, w.Code)

        var list []map[string]interface{}
        err := json.Unmarshal(w.Body.Bytes(), &list)
        assert.NoError(t, err)

        assert.NotContains(t, list[0], "id")
        assert.Equal(t, expectedUsers[0].UID, list[0]["uid"])
        assert.Equal(t, expectedUsers[0].LastName, list[0]["last_name"])
        assert.Equal(t, expectedUsers[0].FirstName, list[0]["first_name"])
        assert.Equal(t, expectedUsers[0].Email, list[0]["email"])
        assert.NotNil(t, list[0]["created_at"])
        assert.NotNil(t, list[0]["updated_at"])
        assert.Nil(t, list[0]["deleted_at"])

        assert.NotContains(t, list[1], "id")
        assert.Equal(t, expectedUsers[1].UID, list[1]["uid"])
        assert.Equal(t, expectedUsers[1].LastName, list[1]["last_name"])
        assert.Equal(t, expectedUsers[1].FirstName, list[1]["first_name"])
        assert.Equal(t, expectedUsers[1].Email, list[1]["email"])
        assert.NotNil(t, list[1]["created_at"])
        assert.NotNil(t, list[1]["updated_at"])
        assert.Nil(t, list[1]["deleted_at"])
    })

    t.Run("ユースケースでエラーが発生した場合にステータス500を返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockUserUsecase.EXPECT().FindAll(gomock.Any()).Return(nil, err)

        // ルーター設定
        r, apiV1 := initTestGin()
        m := middleware.NewMiddleware()
        h := NewUserHandler(mockUserUsecase)
        apiV1.GET("/users", m.Auth(), h.FindAll)

        // リクエスト設定
        path := "/api/v1/users"
        req := httptest.NewRequest(http.MethodGet, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusInternalServerError, w.Code)
        assert.Contains(t, w.Body.String(), "Internal Server Error")
    })
}

func TestUserHandler_FindByUID(t *testing.T) {
    // Ginのテストモードに設定
    gin.SetMode(gin.TestMode)

    // ユースケースのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockUserUsecase := mockUser.NewMockUserUsecase(ctrl)

    t.Run("ステータス200で正常終了すること", func(t *testing.T) {
        // モック化
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
        mockUserUsecase.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ルーター設定
        r, apiV1 := initTestGin()
        m := middleware.NewMiddleware()
        h := NewUserHandler(mockUserUsecase)
        apiV1.GET("/user/:uid", m.Auth(), h.FindByUID)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0001"
        req := httptest.NewRequest(http.MethodGet, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusOK, w.Code)

        var data map[string]interface{}
        err := json.Unmarshal(w.Body.Bytes(), &data)
        assert.NoError(t, err)

        assert.NotContains(t, data, "id")
        assert.NotNil(t, data["uid"])
        assert.Equal(t, expectedUser.LastName, data["last_name"])
        assert.Equal(t, expectedUser.FirstName, data["first_name"])
        assert.Equal(t, expectedUser.Email, data["email"])
        assert.NotNil(t, data["created_at"])
        assert.NotNil(t, data["updated_at"])
        assert.Nil(t, data["deleted_at"])
    })

    t.Run("対象ユーザーが存在しない場合にステータス200で空のオブジェクトを返すこと", func(t *testing.T) {
        // モック化
        mockUserUsecase.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(nil, nil)

        // ルーター設定
        r, apiV1 := initTestGin()
        m := middleware.NewMiddleware()
        h := NewUserHandler(mockUserUsecase)
        apiV1.GET("/user/:uid", m.Auth(), h.FindByUID)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0002"
        req := httptest.NewRequest(http.MethodGet, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusOK, w.Code)

        var data map[string]interface{}
        err := json.Unmarshal(w.Body.Bytes(), &data)
        assert.NoError(t, err)
        assert.Empty(t, data)
    })

    t.Run("ユースケースでエラーが発生した場合にステータス500を返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockUserUsecase.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(nil, err)

        // ルーター設定
        r, apiV1 := initTestGin()
        m := middleware.NewMiddleware()
        h := NewUserHandler(mockUserUsecase)
        apiV1.GET("/user/:uid", m.Auth(), h.FindByUID)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0002"
        req := httptest.NewRequest(http.MethodGet, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusInternalServerError, w.Code)
        assert.Contains(t, w.Body.String(), "Internal Server Error")
    })

    t.Run("バリデーションチェックでエラーの場合にステータス422を返すこと", func(t *testing.T) {
        // ルーター設定
        r, apiV1 := initTestGin()
        m := middleware.NewMiddleware()
        h := NewUserHandler(mockUserUsecase)
        apiV1.GET("/user/:uid", m.Auth(), h.FindByUID)

        // リクエスト設定
        path := "/api/v1/user/ "
        req := httptest.NewRequest(http.MethodGet, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
        assert.Contains(t, w.Body.String(), "バリデーションエラー")
    })
}

func TestUserHandler_Update(t *testing.T) {
    // Ginのテストモードに設定
    gin.SetMode(gin.TestMode)

    // ユースケースのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockUserUsecase := mockUser.NewMockUserUsecase(ctrl)

    t.Run("ステータス200で正常終了すること", func(t *testing.T) {
        // モック化
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "佐藤",
            FirstName: "二郎",
            Email: "z.satou@example.com",
            CreatedAt: time.Time{},
            UpdatedAt: time.Now(),
            DeletedAt: nil,
        }
        mockUserUsecase.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.PUT("/user/:uid", h.Update)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0001"
        reqBody := UpdateUserRequestBody{
            LastName: "佐藤",
            FirstName: "二郎",
            Email: "z.satou@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPut, path, bytes.NewBuffer(jsonReqBody))
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusOK, w.Code)

        var data map[string]interface{}
        err = json.Unmarshal(w.Body.Bytes(), &data)
        assert.NoError(t, err)

        assert.NotContains(t, data, "id")
        assert.NotNil(t, data["uid"])
        assert.Equal(t, expectedUser.LastName, data["last_name"])
        assert.Equal(t, expectedUser.FirstName, data["first_name"])
        assert.Equal(t, expectedUser.Email, data["email"])
        assert.NotNil(t, data["created_at"])
        assert.NotNil(t, data["updated_at"])
        assert.NotEqual(t, data["updated_at"], data["created_at"])
        assert.Nil(t, data["deleted_at"])
    })

    t.Run("ユースケースでエラーが発生した場合にステータス500を返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockUserUsecase.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, err)

        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.PUT("/user/:uid", h.Update)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0001"
        reqBody := UpdateUserRequestBody{
            LastName: "佐藤",
            FirstName: "二郎",
            Email: "z.satou@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPut, path, bytes.NewBuffer(jsonReqBody))
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusInternalServerError, w.Code)
        assert.Contains(t, w.Body.String(), "Internal Server Error")
    })

    t.Run("UIDのバリデーションチェックでエラーの場合にステータス422を返すこと", func(t *testing.T) {
        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.PUT("/user/:uid", h.Update)

        // リクエスト設定
        path := "/api/v1/user/ "
        reqBody := UpdateUserRequestBody{
            LastName: "佐藤",
            FirstName: "二郎",
            Email: "z.satou@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPut, path, bytes.NewBuffer(jsonReqBody))
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
        assert.Contains(t, w.Body.String(), "バリデーションエラー")
    })

    t.Run("リクエストボディのバリデーションチェックでエラーの場合にステータス422を返すこと", func(t *testing.T) {
        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.PUT("/user/:uid", h.Update)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0001"
        reqBody := UpdateUserRequestBody{
            LastName: "佐藤",
            FirstName: "",
            Email: "z.satou@example.com",
        }
        jsonReqBody, err := json.Marshal(reqBody)
        if err != nil {
            t.Fatal(err)
        }
        req := httptest.NewRequest(http.MethodPut, path, bytes.NewBuffer(jsonReqBody))
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
        assert.Contains(t, w.Body.String(), "バリデーションエラー")
    })
}

func TestUserHandler_Delete(t *testing.T) {
    // Ginのテストモードに設定
    gin.SetMode(gin.TestMode)

    // ユースケースのモック
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockUserUsecase := mockUser.NewMockUserUsecase(ctrl)

    t.Run("ステータス200で正常終了すること", func(t *testing.T) {
        // モック化
        date := time.Now()
        dateString := date.Format("2006-01-02 15:04:05")
        updateEmail := "z.satou@example.com" + dateString
        expectedUser := &domain_user.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "佐藤",
            FirstName: "二郎",
            Email: updateEmail,
            CreatedAt: time.Time{},
            UpdatedAt: date,
            DeletedAt: &date,
        }
        mockUserUsecase.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.DELETE("/user/:uid", h.Delete)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0001"
        req := httptest.NewRequest(http.MethodDelete, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusOK, w.Code)

        var data map[string]interface{}
        err := json.Unmarshal(w.Body.Bytes(), &data)
        assert.NoError(t, err)

        assert.NotContains(t, data, "id")
        assert.NotNil(t, data["uid"])
        assert.Equal(t, expectedUser.LastName, data["last_name"])
        assert.Equal(t, expectedUser.FirstName, data["first_name"])
        assert.Equal(t, expectedUser.Email, data["email"])
        assert.NotNil(t, data["created_at"])
        assert.NotNil(t, data["updated_at"])
        assert.NotEqual(t, data["updated_at"], data["created_at"])
        assert.NotNil(t, data["deleted_at"])
        assert.Equal(t, data["deleted_at"], data["updated_at"])
    })

    t.Run("ユースケースでエラーが発生した場合にステータス500を返すこと", func(t *testing.T) {
        // モック化
        err := fmt.Errorf("Internal Server Error")
        mockUserUsecase.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil, err)

        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.DELETE("/user/:uid", h.Delete)

        // リクエスト設定
        path := "/api/v1/user/xxxx-xxxx-xxxx-0001"
        req := httptest.NewRequest(http.MethodDelete, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusInternalServerError, w.Code)
        assert.Contains(t, w.Body.String(), "Internal Server Error")
    })

    t.Run("バリデーションチェックでエラーの場合にステータス422を返すこと", func(t *testing.T) {
        // ルーター設定
        r, apiV1 := initTestGin()
        h := NewUserHandler(mockUserUsecase)
        apiV1.DELETE("/user/:uid", h.Delete)

        // リクエスト設定
        path := "/api/v1/user/ "
        req := httptest.NewRequest(http.MethodDelete, path, nil)
        req.Header.Set("Authorization", "Bearer xxxxxx")

        // テストの実行
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)

        // 検証
        assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
        assert.Contains(t, w.Body.String(), "バリデーションエラー")
    })
}

 

レジストリ登録

次にルーター設定で使うためのレジストリ登録をするため、以下のコマンドを実行してファイルを作成します。

$ mkdir -p src/internal/registry && touch src/internal/registry/registry.go

 

次に作成したファイルを以下のように記述します。

・「src/internal/registry/registry.go」

package registry

import (
    "context"
    "fmt"

    usecase_user "go-gin-domain/internal/application/usecase/user"
    "go-gin-domain/internal/infrastructure/database"
    "go-gin-domain/internal/infrastructure/logger"
    persistence_user "go-gin-domain/internal/infrastructure/persistence/user"
    handler_user "go-gin-domain/internal/presentation/handler/user"
)

// ハンドラーをまとめるコントローラー構造体
type Controller struct {
    User handler_user.UserHandler
}

func NewController() *Controller {
    // コンテキスト
    ctx := context.Background()

    // ロガー設定
    logger := logger.NewSlogLogger()

    // DB設定(今回はダミー設定とする)
    cfg := database.DummyConfig{
        Dummy: "dummy",
    }
    db_dummy, err := database.NewDummyConnection(cfg, logger)
    if err != nil {
        msg := fmt.Sprintf("エラー: %s", err.Error())
        logger.Error(ctx, msg)
    }

    // userドメインのハンドラー設定
    userRepo := persistence_user.NewUserRepository(db_dummy, logger)
    userUsecase := usecase_user.NewUserUsecase(userRepo, logger)
    userHandler := handler_user.NewUserHandler(userUsecase)

    return &Controller{
        User: userHandler,
    }
}

※ルーター設定時にインスタンス化したハンドラーが必要になるため、このファイルでまとめるようにしています。

 

ルーター設定

次にルーター設定をするため、以下のコマンドを実行してファイルを作成します。

$ mkdir -p src/internal/presentation/router && touch src/internal/presentation/router/router.go

 

・「src/internal/presentation/router/router.go」

package router

import (
    "go-gin-domain/internal/presentation/middleware"
    "go-gin-domain/internal/registry"

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

func SetupRouter(c *registry.Controller, m *middleware.Middleware) *gin.Engine {
    r := gin.New()

    // 共通ミドルウェアの適用
    r.Use(m.Request())
    r.Use(m.CustomLogger())
    r.Use(gin.Recovery())

    // ルーティングの設定
    apiV1 := r.Group("/api/v1")
    apiV1.POST("/user", c.User.Create)
    apiV1.GET("/users", m.Auth(), c.User.FindAll)
    apiV1.GET("/user/:uid", m.Auth(), c.User.FindByUID)
    apiV1.PUT("/user/:uid", m.Auth(), c.User.Update)
    apiV1.DELETE("/user/:uid", m.Auth(), c.User.Delete)

    return r
}

 

main.goの修正

次にファイル「src/main.go」を以下のように修正します。

・「src/main.go」

package main

import (
    "fmt"
    "log/slog"
    "os"

    "go-gin-domain/internal/presentation/middleware"
    "go-gin-domain/internal/presentation/router"
    "go-gin-domain/internal/registry"

    "github.com/joho/godotenv"
)

func main() {
    // .env ファイルの読み込み
    err := godotenv.Load()
    if err != nil {
        slog.Error(".envファイルの読み込みに失敗しました。")
    }

    // ポート番号の設定
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    startPort := fmt.Sprintf(":%s", port)

    // サーバー起動ログ出力
    env := os.Getenv("ENV")
    slog.Info(fmt.Sprintf("[ENV=%s] Start Gin Server Port: %s", env, port))

    // サーバー起動
    c := registry.NewController()
    m := middleware.NewMiddleware()
    r := router.SetupRouter(c, m)
    r.Run(startPort)
} 

 

次に以下のコマンドを実行し、フォーマット修正および静的コード解析を行い、警告が出ないことを確認します。

$ docker compose run --rm api go mod tidy
$ docker compose run --rm api go fmt ./...
$ docker compose run --rm api staticcheck ./...

 

コンテナの再ビルドと起動

次に以下のコマンドを実行し、コンテナを再びルドします。

$ docker compose down
$ docker compose build --no-cache

 

次に以下のコマンドを実行し、コンテナを起動します。

$ docker compose up -d

 

次に以下のコマンドを実行し、ログ出力を確認します。

$ docker compose logs

 

ログ出力を確認し、エラーがなければOKです。

 

スポンサーリンク

ユーザードメインのAPIを試す

次に上記で作成したユーザードメインのAPIをPostmanを使って試します。

まずはPOSTメソッドで「http://localhost:8080/api/v1/user」を実行し、下図のようにステータスコード201で想定通りの結果になればOKです。

 

次にBearertトークン未設定でGETメソッドの「http://localhost:8080/api/v1/users」を実行し、下図のようにステータスコード401エラーになればOKです。

 

次にBearertトークンを設定してGETメソッドの「http://localhost:8080/api/v1/users」を実行し、下図のようにステータスコード200で想定通りの結果になればOKです。

 

次にBearertトークンを設定してGETメソッドの「http://localhost:8080/api/v1/user/{対象のuid}」を実行し、下図のようにステータスコード200で想定通りの結果になればOKです。

 

次にBearertトークンを設定してPUTメソッドの「http://localhost:8080/api/v1/user/{対象のuid}」を実行し、下図のようにステータスコード200で想定通りの結果になればOKです。

 

次にBearertトークンを設定してDELETEメソッドの「http://localhost:8080/api/v1/user/{対象のuid}」を実行し、下図のようにステータスコード200で想定通りの結果になればOKです。

 

テストコードのカバレッジを確認

次に上記で追加したハンドラーのテストも試してみますが、Goならオプション「-cover」を使ってカバレッジ率(テストがどれくらい網羅されているか)も確認することができます。

$ docker compose exec api go test -v $(docker compose exec api go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)

 

また、以下のコマンドを実行し、カバレッジの対象箇所をファイル出力し、それをHTMLファイルに変換してブラウザで確認することも可能です。

$ docker compose exec api go test -v -coverprofile=internal/coverage.out $(docker compose exec api go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)
$ docker compose exec api go tool cover -html=internal/coverage.out -o=internal/coverage.html

 

コマンド実行後、以下のようにファイルが作成されます。

 

ファイル「src/internal/coverage.html」をブラウザで確認すると下図のように表示されます。

左上のリストから対象のコードを選択可能で、カバー済みの箇所は緑色の文字で表示され、もし未対応の箇所があったら赤色の文字で表示されます。

※上図はVSCodeの拡張機能「HTML Preview」を使ってプレビューしています。

 

データベースやOpenAPIについて

今回はデータベースやOpenAPIに関する部分は省略しています。必要な場合は以下の記事を参考にしてみて下さい。

Go言語(Golang)のEchoでシンプルかつ実務的なバックエンドAPI開発方法まとめ
こんにちは。Tomoyuki(@tomoyuki65)です。以前にGo言語のEchoでバックエンドAPIを開発する方法についての記事を書きましたが、あれから私自身もさらに成長し、もっとシンプルかつ実務的にAPIを開発する方法をまとめたいと思...

 

スポンサーリンク

最後に

今回はGo言語のGinでDDD構成のバックエンドAPIを開発する方法について解説しました。

実際にGo言語の仕事を探すとDDD構成を採用しているケースが多く、実務においてはドメイン駆動設計での開発経験が求められることが多いと思います。

特にドメイン部分は専門性が問われるため、実際にはもっと複雑になると思いますが、基本的な開発方法についてはまとめられたと思うので、Go言語によるDDD(ドメイン駆動設計)について学びたい方はぜひ参考にしてみて下さい!

 

この記事を書いた人
Tomoyuki

SE→ブロガーを経て、現在はWeb系エンジニアをしています!

Tomoyukiをフォローする
応用
スポンサーリンク
Tomoyukiをフォローする

コメント

タイトルとURLをコピーしました