7
7

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における「俺的」クリーンアーキテクチャ構成

Posted at

はじめに

こんにちは、mizukoです!

親の顔より見たGoのクリーンアーキテクチャ構成について、自分はこうしているよ!というのをアウトプットしていきます!
Goのディレクトリ構成に悩んでいる人の参考になりましたら幸いです!

構成

まず全体像です。

.
├── .devcontainer
├── .env
├── .gitignore
├── Makefile
├── compose.yml
├── config
│   ├── config.go
├── docker
│   ├── golang
│   │   └── Dockerfile
│   └── postgres
│       ├── Dockerfile
│       └── pg_hba.conf
├── domain
│   ├── model
│   │   ├── category.go
│   │   ├── memo.go
│   │   └── user.go
│   └── repository
│       ├── category
│       │   └── category.go
│       ├── common
│       │   └── transaction.go
│       ├── memo
│       │   └── memo.go
│       └── user
│           └── user.go
├── go.mod
├── go.sum
├── go.work
├── go.work.sum
├── infrastructure
│   ├── auth
│   │   └── jwt
│   │       └── jwt.go
│   ├── database
│   │   └── postgres
│   │       └── bun
│   │           ├── connection.go
│   │           ├── mapper
│   │           │   ├── category.go
│   │           │   ├── memo.go
│   │           │   └── user.go
│   │           ├── migrate
│   │           │   ├── generate.go
│   │           │   ├── migrations
│   │           │   └── schema.sql
│   │           ├── model
│   │           │   ├── category.go
│   │           │   ├── memo.go
│   │           │   └── user.go
│   │           ├── query
│   │           │   └── query.go
│   │           ├── repository
│   │           │   ├── category
│   │           │   ├── common
│   │           │   ├── memo
│   │           │   └── user
│   │           └── seeds
│   │               └── main.go
│   └── external
│       └── google
│           └── auth.go
├── main.go
├── mocks
├── presentation
│   └── http
│       ├── constants
│       │   └── xxx.go
│       ├── handler
│       │   ├── auth
│       │   │   ├── auth.go
│       │   ├── category
│       │   │   └── category.go
│       │   ├── memo
│       │   │   └── memo.go
│       │   └── user
│       │       └── user.go
│       ├── helper
│       │   ├── context_helper.go
│       │   └── error_handler.go
│       ├── middleware
│       │   └── authentication.go
│       ├── request
│       │   ├── auth
│       │   │   ├── inputs.go
│       │   │   └── validator.go
│       │   ├── helper.go
│       │   ├── memo
│       │   │   ├── inputs.go
│       │   │   └── validator.go
│       │   └── user
│       │       ├── inputs.go
│       │       └── validator.go
│       ├── response
│       │   ├── auth
│       │   │   └── auth_response.go
│       │   ├── memo
│       │   │   └── get_user_memo_response.go
│       │   └── user
│       │       └── user_response.go
│       └── router
│           └── router.go
├── registry
│   └── registry.go
└── usecase
    ├── category
    │   └── category.go
    ├── memo
    │   ├── memo.go
    │   └── memo_test.go // 例
    └── user
        └── user.go

以下についてそれぞれ説明していきたいと思います。

  • domain
  • usecase
  • presentation
  • infrastructure
  • registry
  • main.go

domain

クリーンアーキテクチャにおける「Entities」部分で、ドメインロジックを集約するディレクトリです。
modelにはentityを、repositoryにはインターフェースを定義します。
modelのディレクトリ名はentityでも良いかと思いますが、そこはお好みで。

domain/mode/category.go
package model

import (
	"time"
)

type Category struct {
	ID        uint      `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`

	Memos []Memo `json:"memos"`
}

// ドメインに関わる処理がある場合は以下の様に定義していく(めっちゃ例です)
func (c *Category) SetCreatedAt() {
	c.CreatedAt = time.Now()
}
domain/repository/category/category.go
package category

import (
	".../domain/model"
)

type CategoryRepository interface {
	FindByID(id uint) (*model.Category, error)
	FindByIDs(ids []uint) ([]model.Category, error)
	GetList() ([]model.Category, error)
	Store(category model.Category) (*model.Category, error)
}

ドメインモデルのstructにORMに関わる情報は載せません。
例えば、Gormのタグの様なものはここに定義せず、infrastructure配下にある、ORM用のmodelに定義します。
ドメインモデルがORMに依存してしまうと、ORMを変えたい場合にドメインモデルも全て書き直す手間が発生します。

usecase

