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
※本記事は2025年07月時点の情報です。