🐟

クリーンアーキテクチャ初学者がGo / Gin / gormでログイン機能開発

に公開

はじめに

Go言語の経験をしていく中で、コードの理解は進んできましたが、LaravelやDjangoなどのWebフレームワークのように、明確なお決まりのディレクトリ構成が存在しないため、設計に迷うことが増えてきました。

そこで色々と調べてみたところ、Go言語では「クリーンアーキテクチャ」が広く採用されていることを知りました。クリーンアーキテクチャを取り入れることで、汎用性が高く、長期的な開発にも耐えられるスタイルが身につきそうだと感じ、チーム開発にも柔軟に対応できる感じました。

そこで今回は、実際にログイン機能をクリーンアーキテクチャに見習って実装してみたので、今後振り返りができるように、まとめておくことにしました。

開発環境

Docker環境で以下の構成を準備しました。

  • Go 1.24.1
  • PostgreSQL 15
  • Redis 7.4.2
  • pgAdmin4
  • Macbook air

Docker環境については、以前に記事を作成していますのでこちらもご覧いただけると嬉しいです。
https://zenn.dev/tugu__web/articles/c6e5e711d13d49

また、Goのライブラリは、
サーバー部分の実装にGin、ORMとしてgormを使用しています。
理由としては、メジャーなものを使用したかったからです。

クリーンアーキテクチャについて

クリーンアーキテクチャは、システムを階層的に分離し、変更に強い構造を作る設計思想です。
具体的には、ビジネスロジック(ユースケース)を中心に据え、外部(データベースやWebフレームワークなど)への依存を極力小さく保つことを目的としています。

よく見るのが以下の図です。

正直まだ、役割によってディレクトリ階層が分かれているという理解くらいで、勉強不足なのでさらに理解度は上げていきたいと思います。
教材としては以下の書籍が有名なので、購入したいと思います。
https://www.amazon.co.jp/Clean-Architecture-達人に学ぶソフトウェアの構造と設計-Robert-C-Martin/dp/4048930656

この記事では実装メインで、実際にログイン機能をクリーンアーキテクチャに沿って実装した内容を紹介していきます。

機能一覧

  • 新規ユーザー登録
  • ログイン
  • ログアウト
  • 認証確認ミドルウェア

ログインの流れ

JWT認証セッション管理組み合わせた認証管理の設計にしました。
理由としては、まずJWTを使った認証の実装を一度経験してみたかったというのがあります。

ただ、JWTだけだと一度発行したトークンを無効にするのが難しく、ログアウトの管理がしにくい欠点があります。
そこで、セッション管理も取り入れて、Redisなどにユーザーの状態を保存することで、サーバー側で簡単にログアウト処理やアクセス制御ができるようにしました。

JWTトークンのペイロード部分は以下のような構造にしました。

JWTトークンのペイロード構造
{
    exp:1746146168
    jti:e4af9370-d14d-40c5-9cff-5630f428ab6f
    user_id:1
}

jti にはUUIDで生成したランダムトークンを入れています。
user_idにはデータベースに保存しているユーザーIDを入れています。
jtiuser_idを後述の、Redisの値としてユーザーIDと組み合わせて保存します。

Redisへの保存データは以下のような構造です。

ユーザーID) "session:jti:トークン"

実際の保存データはこのようになる。
1) "session:jti:ff902b38-968b-4869-80e0-71fd0f719a92"

これであれば一度発行されたJWTトークンの使い回しを防止し、ログアウト後のセキュリティ面もカバーできると思ったからです。

ログイン時のシーケンスは以下のようなイメージです。

ログアウト処理は、セッション情報を削除したのち、フロントエンド側でもCookieに保存されたJWTトークンを削除するような処理にしています。

JWTトークンについては調べるととても分かり易い記事があります。

https://zenn.dev/collabostyle/articles/b08c7f29a2e94c
https://zenn.dev/mikakane/articles/tutorial_for_jwt

各階層の実装内容