クリーンアーキテクチャにおける「Use Cases」部分で、アプリケーション固有のビジネスロジックを集約するディレクトリです。
現在はusecase配下に各サブドメインが並んでいますが、サブドメインを跨いで共通の処理がある場合などに備えて、以下構成にするのもありかと思います。

.
├── interactors
│   ├── category
│   └── user
└── services

また、サブドメイン配下のファイルはcategory.goとしていますが、1ユースケース = 1ファイルとする構成もありかと思いますし、いくつかのまとまり(read、write、searchなど)でファイルを切るのも良いかと思います。
プロジェクトの規模に応じて良い感じに分けていきましょう。

現在私の場合は、以下の様にインターフェースも実装も一つのファイルで管理しています。
usecaseのロジックが肥大化してくる様なら上記戦略も考えようかなぁ〜という温度感です。
最初からきっちり疎結合にしたい!という方は、1ユースケース1ファイルがオススメです。

usecase/category.go
package category

import (
	".../domain/model"
	domainRepository ".../domain/repository/category"
)

type CategoryUsecaseImpl struct {
	CategoryRepository domainRepository.CategoryRepository
}

func NewCategoryUsecase(repo domainRepository.CategoryRepository) CategoryUsecase {
	return &CategoryUsecaseImpl{
		CategoryRepository: repo,
	}
}

type CategoryUsecase interface {
	GetList() ([]model.Category, error)
}

func (i *CategoryUsecaseImpl) GetList() ([]model.Category, error) {
	return i.CategoryRepository.GetList()
}

presentation

クリーンアーキテクチャにおける「Interface Adapters」部分で、外部からのデータのやり取りやユースケースの呼び出しなどを行います。

presentation配下に現状httpしかありませんが、gRPCやcliなどの追加を想定しています。

http配下については、主要なhandlerとrouterを紹介します。
middlewareやhelperなどは、別途記事にできたらなぁ〜と思ってます!

handler

handlerは、外部からのデータの変換やユースケースの呼び出しなど、インターフェース・アダプター層の中核を担います。
webフレームワークはGinを使っています。

package memo

import (
	"net/http"
	"strconv"

	".../presentation/http/constants"
	".../presentation/http/helper"
	request ".../presentation/http/request/memo"
	response ".../presentation/http/response/memo"
	usecase ".../usecase/memo"
	"github.com/gin-gonic/gin"
)

type MemoHandler struct {
	MemoUseCase usecase.MemoUsecase
	Validator   *request.MemoValidator
}

func NewMemoHandler(memoUseCase usecase.MemoUsecase) MemoHandler {
	return MemoHandler{
		MemoUseCase: memoUseCase,
		Validator:   request.NewMemoValidator(),
	}
}

~  ~

func (h *MemoHandler) Create(c *gin.Context) {
	contextUser := helper.GetUserFromContext(c)

	var input request.CreateMemoRequest

	if err := c.Bind(&input); err != nil {
		helper.HandleInternalServerError(c, err.Error())
		return
	}

	if err := h.Validator.ValidateCreateMemoRequest(input); err != nil {
		helper.HandleValidationErrors(c, err, h.Validator.Translator)
		return
	}

	_, err := h.MemoUseCase.Create(contextUser.ID, input)
	if err != nil {
		helper.HandleBadRequest(c, err.Error())
		return
	}

	c.JSON(http.StatusCreated, gin.H{})
}

~  ~

router

routerはAPIのパスを定義します。
middlewareやcorsの設定などもここで行います。

package router

import (
	"os"
	"time"

        ".../presentation/http/handler/auth"
	".../presentation/http/handler/category"
	".../presentation/http/handler/memo"
	".../presentation/http/middleware"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
)

type Router struct {
	AuthHandler         auth.AuthHandler
	CategoryHandler     category.CategoryHandler
	MemoHandler         memo.MemoHandler
	AuthMiddleware      middleware.AuthMiddleware
}

func NewRouter(
	authHandler auth.AuthHandler,
	categoryHandler category.CategoryHandler,
	memoHandler memo.MemoHandler,
	authMiddleware middleware.AuthMiddleware,
) Router {
	return Router{
		AuthHandler:         authHandler,
		CategoryHandler:     categoryHandler,
		MemoHandler:         memoHandler,
		AuthMiddleware:      authMiddleware,
	}
}

