「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」書籍がDDDを実践しはじめるのに分かりやすくとてもよいのですが、忘れてしまい何度か読み直しているのでまとめておきます。今回はドメインサービスです。
ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本
- 成瀬 允宣
- 翔泳社
- 価格¥1,760(2022/05/05 01:05時点)
- 発売日2020/02/13
- 商品ランキング2,613位
ドメインサービスとは
システムには値オブジェクトやエンティティに記述すると不自然になってしまうふるまいが存在します。ドメインサービスはそういった不自然さを解決するオブジェクトです。
例えば、ユーザの重複確認をする処理をする場合、生成したオブジェクト自身に問い合わせをすることになるので、ユーザ自身に重複するかを確認するのは不自然な振る舞いとなります。
package model
import (
"errors"
"fmt"
"github.com/google/uuid"
)
type User struct {
ID string
Name string
}
func NewUser(name string) (*User, error) {
u := &User{
ID: uuid.NewString(),
}
err := u.ChangeName(name)
if err != nil {
return nil, err
}
return u, nil
}
// 自分自身に問い合わせる不自然な振る舞い
func Exists(name string) bool {
//重複を確認するコード
// ...
return false
}
不自然さを解決する
この不自然さを解決するのがドメインサービスです。
package service
import (
"github.com/taisa831/go-ddd/domain/model"
"github.com/taisa831/go-ddd/domain/repository"
"github.com/taisa831/go-ddd/domain/service"
)
type UserService struct {
r repository.Repository
}
func NewUserService(r repository.Repository) service.UserService {
return &UserService{
r: r,
}
}
// 不自然さを解決する
func (s *UserService) Exists(name string) (bool, error) {
_, err := s.r.FindUserByName(name)
if err != nil {
return false, err
}
return true, nil
}
可能な限りドメインサービスを避ける
ドメインモデルの処理もドメインサービスに書けてしまいますが、無機質な Getter と Setter だけを持った、何も語らないドメインモデルとなるため(ドメインモデル貧血症)、可能な限りドメインサービスの利用は避けるようにします。
ドメインモデル貧血症ドメインオブジェクトに本来記述されるべき知識やふるまいが、ドメインサービスやアプリケーションサービスに記述され、語るべきことを何も語っていないドメインオブジェクトの状態をドメインモデル貧血症といいます。
エンティティや値オブジェクトと共にユースケースを組み立てる
実際に MySQL を利用してクリーンアーキテクチャでドメインモデル・ドメインサービス・ユースケースを構成してみました。
tree
.
├── application
│ └── usecase
│ └── user_usecase.go
├── domain
│ ├── model
│ │ ├── errors.go
│ │ └── users.go
│ ├── repository
│ │ └── repository.go
│ └── service
│ └── user_service.go
├── go.mod
├── go.sum
├── gorm.db
├── infrastructure
│ ├── repository
│ │ ├── repository.go
│ │ └── user_repository.go
│ └── service
│ └── user_service.go
├── interfaces
│ ├── handler
│ │ └── user_handler.go
│ └── request
│ │ └── user_request.go
│ └── response
│ └── user_response.go
└── main.go
ユーザー作成処理
ユーザー作成処理を例に実装してみます。API 定義は下記を想定します。
- POST /users
request body
{
"name": "username" // username 重複不可
}
- response
- 200 OK
ドメインモデル
ドメインモデルを実装します。
domain/model/users.go
package model
import (
"errors"
"fmt"
"github.com/google/uuid"
)
type User struct {
ID string
Name string
}
type UserCreateConfig struct {
Name string
}
func NewUser(conf UserCreateConfig) (*User, error) {
u := &User{
ID: uuid.NewString(),
}
err := u.ChangeName(conf.Name)
if err != nil {
return nil, err
}
return u, nil
}
func (m *User) ChangeName(name string) error {
if name == "" {
return fmt.Errorf("ユーザ名は必須です。")
}
if len(name) < 3 {
return fmt.Errorf("ユーザ名は3文字以上です。%s", name)
}
m.Name = name
return nil
}
ドメインモデルに独自エラーを定義しておきます。
domain/model/errors.go
package model
type UserExistsError struct{}
func (e *UserExistsError) Error() string {
return "ユーザーは存在します。"
}
ドメインサービス
ドメインサービスのインターフェースを定義してインフラ層に実装します。
domain/service/user_service.go
package service
import "github.com/taisa831/go-ddd/domain/model"
type UserService interface {
Exists(name string) (bool, error)
}`
infrastructure/service/user_service.go
package service
import (
"github.com/taisa831/go-ddd/domain/model"
"github.com/taisa831/go-ddd/domain/repository"
"github.com/taisa831/go-ddd/domain/service"
"gorm.io/gorm
)
type UserService struct {
r repository.Repository
}
func NewUserService(r repository.Repository) service.UserService {
return &UserService{
r: r,
}
}
// ユーザーの重複確認をする
func (s *UserService) Exists(name string) (bool, error) {
_, err := s.r.FindUserByName(name)
if err != nil && err != gorm.ErrRecordNotFound {
return false, err
}
return true, nil
}
リポジトリ
リポジトリのインターフェースを定義してインフラ層に実装します。
domain/repository/repository.go
package repository
import "github.com/taisa831/go-ddd/domain/model"
type Repository interface {
FindUserByName(name string) (*model.User, error)
CreateUser(user *model.User) error
}`
`infrastructure/repository/repository.go`
`package repository
import (
"github.com/taisa831/go-ddd/domain/repository"
"gorm.io/gorm"
)
type dbRepository struct {
db *gorm.DB
}
func OpenDB() (*gorm.DB, error) {
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", os.Getenv("USERNAME"), os.Getenv("PASSWORD"), os.Getenv("DBHOST"), os.Getenv("DBPORT"), os.Getenv("SCHEMA"))
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newLogger,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
panic(err)
}
sqlDB.SetMaxOpenConns(151)
sqlDB.SetMaxIdleConns(100)
sqlDB.SetConnMaxLifetime(10 * time.Minute)
err = db.AutoMigrate(&model.User{})
if err != nil {
return nil, err
}
return db, nil
}
func NewRepository(db *gorm.DB) repository.Repository {
return &dbRepository{
db: db,
}
}
infrastructure/repository/user_repository.go
package repository
import (
"github.com/taisa831/go-ddd/domain/model"
"gorm.io/gorm"
)
func (r *dbRepository) FindUserByName(name string) (*model.User, error) {
var user model.User
err := r.db.Where("name = ?", name).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *dbRepository) CreateUser(user *model.User) error {
return r.db.Create(user).Error
}
ユースケース
application/usecase/user_usecase.go
package usecase
import (
"fmt"
"github.com/taisa831/go-ddd/application/input"
"github.com/taisa831/go-ddd/domain/model"
"github.com/taisa831/go-ddd/domain/repository"
"github.com/taisa831/go-ddd/domain/service"
)
type UserUsecase struct {
r repository.Repository
us service.UserService
}
func NewUserUsecase(r repository.Repository, us service.UserService) *UserUsecase {
return &UserUsecase{
r: r,
us: us,
}
}
func (u *UserUsecase) Create(in input.UserCreateInput) error {
b, err := u.us.Exists(in.Name)
if err != nil {
return err
}
if b {
// ユーザーが存在する場合は独自エラーを返す
return &model.UserExistsError{}
}
conf := model.UserCreateConfig{
Name: in.Name,
}
user, err := model.NewUser(conf)
if err != nil {
return err
}
if err := u.r.CreateUser(user); err != nil {
return err
}
return nil
}
ハンドラー
interfaces/handler/user_handler.go
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/taisa831/go-ddd/application/usecase"
"github.com/taisa831/go-ddd/domain/model"
"github.com/taisa831/go-ddd/domain/repository"
"github.com/taisa831/go-ddd/domain/service"
"github.com/taisa831/go-ddd/interfaces/request"
)
type UserHandler struct {
u *usecase.UserUsecase
}
func NewUserHandler(r repository.Repository, us service.UserService) UserHandler {
return UserHandler{
u: usecase.NewUserUsecase(r, us),
}
}
func (h *UserHandler) Create(c *gin.Context) {
req := request.UserCreateRequest{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.u.Create(req)
if err != nil {
var reErr *model.UserExistsError
if errors.As(err, &reErr) {
// UserExistsError の場合は 409 conflict を返す
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{})
}
ルーティング
main.go
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/taisa831/go-ddd/infrastructure/repository"
"github.com/taisa831/go-ddd/infrastructure/service"
"github.com/taisa831/go-ddd/interfaces/handler"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal(".env ファイルの読み込みに失敗しました。")
}
db, err := repository.OpenDB()
if err != nil {
log.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatal(err)
}
defer sqlDB.Close()
router := gin.Default()
r := repository.NewRepository(db)
us := service.NewUserService(r)
uh := handler.NewUserHandler(r, us)
router.POST("/users", uh.Create)
router.Run()
}
環境変数
.env
USERNAME=username
PASSWORD=password
DBHOST=127.0.0.1
DBPORT=3306
SCHEMA=schemaname
動作確認
$ go run main.go
$ curl -X POST http://localhost:8080/users -H "content-type:application/json" -d '{ "name": "username" }'
$ {} // username 登録
$ curl -X POST http://localhost:8080/users -H "content-type:application/json" -d '{ "name": "username" }'
$ {"error":"ユーザーは存在します。"} // username 重複
Docker で実行可能にする
これらのコードを Docker で実行可能にします。
Dockerfile
`# syntax = docker/dockerfile:experimental
FROM golang:1.16.4-alpine
RUN apk add --no-cache \
git
RUN GO111MODULE=off go get -u -v \
# ホットリロードライブラリ
github.com/oxequa/realize
# コンテナの起動を待つライブラリ
ENV DOCKERIZE_VERSION v0.6.1
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
&& tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
&& rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz
# コンテナログイン時のディレクトリ指定
WORKDIR /go/src/go-ddd
# ホストのファイルをコンテナの作業ディレクトリにコピー
COPY . .
# ビルド
RUN go build -o /opt/app main.go`
`docker-compose.yml`
`version: "3"
volumes:
db-volume:
services:
mysql:
image: mysql:5.7.30
environment:
# MySQL設定値
MYSQL_USER: user
MYSQL_ROOT_PASSWORD: root
MYSQL_PASSWORD: password
MYSQL_DATABASE: go-ddd
ports:
- 13306:3306
volumes:
- db-volume:/var/lib/mysql
app:
build: .
ports:
- "8081:8080"
entrypoint:
- dockerize
- -wait
- tcp://mysql:3306
command: realize start --run
restart: always
volumes:
# realizeがコードの変更を検知する
- .:/go/src/go-ddd
depends_on:
- mysql`
ホットリロード用に realize の設定ファイルを配置します。
.realize.yaml
settings:
legacy:
force: false
interval: 100s
schema:
- name: app
path: .
commands:
generate:
status: true
install:
status: true
method: go build -x -o /opt/app
run:
status: true
method: /opt/app
watcher:
extensions:
- go
paths:
- /
ignored_paths:
- .git
- .realize
- vendor
.env
の DBHOST を mysql に変更する
USERNAME=user
PASSWORD=password
DBHOST=mysql
DBPORT=3306
SCHEMA=go-ddd
起動する
$ docker-compose up
app_1 | [01:19:03][APP] : Install started
app_1 | [01:19:07][APP] : Install completed in 3.464 s
app_1 | [01:19:07][APP] : Running..
app_1 | [01:19:07][APP] : - using env: export GIN_MODE=release
app_1 | [01:19:07][APP] : - using code: gin.SetMode(gin.ReleaseMode)
app_1 | [01:19:07][APP] : [GIN-debug] POST /users --> github.com/taisa831/go-ddd/interfaces/handler.(*UserHandler).Create-fm (3 handlers)
app_1 | [01:19:07][APP] : [GIN-debug] GET /users --> github.com/taisa831/go-ddd/interfaces/handler.(*UserHandler).List-fm (3 handlers)
app_1 | [01:19:07][APP] : [GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
app_1 | [01:19:07][APP] : [GIN-debug] Listening and serving HTTP on :8080
// port 8081 にして実行する
$ curl -X POST http://localhost:8081/users -H "content-type:application/json" -d '{ "name": "username" }'
まとめ
- ドメインサービスはドメインモデルの不自然さを解決するもの
- ドメインモデル貧血症になるため、可能な限りドメインサービスを避ける
ドメインサービスについて簡単にまとめましたが、詳しくは下記書籍にてご確認ください。
ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本
- 成瀬 允宣
- 翔泳社
- 価格¥1,760(2022/05/05 01:05時点)
- 発売日2020/02/13
- 商品ランキング2,613位
その他参考書籍
エリック・エヴァンスのドメイン駆動設計
- Eric Evans,和智右桂,牧野祐子,今関剛
- 翔泳社
- 価格¥5,148(2022/05/05 01:05時点)
- 発売日2011/04/08
- 商品ランキング37,053位
実践ドメイン駆動設計 (Object Oriented SELECTION)
- ヴォーン・ヴァーノン,髙木 正弘
- 翔泳社
- 価格¥5,720(2022/05/05 01:05時点)
- 発売日2015/03/16
- 商品ランキング28,318位