クリーンアーキテクチャ未経験だったので色々なサイトを参考にし、自分が使いやすいそうなディレクトリ構造にアレンジしています。
正確なアーキテクチャではないかもしれませんが、今後も勉強して改善点があれば修正していきたいと思います。
もしご指摘あればコメントください。

ディレクトリ構成

GitHubリポジトリはこちら

https://github.com/tugu-develop/login_project

├── route
│   └── router.go            # ルーティング設定(リクエストを対応するハンドラーに振り分け)
├── controller
│   └── user_controller.go   # ユーザー関連のHTTPリクエストを処理するコントローラー
├── usecase
│   └── user_usecase.go      # ユーザーに関連するビジネスロジック(ユースケース)の実装
├── middleware
│   └── auth_middleware.go   # 認証確認を担当するミドルウェア
├── repository
│   ├── user_repository.go   # ユーザー情報のデータベース操作(CRUD)
│   └── session_repository.go# セッション管理(Redis)の操作
├── infrastructure
│   ├── db.go               # データベース接続設定(PostgreSQL用)
│   └── redis.go            # Redis接続設定
├── model
│   └── user.go             # ユーザーモデル(ID、メールアドレス、パスワードなど)
└── main.go                 # アプリケーションのエントリポイント

以下各ディレクトリとファイルの説明、順不同です。

main.go

main.goの流れは以下のように記述。

package main

import (
	"login_project/controller"
	"login_project/infrastructure"
	"login_project/repository"
	"login_project/router"
	"login_project/usecase"
	"log"
	"context"
	"fmt"

	"github.com/joho/godotenv"
)

func main() {
	// 環境変数読込
	if err := godotenv.Load(); err != nil {
		log.Fatal("Error loading .env file")
	}

	// PostgreSQL接続
	db := infrastructure.NewDB() 

	// Redis接続
	redisClient := infrastructure.NewRedisClient()
	ctx := context.Background()
	if err := redisClient.Ping(ctx).Err(); err != nil {
		log.Fatalf("Failed to connect to Redis: %v", err)
	}

	// メイン処理
	userRepository := repository.NewUserRepository(db)
	sessionRepository := repository.NewSessionRepository(redisClient)
	userUsecase := usecase.NewUserUsecase(userRepository, sessionRepository)
	userController := controller.NewUserController(userUsecase)
	r := router.NewRouter(userController)
	r.Run()
}

infrastructure

各データベースへの接続や切断処理を記述しています。

db.go

  • PostgresSQLへの接続
  • データベースから切断
package infrastructure

import (
	"fmt"
	"log"
	"os"

	"github.com/joho/godotenv"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

func NewDB() *gorm.DB {
	// .env読込
	err := godotenv.Load()
	if err != nil {
		fmt.Println("Error loading .env file")
		log.Fatalln(err)
	}

	// DB接続
	url := fmt.Sprintf("postgres://%s:%s@%s:%s/%s", os.Getenv("POSTGRES_USER"),
		os.Getenv("POSTGRES_PW"), os.Getenv("POSTGRES_HOST"),
		os.Getenv("POSTGRES_PORT"), os.Getenv("POSTGRES_DB"))
	db, err := gorm.Open(postgres.Open(url), &gorm.Config{})
	if err != nil {
		log.Fatalln(err)
	}
	fmt.Println("Connceted")
	return db
}

func CloseDB(db *gorm.DB) {
	sqlDB, _ := db.DB()
	if err := sqlDB.Close(); err != nil {
		log.Fatalln(err)
	}
}

redis.go

  • Redisへの接続処理
package infrastructure

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/redis/go-redis/v9"
)

var ctx = context.Background()

func NewRedisClient() *redis.Client {
	client := redis.NewClient(&redis.Options{
		Addr: fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), os.Getenv("REDIS_PORT")),
		Password: "",                   
		DB:       0,     
	})

	// 接続確認
	_, err := client.Ping(ctx).Result()
	if err != nil {
		log.Fatalf("Failed to connect to Redis: %v", err)
	}
	return client
}

router

HTTPリクエストを受け取り、コントローラーへのルーティングを管理している部分です。

router.go

  • 各ハンドラへのルーティング
  • CORS設定
package router