func CORSConfig() cors.Config {
	return cors.Config{
		AllowOrigins: []string{
			os.Getenv("APP_URL"),
		},
		AllowMethods: []string{
			"POST",
			"GET",
			"PUT",
			"DELETE",
			"OPTIONS",
		},
		AllowHeaders: []string{
			"Access-Control-Allow-Credentials",
			"Access-Control-Allow-Headers",
			"Content-Type",
			"Content-Length",
			"Accept-Encoding",
			"Authorization",
		},
		AllowCredentials: true,
		MaxAge:           1 * time.Hour,
	}
}

func (r *Router) SetupRoutes(e *gin.Engine) {
	// CORSの設定
	e.Use(cors.New(CORSConfig()))

	v1Group := e.Group("/v1")

	v1Group.POST("/auth/login", r.AuthHandler.Login)
	v1Group.GET("/auth/verify", r.AuthHandler.Verify)
	v1Group.GET("/auth/refresh", r.AuthHandler.Refresh)

~  ~ 

	v1Group.GET("/memos", r.MemoHandler.GetList)

	// 認証が必要なルートグループ
	authorizedGroup := v1Group.Group("/me")
	authorizedGroup.Use(r.AuthMiddleware.Authentication())
	{

~  ~

		authorizedGroup.GET("/memos", r.MemoHandler.GetListByUser)
		authorizedGroup.POST("/memo", r.MemoHandler.Create)

~  ~

	}
}

infrastructure

クリーンアーキテクチャにおける「Frameworks & Drivers」部分で、データベース、外部APIサービスなどの外部リソースとの連携を行います。

authとexternalについては割愛しますが、名前の通り、それぞれ認証に関する役割と外部サービスとの連携を担っています。

database

その名の通り、databaseに関わる処理を集約します。
database配下に現在はpostgresしかありませんが、MySQLやSQLite等、別のDBクライアントが並びます。
さらにpostgres配下には現状bunしかありませんが、Gorm等別のORMが並びます。

この構成にすることにより、別のDBクライアントやORMに簡単に切り替えることができます。(私も実際GormからBunへリプレイスしましたが、ORM部分だけ切り替えるだけでリプレイスが完了しました。)

repository

domainで定義したrepositoryの実態を実装します。

package category

import (
	"context"
	"database/sql"
	"errors"
	"log"

	domainModel ".../domain/model"
	domainRepository ".../domain/repository/category"
	".../infrastructure/database/postgres/bun/mapper"
	ormModel ".../infrastructure/database/postgres/bun/model"

	"github.com/uptrace/bun"
)

type categoryRepository struct {
	db *bun.DB
}

func NewCategoryRepository(db *bun.DB) domainRepository.CategoryRepository {
	return &categoryRepository{db: db}
}

func (r *categoryRepository) FindByID(id uint) (*domainModel.Category, error) {
	var category ormModel.Category
	err := r.db.NewSelect().Model(&category).Where("id = ?", id).Scan(context.TODO())

	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, nil
		}

		log.Printf("Could not query category: %v", err)

		return nil, err
	}

	return mapper.ToDomainCategoryModel(&category), nil
}

mapper

domainモデルとORMのモデルとのマッピング担います。
mapperを挟むことにより、domainモデルがデーターベース側に依存しなくなるので、先述した通り、DBやORMの切り替えが容易になります。

package mapper

import (
	domainModel ".../domain/model"
	ormModel ".../infrastructure/database/postgres/bun/model"
)

func ToDomainCategoryModel(ormCategory *ormModel.Category) *domainModel.Category {
	domainMemos := make([]domainModel.Memo, len(ormCategory.Memos))
	for i, ormMemo := range ormCategory.Memos {
		domainMemos[i] = *ToDomainMemoModel(&ormMemo)
	}

	return &domainModel.Category{
		ID:        ormCategory.ID,
		Name:      ormCategory.Name,
		CreatedAt: ormCategory.CreatedAt,
		UpdatedAt: ormCategory.UpdatedAt,

		Memos: domainMemos,
	}
}

func ToORMCategoryModel(domainCategory *domainModel.Category) *ormModel.Category {
	ormMemos := make([]ormModel.Memo, len(domainCategory.Memos))
	for i, domainMemo := range domainCategory.Memos {
		ormMemos[i] = *ToORMMemoModel(&domainMemo)
	}

	return &ormModel.Category{
		ID:        domainCategory.ID,
		Name:      domainCategory.Name,
		CreatedAt: domainCategory.CreatedAt,
		UpdatedAt: domainCategory.UpdatedAt,

		Memos: ormMemos,
	}
}

model

ORMのモデルです。
bunの場合は以下の様な感じでタグを付与しています。

package model

import (
	"time"

	"github.com/uptrace/bun"
)

