Goで理解するDDD – ドメインサービス


ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」書籍がDDDを実践しはじめるのに分かりやすくとてもよいのですが、忘れてしまい何度か読み直しているのでまとめておきます。今回はドメインサービスです。

 

ドメインサービスとは

システムには値オブジェクトやエンティティに記述すると不自然になってしまうふるまいが存在します。ドメインサービスはそういった不自然さを解決するオブジェクトです。

例えば、ユーザの重複確認をする処理をする場合、生成したオブジェクト自身に問い合わせをすることになるので、ユーザ自身に重複するかを確認するのは不自然な振る舞いとなります。

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" }'

 

まとめ

  • ドメインサービスはドメインモデルの不自然さを解決するもの
  • ドメインモデル貧血症になるため、可能な限りドメインサービスを避ける

 

ドメインサービスについて簡単にまとめましたが、詳しくは下記書籍にてご確認ください。

 

その他参考書籍


関連記事