import (
	"login_project/controller"
	"login_project/middleware"
	"net/http"
	"time"

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

func NewRouter(uc controller.UserController) *gin.Engine {
	r := gin.Default()

	r.Use(cors.New(cors.Config{
		AllowOrigins: []string{
			"http://localhost:3000",
			"http://127.0.0.1:3000",
		},
		AllowMethods: []string{
			"POST",
			"GET",
			"OPTIONS",
			"DELETE",
		},
		AllowHeaders: []string{
			"Access-Control-Allow-Credentials",
			"Access-Control-Allow-Headers",
			"Content-Type",
			"Content-Length",
			"Accept-Encoding",
			"Authorization",
		},
		AllowCredentials: true,
		MaxAge:           24 * time.Hour,
	}))

	// ヘルスチェック用
	r.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"status": "ok",
		})
	})

	r.POST("/signup", uc.Signup)
	r.POST("/login", uc.Login)
	r.POST("/logout", uc.Logout)

	// 認証が必要なエンドポイント(/auth)
	authGroup := r.Group("/auth")
	authGroup.Use(middleware.AuthMiddleware(uc))
	{
		authGroup.GET("", uc.AuthOk)  // ハンドラーを設定
	}

	return r
}

controller

routerからリクエスト内容を受け取って、ビジネスロジックを持ったユースケースへの受け渡しを行います。

user_controller.go

  • 新規ユーザー登録
  • ログイン処理
  • ログアウト処理
  • 認証処理
  • 認証OK確認
package controller

import (
	"fmt"
	"login_project/model"
	"login_project/usecase"
	"net/http"

	"time"

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

type UserController interface {
	Signup(c *gin.Context)
	Login(c *gin.Context)
	Logout(c *gin.Context)
	Authenticate(c *gin.Context) (string, error)
	AuthOk(c *gin.Context)
}

type userController struct {
	uu usecase.UserUsecase
}

func NewUserController(uu usecase.UserUsecase) UserController {
	return &userController{uu}
}

func (uc *userController) Signup(c *gin.Context) {
	user := model.User{}
	if err := c.BindJSON(&user); err != nil {
		c.JSON(http.StatusBadRequest, err)
	}

	err := uc.uu.Signup(user)
	if err != nil {
		c.JSON(http.StatusInternalServerError, err)
	}
	c.JSON(http.StatusOK, nil)
}

func (uc *userController) Login(c *gin.Context) {
	user := model.User{}

	if err := c.BindJSON(&user); err != nil {
		c.JSON(http.StatusBadRequest, err)
	}

	tokenString, err := uc.uu.Login(user)
	if err != nil {
		c.JSON(http.StatusInternalServerError, err)
	}

	cookie := &http.Cookie{
		Name:     "token",
		Value:    tokenString,
		Expires:  time.Now().Add(1 * time.Hour),
		Path:     "/",
		Domain:   "localhost",
		Secure:   true,
		HttpOnly: true,
		SameSite: http.SameSiteNoneMode,
	}
	http.SetCookie(c.Writer, cookie)

	c.JSON(http.StatusOK, tokenString)
}

func (uc *userController) Logout(c *gin.Context) {
	// リクエストのクッキーからトークンを取得
	token, err := c.Cookie("token")
	if err != nil {
		fmt.Println(err.Error())
		c.JSON(http.StatusUnauthorized, "Token not found")
		return
	}

	// ログアウト処理
	err = uc.uu.Logout(token)
	if err != nil {
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	cookie := &http.Cookie{
		Name:     "token",
		Value:    "",
		Expires:  time.Now(),
		Path:     "/",
		Domain:   "localhost",
		Secure:   true,
		HttpOnly: true,
		SameSite: http.SameSiteNoneMode,
	}

	http.SetCookie(c.Writer, cookie)
	c.JSON(http.StatusOK, nil)
}

// 認証確認ミドルウェア
func (uc *userController) Authenticate(c *gin.Context) (string, error) {
	token, err := c.Cookie("token")
	if err != nil || token == "" { // トークン取得失敗
		fmt.Println("error: No token")
		return "", err
	}

	userID, err := uc.uu.Authenticate(token)
	if err != nil || userID == "" { // 認証失敗
		fmt.Println("error: No Authenticate")
		return "", err
	}

	return userID, nil
}

// ログイン認証OK
func (uc *userController) AuthOk(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{"authenticated": true})
}

usecase

コントローラーから呼び出されて処理を行います。
データベースの操作が必要なときはリポジトリを呼び出します。
Redisへの操作の際にもセッション管理用のリポジトリを呼び出します。

user_usecase.go

  • 新規ユーザー登録処理
  • ログイン処理
  • ログアウト処理
  • 認証確認処理
package usecase

import (
	"context"
	"fmt"
	"login_project/model"
	"login_project/repository"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v4"
	"github.com/google/uuid"
	"golang.org/x/crypto/bcrypt"
)

type UserUsecase interface {
	Signup(user model.User) error
	Login(user model.User) (string, error)
	Logout(token string) error
	Authenticate(token string) (string, error)
}

type userUsecase struct {
	ur repository.UserRepository
	sr repository.SessionRepository
}

func NewUserUsecase(ur repository.UserRepository, sr repository.SessionRepository) UserUsecase {
	return &userUsecase{
		ur: ur,
		sr: sr,
	}
}

func (uu *userUsecase) Signup(user model.User) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)
	if err != nil {
		return err
	}
	newUser := model.User{Email: user.Email, Password: string(hash)}
	if err := uu.ur.CreateUser(&newUser); err != nil {
		return err
	}
	return nil
}