type Category struct {
	bun.BaseModel `bun:"table:categories,alias:c"`

	ID        uint      `bun:",pk,autoincrement"`
	Name      string    `bun:",type:varchar(100)"`
	CreatedAt time.Time `bun:",notnull,default:current_timestamp"`
	UpdatedAt time.Time `bun:",notnull,default:current_timestamp"`

	Memos []Memo `bun:"m2m:memo_categories,join:Category=Memo"`
}

registry

自作のDIコンテナを定義します。
個人的にはライブラリを使うまでも無く、registryを見れば依存関係を一目瞭然できる今の構成が気に入っています。

~  ~
type Registry struct {
	GoogleAuthService           googleAuthService.GoogleAuthService
	AuthHandler                 authHandler.AuthHandler
	GoogleAuthHandler           authHandler.GoogleAuthHandler
	UserRepository              *userDomainRepository.UserRepository
	UserUsecase                 userUsecase.UserUsecase
	UserHandler                 userHandler.UserHandler
	MemoRepository              *memoDomainRepository.MemoRepository
	MemoUsecase                 memoUsecase.MemoUsecase
	MemoHandler                 memoHandler.MemoHandler
	CategoryRepository          *categoryDomainRepository.CategoryRepository
	CategoryUsecase             categoryUsecase.CategoryUsecase
	CategoryHandler             categoryHandler.CategoryHandler
	AuthMiddleware              middleware.AuthMiddleware
	TxRepository                *commonDomainRepository.TxRepository
	Router                      router.Router
}

func NewRegistry(db *bun.DB) *Registry {
	r := &Registry{}

	r.initRegistry(db)

	return r
}

func (r *Registry) initRegistry(db *bun.DB) {
	txRepository := commonInfraRepository.NewTxRepository(db)
	r.TxRepository = &txRepository

	googleAuthService := googleAuthService.NewGoogleAuthService()
	r.GoogleAuthService = googleAuthService

	userRepository := userInfraRepository.NewUserRepository(db)
	userUsecase := userUsecase.NewUserUsecase(userRepository)

	r.UserRepository = &userRepository
	r.UserUsecase = userUsecase

	r.AuthHandler = authHandler.NewAuthHandler(userUsecase)
	r.GoogleAuthHandler = authHandler.NewGoogleAuthHandler(userUsecase, googleAuthService)
	r.UserHandler = userHandler.NewUserHandler(userUsecase)
	r.AuthMiddleware = middleware.NewAuthMiddleware(userRepository)

	categoryRepository := categoryInfraRepository.NewCategoryRepository(db)
	categoryUsecase := categoryUsecase.NewCategoryUsecase(categoryRepository)
	r.CategoryHandler = categoryHandler.NewCategoryHandler(categoryUsecase)
	r.CategoryRepository = &categoryRepository
	r.CategoryUsecase = categoryUsecase

	memoRepository := memoInfraRepository.NewMemoRepository(db)
	memoUsecase := memoUsecase.NewMemoUsecase(memoRepository, categoryRepository, txRepository)
	r.MemoRepository = &memoRepository
	r.MemoUsecase = memoUsecase
	r.MemoHandler = memoHandler.NewMemoHandler(memoUsecase)

	r.Router = router.NewRouter(
		r.AuthHandler,
		r.GoogleAuthHandler,
		r.CategoryHandler,
		r.MemoHandler,
		r.UserHandler,
		r.AuthMiddleware,
	)
}

main.go

データベースの接続やDIコンテナの初期化、ルーティングのセットアップなどを行います。

package main

import (
	"log"

	postgres ".../infrastructure/database/postgres/bun"
	".../registry"
	"github.com/gin-gonic/gin"
)

func main() {
	// データベース接続を作成
	db, err := postgres.LoadConfigAndCreateDBConnection()
	if err != nil {
		log.Fatalf("Could not connect to database: %v", err)
	}

	// DIコンテナを初期化
	registry := registry.NewRegistry(db)

	// ルーティングの設定
	r := gin.Default()
	registry.Router.SetupRoutes(r)

	r.Run(":8080")
}

まとめ

ということで、Goでやる俺的クリーンアーキテクチャ構成を殴り書きしてみました!
雰囲気は伝わったでしょうか...?
あくまで「現状」なので、また明日には変わっているかもしれませんが、都度更新できたらなぁと思ってます。(たぶん)
また、middlewareやdtoなど、今回書ききれなかったことを別途記載する予定ですので、是非フォローやいいねお願いします🫶

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?