2025/07/28

テクノロジー

Go×DDD×レイヤードアーキテクチャの構成について

この記事の目次

    GoでレイヤードアーキテクチャとDDD(ドメイン駆動設計)をどう実装しているかまとめます。
    自分で考えて試している部分も多く、この構成でうまくいかない部分もあるかもしれません。その点ご認識ください。

    レイヤードアーキテクチャについては下記が参考になります。
    https://qiita.com/tono-maron/items/345c433b86f74d314c8d

    例として部署情報(department)のCRUDについて書きます

    presentation層(interface)

    ├── presentation/
    │   ├── batch/
    │   │   └── job.go
    │   ├── external/
    │   │   └── client.go
    │   └── rest/
    │       ├── controller/
    │       │   ├── controllers.go
    │       │   ├── department.go
    │       │   └── router.go  # openapi ルーティング定義
    

    controllerとなっていますが、handlerでもいいと思います(この辺は好みです)。

    controller実装例

    package controller
    
    import (
    	"main/application/usecase/department"
    	"main/presentation/rest/openapi"
    	"net/http"
    
    	"github.com/labstack/echo/v4"
    )
    
    type DepartmentController interface {
    	GetDepartment(c echo.Context) error
    	CreateDepartment(c echo.Context) error
    	UpdateDepartment(c echo.Context, departmentID int64) error
    	DeleteDepartment(c echo.Context, departmentID int64) error
    }
    
    type departmentController struct {
    	departmentUseCase department.UseCase
    }
    
    func NewDepartmentController(departmentUseCase department.UseCase) DepartmentController {
    	return &departmentController{
    		departmentUseCase: departmentUseCase,
    	}
    }
    
    func (ctrl *departmentController) GetDepartment(c echo.Context) error {
    	ctx := c.Request().Context()
    	departments, err := ctrl.departmentUseCase.GetAll(ctx)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    	return c.JSON(http.StatusOK, departments)
    }
    
    func (ctrl *departmentController) CreateDepartment(c echo.Context) error {
    	ctx := c.Request().Context()
    	var req openapi.CreateDepartmentJSONRequestBody
    	if err := c.Bind(&req); err != nil {
    		return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    	}
    	err := ctrl.departmentUseCase.Create(ctx, &req)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    	msg := "Successfully created department"
    	res := openapi.The200s{
    		Code:    http.StatusCreated,
    		Message: &msg,
    	}
    	return c.JSON(http.StatusCreated, res)
    }
    
    • APIのdepartment/に関するものはこのcontrollerにまとめています。
    • 業務ロジックが大きい場合はAPI単位でcontrollerを分けてもいいと思います。
    • package名は集約または集約ルートごとに切る予定です。
    • DTOを使う場合はusecaseに渡す前に変換ロジックをここに書いてもいいですが、今回はopenapiで自動生成された型とDTOがほぼ同じだったのでopenapiの型をそのまま使っています。
      ただし、usecase層がopenapi型に依存することになるので、DTOを設けてopenapi→DTOの変換を入れるのがベストです。
      DDDは完璧にやりすぎると時間がかかるので、適宜調整が必要です
    • あとエラーメッセージは後々まとめます

    application層

    ├── application/
    │   ├── service/
    │   │   └── common.go
    │   └── usecase/
    │       └── department/
    │           ├── mapper.go  # ドメインオブジェクト⇔DTOマッピング
    │           └── department.go
    

    前のプロジェクトで、dto<->entityの変換ロジックを一ファイルにまとめると肥大化して分かりにくくなったため、マッピング用のファイルを設けています。

    func ConvertCreateReqToEntity(req *openapi.CreateDepartmentJSONRequestBody) (*department.DepartmentEntity, error) {
    	name, err := stringx.RemoveEmptySpaces(req.Name)
    	if err != nil {
    		return nil, err
    	}
    	return &department.DepartmentEntity{
    		Name: name,
    	}, nil
    }
    

    usecase実装例

    package department
    
    import (
    	"context"
    	"main/domain/department"
    	"main/presentation/rest/openapi"
    )
    
    type UseCase interface {
    	GetAll(ctx context.Context) ([]*openapi.Department, error)
    	Create(ctx context.Context, req *openapi.CreateDepartmentJSONRequestBody) error
    	Update(ctx context.Context, id int64, req *openapi.UpdateDepartmentJSONRequestBody) error
    	Delete(ctx context.Context, id int64) error
    }
    
    type useCase struct {
    	departmentRepository department.Repository
    }
    
    func NewUseCase(departmentRepository department.Repository) UseCase {
    	return &useCase{
    		departmentRepository: departmentRepository,
    	}
    }
    
    func (u *useCase) GetAll(ctx context.Context) ([]*openapi.Department, error) {
    	entities, err := u.departmentRepository.GetAll(ctx)
    	if err != nil {
    		return nil, err
    	}
    	res := ConvertEntitiesToResponses(entities)
    	return res, nil
    }
    
    func (u *useCase) Create(ctx context.Context, req *openapi.CreateDepartmentJSONRequestBody) error {
    	entity, err := ConvertCreateReqToEntity(req)
    	if err != nil {
    		return err
    	}
    	err = u.departmentRepository.Create(ctx, entity)
    	if err != nil {
    		return err
    	}
    	return nil
    }
    
    
    • CRUD処理は単一ファイルにまとめています。要件的に業務ロジックが少ないため肥大化しないと判断したため。
    • 肥大化、大規模開発になる場合はCRUDごとにusecaseを分けるやり方もあります(参考)。
      ですがこちらは作業量も増えるのでプロジェクトの要件や割けるリソースによると思います
    • 今回はinterfaceを設けて単一ファイルにまとめています。
    • usecaseにはドメインロジックを書かないようにしています。

    domain層

    ├── domain/
    │   ├── department/
    │   │   ├── valueobject/
    │   │   ├── entity.go
    │   │   └── repository.go
    │   └── shared/
    │       ├── errors/
    │       │   └── domain_errors.go
    │       └── valueobject/
    │           ├── address.go
    │           └── money.go
    
    • entity, valueobject, repositoryを集約ごとに配置。
    • 複数集約にまたがるvalueobjectはshared配下に置いています。

    repository interface

    package department
    
    import (
    	"context"
    )
    
    type Repository interface {
    	GetAll(ctx context.Context) ([]*DepartmentEntity, error)
    	Create(ctx context.Context, entity *DepartmentEntity) error
    	Update(ctx context.Context, entity *DepartmentEntity) error
    	Delete(ctx context.Context, id int64) error
    }
    
    • 基本的なCRUDはrepositoryのinterfaceとしてまとめています。
    • 複数集約にまたがる検索等はCQRSを導入してquery serviceとしてまとめると保守性が高まります。

    valueobject例

    package valueobject
    
    import "fmt"
    
    type CategoryType uint8
    
    const (
    	CategoryTypeUnknown CategoryType = 0 // 未分類
    	CategoryTypeA       CategoryType = 1 // 種別A
    	CategoryTypeB       CategoryType = 2 // 種別B
    )
    
    func NewCategoryType(value int) (CategoryType, error) {
    	categoryType := CategoryType(value)
    	if !categoryType.IsValid() {
    		return 0, fmt.Errorf("invalid CategoryType value: %d, must be between %d and %d", value, CategoryTypeUnknown, CategoryTypeB)
    	}
    	return categoryType, nil
    }
    
    func (t CategoryType) String() string {
    	switch t {
    	case CategoryTypeUnknown:
    		return "Unknown"
    	case CategoryTypeA:
    		return "TypeA"
    	case CategoryTypeB:
    		return "TypeB"
    	default:
    		return "Unknown"
    	}
    }
    
    func (t CategoryType) IsValid() bool {
    	return t >= CategoryTypeUnknown && t <= CategoryTypeB
    }
    
    
    • valueobject作成時にisvalidでバリデーションを強制しています。valueobject固有のロジックはここにおきます
      例を挙げるなら文字数制限など
    • valueobjectは複数あるのでフォルダを切りました。
    • 複数集約にまたがる場合はdomain serviceにまとめるのが良いと思います。

    entity例

    package department
    
    import (
    	"time"
    )
    
    type DepartmentEntity struct {
    	ID        int64     `json:"id,omitempty"`
    	Name      string    `json:"name"`
    	CreatedAt time.Time `json:"created_at"`
    	UpdatedAt time.Time `json:"updated_at"`
    }
    
    • DB駆動設計にならないよう、ドメインモデリングとテーブル設計は一致させないようにしています。
      https://speakerdeck.com/pospome/liang-ikodonoding-yi-toshe-ji-neng-li-nobi
      テーブルはデータ保持のため、entityはコード上で使いやすいように最適化されるため、DBモデルとドメインモデルは分けて考えるべきです
    • 例えば営業側から見た部署と管理者側から見た部署情報は違うため、entityを分けることも検討します
      こちらもわかりやすいと思います
      https://qiita.com/MinoDriven/items/2a378a09638e234d8614
    • entity固有のドメインロジックはこちらに書き足していきます

    infrastructure層

    ├── infrastructure/
    │   ├── mysql/
    │   │   ├── conf/
    │   │   ├── db/
    │   │   ├── initdata/
    │   │   ├── migrations/
    │   │   └── department/
    │   │       ├── mapper.go  # ドメインオブジェクト⇔ORMモデル変換
    │   │       └── repository.go
    
    • ここにもmapperを用意し、ドメインオブジェクト⇔ORMモデルの変換関数をまとめています。
    func ConvertModelToEntity(model *models.Department) *department.DepartmentEntity {
    	if model == nil {
    		return nil
    	}
    	return &department.DepartmentEntity{
    		ID:        int64(model.ID),
    		Name:      model.Name,
    		CreatedAt: model.CreatedAt,
    		UpdatedAt: model.UpdatedAt,
    	}
    }
    

    repository (実体)実装

    package department
    
    import (
    	"context"
    	"main/domain/department"
    	"main/infrastructure/models"
    
    	"gorm.io/gorm"
    )
    
    type repository struct {
    	db *gorm.DB
    }
    
    func NewRepository(db *gorm.DB) department.Repository {
    	return &repository{db: db}
    }
    
    func (r *repository) GetAll(ctx context.Context) ([]*department.DepartmentEntity, error) {
    	var models []*models.Department
    	if err := r.db.WithContext(ctx).Find(&models).Error; err != nil {
    		return nil, err
    	}
    	return ConvertModelsToEntities(models), nil
    }
    

    依存性注入

    package di
    
    import (
    	"main/application/usecase/department"
    	departmentInfra "main/infrastructure/mysql/department"
    	"main/presentation/rest/controller"
    
    	"gorm.io/gorm"
    )
    
    func Department(db *gorm.DB) controller.DepartmentController {
    	departmentRepository := departmentInfra.NewRepository(db)
    	departmentUsecase := department.NewUseCase(departmentRepository)
    	return controller.NewDepartmentController(departmentUsecase)
    }
    

    依存性注入を行うことにより各レイヤーが分離され単体テスト等がやりやすくなります
    wireを導入して自動でやるのもおすすめです

    CQRSやQuery Serviceについて

    • 複数集約にまたがる検索や参照系の処理は、CQRSパターンを取り入れてQuery Serviceとして切り出すと保守性が高まります。
    • 例えば「部署+ユーザー情報をまとめて取得し検索する」など、集約横断的な参照はdomain層のrepositoryではなく、application層のquery serviceで実装する方針です。
    • これにより、ドメインモデルの純粋性を保ちつつ、柔軟な参照要件にも対応できます。

    最終的なディレクトリ構成例

    backend/
    ├── application/
    │   ├── service/
    │   └── usecase/
    │       └── department/
    │           ├── mapper.go
    │           └── department.go
    ├── cmd/
    │   └── api/
    │       └── main.go
    ├── configs/
    ├── di/
    │   └── department.go
    ├── domain/
    │   ├── department/
    │   │   ├── valueobject/
    │   │   ├── entity.go
    │   │   └── repository.go
    │   └── shared/
    │       ├── errors/
    │       └── valueobject/
    ├── infrastructure/
    │   ├── models/
    │   ├── mysql/
    │   │   └── department/
    │   │       ├── mapper.go
    │   │       └── repository.go
    ├── presentation/
    │   ├── batch/
    │   ├── external/
    │   └── rest/
    │       ├── controller/
    │       │   ├── controllers.go
    │       │   ├── department.go
    │       │   └── router.go
    │       └── openapi/
    

    まとめ

    • 業務ロジックやプロジェクト規模に応じて、controllerやusecaseの粒度を調整しています。
    • DTOやCQRS、Query Serviceの導入は、依存関係や保守性を考慮して適宜検討しています。
    • ドメインモデルとDBテーブル設計は一致させず、それぞれの役割に最適化するよう意識しています。
    • DDDは難しく、まだわからないことも多いです

    この構成は実際に試行錯誤しながら作ったもので、今後も改善していく予定です。
    何かあればコメントの方よろしくお願いします

    今すぐ「レイヤードアーキテクチャ+DDD」を理解しよう。(golang) - Qiita

    役割駆動設計で巨大クラスを爆殺する - Qiita

    DDDはなぜ難しいのか / 良いコードの定義と設計能力の壁

    ※本記事は2025年07月時点の情報です。

    著者:マイナビエンジニアブログ編集部