func (uu *userUsecase) Login(user model.User) (string, error) {
	storedUser := model.User{}
	if err := uu.ur.GetUserByEmail(&storedUser, user.Email); err != nil {
		return "", err
	}
	err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(user.Password))
	if err != nil {
		return "", err
	}
	// UUID を生成
	jti := uuid.New().String()
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id": storedUser.ID,
		"jti":     jti,
		"exp":     time.Now().Add(time.Hour * 12).Unix(),
	})
	tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))
	if err != nil {
		return "", err
	}

	// Redis にセッション保存(キー: トークン, 値: ユーザーID)
	ctx := context.Background()
	sessionKey := fmt.Sprintf("session:jti:%s", jti)  // Redisキーsession:jti:<jti>
	if err := uu.sr.SaveSession(ctx, sessionKey, fmt.Sprintf("%v", storedUser.ID)); err != nil {
		return "", err
	}

	return tokenString, nil
}

// ログアウト時に Redis から削除
func (uu *userUsecase) Logout(token string) error {
	ctx := context.Background()
	return uu.sr.DeleteSession(ctx, token)
}

// 認証チェック(Redis でセッション確認)
func (uu *userUsecase) Authenticate(token string) (string, error) {
	ctx := context.Background()
	userID, err := uu.sr.GetUserIDByToken(ctx, token)

	if err != nil {
		return "", err
	}
	if userID == "" {
		return "", nil // 未認証
	}
	return userID, nil
}

repository

データベース(PostgreSQL)やセッション管理(Redis)の操作を行います。

user_repository.go

  • 新規ユーザー登録
  • ユーザー情報取得
package repository

import (
	"login_project/model"

	"gorm.io/gorm"
)

type UserRepository interface {
	GetUserByEmail(user *model.User, email string) error
	CreateUser(user *model.User) error
}

type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) UserRepository {
	return &userRepository{db}
}

func (ur *userRepository) GetUserByEmail(user *model.User, email string) error {
	if err := ur.db.Where("email=?", email).First(user).Error; err != nil {
		return err
	}
	return nil
}

func (ur *userRepository) CreateUser(user *model.User) error {
	if err := ur.db.Create(user).Error; err != nil {
		return err
	}
	return nil
}

session_ripository

  • セッション情報保存
  • セッション情報削除
  • ユーザーID取得
package repository

import (
	"context"
	"fmt"
	"github.com/golang-jwt/jwt/v4"
	"github.com/redis/go-redis/v9"
	"os"
	"time"
)

type SessionRepository interface {
	SaveSession(ctx context.Context, jti string, userID string) error
	GetUserIDByToken(ctx context.Context, token string) (string, error)
	DeleteSession(ctx context.Context, token string) error
}

type redisSessionRepository struct {
	client *redis.Client
}

func NewSessionRepository(client *redis.Client) SessionRepository {
	return &redisSessionRepository{client: client}
}

// ログイン時にセッションを保存
func (r *redisSessionRepository) SaveSession(ctx context.Context, jti string, userID string) error {
	return r.client.Set(ctx, jti, userID, time.Hour*12).Err()
}

// API リクエスト時に Redis からユーザーIDを取得
func (r *redisSessionRepository) GetUserIDByToken(ctx context.Context, jwtToken string) (string, error) {
	token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) {
		// 署名の検証
		return []byte(os.Getenv("SECRET")), nil
	})
	if err != nil || !token.Valid {
		return "", fmt.Errorf("invalid token: %w", err)
	}

	// claimsを取得
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return "", fmt.Errorf("invalid claims")
	}

	// jtiを取り出す
	jti, ok := claims["jti"].(string)
	if !ok || jti == "" {
		return "", fmt.Errorf("jti not found in token")
	}

	// Redisから jti が一致するセッション情報を取得
	redisKey := "session:jti:" + jti
	userID, err := r.client.Get(ctx, redisKey).Result()
	if err == redis.Nil {
		return "", fmt.Errorf("session not found for jti")
	} else if err != nil {
		return "", err
	}
	return userID, nil
}

// ログアウト時にセッションを削除
func (r *redisSessionRepository) DeleteSession(ctx context.Context, jwtToken string) error {
	// JWTトークンの解析
	token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) {
		// 署名の検証
		return []byte(os.Getenv("SECRET")), nil
	})
	if err != nil || !token.Valid {
		return fmt.Errorf("invalid token: %w", err)
	}

	// claimsを取得
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return fmt.Errorf("invalid claims")
	}

	// jtiを取り出す
	jti, ok := claims["jti"].(string)
	if !ok || jti == "" {
		return fmt.Errorf("jti not found in token")
	}

	// Redisから jti に基づくセッション情報を削除
	redisKey := "session:jti:" + jti
	err = r.client.Del(ctx, redisKey).Err()
	if err != nil {
		return fmt.Errorf("failed to delete session: %w", err)
	}

	return nil
}

middleware

HTTPリクエストとレスポンスの間に処理を挟む役割を持ちます。
認証保護したいURLのアクセス時に認証確認をする処理を実装しました。
ミドルウェア内ではコントローラーを呼び出すように意識しました。

auth_middleware.go

package middleware

import (
	"fmt"
	"login_project/controller"
	"net/http"

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

func AuthMiddleware(uc controller.UserController) gin.HandlerFunc {
	return func(c *gin.Context) {
		// 認証処理
		userID, err := uc.Authenticate(c)
		if err != nil || userID == "" { // 認証NG
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"authenticated": false})
			return
		}
		// ユーザーIDをコンテキストに保存(後続のハンドラで使えるかも)
		c.Set("user_id", userID)
		c.Next()
	}
}

model

ドメインとなるデータ構造を定義します。

user_model.go

ユーザー情報(ID、メールアドレス、パスワード)を定義しています。

package model

type User struct {
	ID       uint   `json:"id" gorm:"primaryKey"`
	Email    string `json:"email" gorm:"unique"`
	Password string `json:"password"`
}

最後に

まだクリーンアーキテクチャを意識したGoプロジェクトの実装を始めたばかりではあるので、今後修正があれば随時更新していきたいと思います。
エラーハンドリング、ロギング、HTTPレスポンスの返値等、まだ検討すべきところは多々あるかと思います。また、バリデーションも今後追加予定です。
もっとこうした方がいいとか、セキュリティ面で不安なところがあるなどありましたらコメントいただけますと幸いです。
ログイン機能はどのアプリケーションにもよく出てくる実装なので、この記事のコードを元にブラッシュアップを重ねていきより良い自分のテンプレートとして使用できたらいいなと思います。

Discussion