🍉

Go初心者がレイヤードアーキテクチャ+DDDでAPIを実装してみる

2022/09/11に公開

はじめに

この記事は実務でRailsでAPI開発をしていた私自身がGo言語でAPI開発をすることになり、Go言語やDDDなどの設計手法についてある程度理解するために、簡単な設計をした上でAPIを実装してみて学んだことをまとめたものです。
そのため、初心者向けの記事となりますが、有益な記事となれば幸いです!!

DDD(ドメイン駆動設計)とは

DDD(ドメイン駆動設計)とは、ドメインの知識に着目しドメインモデルを練り上げ、それをソフトウェアに落とし込んでいく設計手法。

ドメインとは、プログラムを適用する対象となる領域を指しており、
ドメインにおける問題解決のために、物事の特定の側面を抽象化(モデリング)したものがドメインモデルにあたります。

ソフトウェア開発の目的はソフトウェア利用者のドメインにおける何らかの問題を解決することであり、問題解決に必要な知識をソフトウェアに反映していかなければなりません。
そのため、DDDではドメインの知識と問題について洞察を繰り返し、ドメインモデルを適切に構築することで、ソフトウェアの価値をより高めていくことを目的としています。
(私なりのざっくりとした解釈が含まれます)

参照: https://codezine.jp/article/detail/11968

レイヤードアーキテクチャとは


レイヤードアーキテクチャはシステムの責務をレイヤーごとに切り分け、依存関係が上のレイヤーから下のレイヤーへ一方向に依存しているのが特徴。

各レイヤーの責務は下記のようになります。

Interface層

フロントからリクエストを受け取り、結果を返すといったコントローラーの役割を持ちます。

Usecase層

アプリケーションのユースケースを表現します。domainのモデルを使って何をするかといったこと。

Domain層

業務の関心事を取り扱うレイヤーです。
ドメインに関する値や振る舞いなどのビジネスロジックを実装する責務を持ちます。

Infrastructure層

技術的な関心事を取り扱うレイヤーです。
データの呼び出し・書き込みを行うDB操作や、メール配信や決済処理をしてくれるような外部サービスとのやり取りを実装する責務を持ちます。

レイヤードアーキテクチャ + DDD

レイヤードアーキテクチャにDDDの設計思想を取り入れることで、下記の図のようにdomain層とinfrastructure層の依存関係が逆転します。
DDDは技術的な問題ではなくドメインの問題に焦点をあてています。よって、DDDの設計思想を取り入れ、domain層が全ての中心になるような設計になっています。

参照: https://www.ogis-ri.co.jp/otc/hiroba/technical/DDDEssence/chap1.html

実装

ここから実際にレイヤードアーキテクチャ+DDDの設計で、APIを実装していきます。
また、DIを使いusecase層やinfrastructure層で実装したオブジェクト同士が直接依存しないようにしていきます。

ユーザーの取得・作成・更新・削除ができる簡単な機能を実装します。

DBには下記のUserのテーブルを用意しています。

Fied Type
id int
name varchar
created_at datetime
updated_at datetime

環境:
・Go: 1.19
・Echo: 4.0.0
・MySQL: 8.0.28

ディレクトリ構成

go_api_sample/
├─ api
│   └─ main.go
├─ domain
│   └─ user.go
├─ infrastructure
│   └─ user.go
├─ interfaces
│   ├─ controllers.go
│   └─ user.go
└─ usecase
    └─ user.go

domain層

domain層の中でドメインモデルを設計していきます。
Userのモデルを実装する場合は、Userは、名前を持つ、名前は必ず存在する、名前は20文字以内、といったようなUserの持つ振る舞いを記述します。

まず、entityを記述します。entityにはDB項目のプロパティを記述します。
entityに関数を紐付けていき、Userの振る舞いを作っていきます。

domain/user.go
package domain

import (
	"fmt"
	"time"
	"unicode/utf8"
)

// Userのentity
type User struct {
	ID int

	Name string

	CreatedAt time.Time
	UpdatedAt time.Time
}

func (u *User) Validate() error {
	if len(u.Name) == 0 {
		return fmt.Errorf("User name is empty")
	}

	if utf8.RuneCountInString(u.Name) > 20 {
		return fmt.Errorf("User name is too long")
	}

	return nil
}

次にrepositoryを実装します。repositoryはentityの保管庫の役割であり、entityのデータの保存や取得をするためのものです。
domain層には技術的な関心事を記述したくないため、domain層にはrepositoryのインターフェースを記述しておき、repositoryのオブジェクトはinfrastructure層で実装し、そちらで具体的なDB操作などを記述します。
Find,Create,Update,Deleteといった基本的な操作を定義しています。

domain/user.go
package domain

import (
	"fmt"
	"time"
	"unicode/utf8"
)

// Userのentity
type User struct {
	ID int

	Name string

	CreatedAt time.Time
	UpdatedAt time.Time
}

func (u *User) Validate() error {
	if len(u.Name) == 0 {
		return fmt.Errorf("User name is empty")
	}

	if utf8.RuneCountInString(u.Name) > 20 {
		return fmt.Errorf("User name is too long")
	}

	return nil
}

// Userのrepository
type UserRepository interface {
	Find(id int) (*User, error)
	Create(user *User) error
	Update(user *User) error
	Delete(id int) error
}

infrastructure層

infrastructure層でDB操作などの技術的な関心事を記述していきます。

domain層で定義したrepositoryのインターフェースのあわせて、repositoryのオブジェクトを実装します。DB操作をする基本的なクエリを記述しています。

infrastructure/user.go
package infrastructure

import (
	"database/sql"
	"go_api_sample/domain"
)

type UserRepositoryInfrastructure struct {
	*sql.DB
}

func NewUserRepositoryInfrastructure(db *sql.DB) domain.UserRepository {
	return &UserRepositoryInfrastructure{db}
}

func (r *UserRepositoryInfrastructure) Find(id int) (*domain.User, error) {
	row := r.QueryRow(`SELECT * FROM users WHERE id = ?`, id)

	user, err := toStructure(row)

	if err != nil {
		return nil, err
	}

	return user, nil
}

func (r *UserRepositoryInfrastructure) Create(user *domain.User) error {
	_, err := r.Exec(`INSERT INTO users(name, created_at, updated_at) values(?, ?, ?)`, user.Name, user.CreatedAt, user.UpdatedAt)
	if err != nil {
		return err
	}

	return nil
}

func (r *UserRepositoryInfrastructure) Update(user *domain.User) error {
	_, err := r.Exec(`UPDATE users SET name = ?, updated_at = ? WHERE id = ?`, user.Name, user.UpdatedAt, user.ID)
	if err != nil {
		return err
	}

	return nil
}

func (r *UserRepositoryInfrastructure) Delete(id int) error {
	_, err := r.Exec(`DELETE FROM users WHERE id = ?`, id)
	if err != nil {
		return err
	}

	return nil
}

// DBから取得した行データをdomainのentityの型に変換
func toStructure(row *sql.Row) (*domain.User, error) {
	user := &domain.User{}
	err := row.Scan(&user.ID, &user.Name, &user.CreatedAt, &user.UpdatedAt)
	if err != nil {
		return nil, err
	}

	return user, nil
}

repositoryを呼び出すためのメソッドでNewUserRepositoryInfrastructureを用意しています。repositoryはDBに依存していますが、直接依存させたくありません。そのため、NewUserRepositoryInfrastructureの引数で、作成したDBを受け取るようにし、直接依存しないようにします。
また、NewUserRepositoryInfrastructureの戻り値でdomainで定義したrepositoryのインターフェースを返すようにしています。repositoryを利用したいオブジェクトはrepositoryのインターフェースに依存させ、後からrepositoryのインターフェースにNewUserRepositoryInfrastructureで呼び出したrepositoryのオブジェクトを注入することで、repositoryとrepositoryを呼び出す他のオブジェクトが直接依存しないようになります。

(ORMは利用していませんが、Go言語のORMにgormsqlboilerなどがあります。)

usecase層

usecase層にアプリケーションのユースケースを記述していきます。

repositoryからUserのデータの取得や更新をする処理を呼び出し、Userを取得、作成、更新、削除するといったユースケースを記述していきます。

usecase/user.go
package usecase

import (
	"go_api_sample/domain"
	"time"
)

type UserUsecase interface {
	FindUser(id int) (*domain.User, error)
	CreateUser(input *CreateUserInput) error
	UpdateUser(input *UpdateUserInput) error
	DeleteUser(id int) error
}

type CreateUserInput struct {
	Name string
}

type UpdateUserInput struct {
	ID   int
	Name string
}

type userUsecase struct {
	userRepository domain.UserRepository
}

func NewUserUsecase(
	userRepository domain.UserRepository,
) UserUsecase {
	return &userUsecase{
		userRepository: userRepository,
	}
}

func (u *userUsecase) FindUser(id int) (*domain.User, error) {
	user, err := u.userRepository.Find(id)
	if err != nil {
		return nil, err
	}

	return user, nil
}

func (u *userUsecase) CreateUser(input *CreateUserInput) error {
	now := time.Now()
	user := &domain.User{
		Name:      input.Name,
		CreatedAt: now,
		UpdatedAt: now,
	}

	if err := user.Validate(); err != nil {
		return err
	}

	if err := u.userRepository.Create(user); err != nil {
		return err
	}

	return nil
}

func (u *userUsecase) UpdateUser(input *UpdateUserInput) error {
	user, err := u.userRepository.Find(input.ID)
	if err != nil {
		return err
	}

	user.Name = input.Name
	user.UpdatedAt = time.Now()

	if err := user.Validate(); err != nil {
		return err
	}

	if err := u.userRepository.Update(user); err != nil {
		return err
	}

	return nil
}

func (u *userUsecase) DeleteUser(id int) error {
	if err := u.userRepository.Delete(id); err != nil {
		return err
	}

	return nil
}

usecase層でrepositoryを呼び出していますが、依存先はdomainのrepositoryのインターフェースにしており、infrastructure層で定義したrepositoryの中身の実装については知らないようになっています。

usecase層でも同様に、usecaseのインターフェースを定義しておき、NewUserUsecaseの戻り値でインターフェースを返すようにします。

interfaces層

interfaces層ではコントローラーのコンポーネントを実装します。

コントローラーはリクエストを受け取り、usecaseを使って処理をした結果を返します。
親コントローラーになるControllersと、その子になるUserControllerを実装していきます。

interfaces/controllers.go
package interfaces

import (
	"github.com/labstack/echo/v4"
)

type Controllers struct {
	userController *UserController
}

func NewControllers(
	userController *UserController,
) *Controllers {
	return &Controllers{
		userController: userController,
	}
}

func (c *Controllers) Mount(group *echo.Group) {
	c.userController.Mount(group.Group("/user"))
}

interfaces/user.go
package interfaces

import (
	"go_api_sample/usecase"
	"net/http"
	"strconv"

	"github.com/labstack/echo/v4"
)

type UserController struct {
	userUsecase usecase.UserUsecase
}

func NewUserController(
	userUsecase usecase.UserUsecase,
) *UserController {
	return &UserController{
		userUsecase: userUsecase,
	}
}

func (c *UserController) Mount(group *echo.Group) {
	group.GET("/:id", c.Show)
	group.POST("/", c.Create)
	group.PUT("/", c.Update)
	group.DELETE("/:id", c.Delete)
}

func (c *UserController) Show(e echo.Context) error {
	id, err := strconv.Atoi(e.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}

	user, err := c.userUsecase.FindUser(id)
	if err != nil {
		return echo.NewHTTPError(http.StatusNotFound, err)
	}

	return e.JSON(http.StatusOK, user)
}

func (c *UserController) Create(e echo.Context) error {
	request := &struct {
		Name string
	}{}
	if err := e.Bind(request); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}

	err := c.userUsecase.CreateUser(
		&usecase.CreateUserInput{
			Name: request.Name,
		},
	)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}

	return e.String(http.StatusOK, "status ok")
}

func (c *UserController) Update(e echo.Context) error {
	request := &struct {
		ID   int
		Name string
	}{}
	if err := e.Bind(request); err != nil {
		return err
	}

	err := c.userUsecase.UpdateUser(
		&usecase.UpdateUserInput{
			ID:   request.ID,
			Name: request.Name,
		},
	)
	if err != nil {
		return err
	}

	return e.String(http.StatusOK, "status ok")
}

func (c *UserController) Delete(e echo.Context) error {
	id, err := strconv.Atoi(e.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}

	if err := c.userUsecase.DeleteUser(id); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}

	return e.String(http.StatusOK, "status ok")
}

レスポンスのステータス内容はテキトーです。

ここはEchoの使い方の話になりますが、POSTやPUTでリクエストされたJSON形式のbodyをEchoを使って受け取るために、リクエストボディのパラメーターを構造体に定義しておき、その構造体をEchoのBindメソッドに入れています。これでEchoがよしなにリクエストボディの値を構造体に割り当ててくれます。

main.go

最後にDB接続、各層で実装したオブジェクトのバインド、サーバー起動について記述していきます。

api/main.go
package main

import (
	"database/sql"
	"fmt"
	"go_api_sample/infrastructure"
	"go_api_sample/interfaces"
	"go_api_sample/usecase"

	"log"
	"os"

	"github.com/joho/godotenv"

	_ "github.com/go-sql-driver/mysql"
	"github.com/labstack/echo/v4"
)

func main() {
	db := dbConnect()

	defer db.Close()

	e := echo.New()

	userRepository := infrastructure.NewUserRepositoryInfrastructure(db)
	userUsecase := usecase.NewUserUsecase(userRepository)
	userController := interfaces.NewUserController(userUsecase)
	controllers := interfaces.NewControllers(userController)
	controllers.Mount(e.Group(""))

	e.Logger.Fatal(e.Start(":1323"))
}

func dbConnect() *sql.DB {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	dbconf := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true",
		os.Getenv("DB_USER"),
		os.Getenv("DB_PASSWORD"),
		os.Getenv("DB_HOST"),
		os.Getenv("DB_PORT"),
		os.Getenv("DB_DATABASE_NAME"),
	)

	db, err := sql.Open("mysql", dbconf)
	if err != nil {
		fmt.Println(err.Error())
	}

	return db
}

dbConnectにDB接続について記述しています。.envにDB接続情報を定義しておき、os.Getenvで呼び出しています。

各層のオブジェクトのバインドですが、オブジェクトが増えてくると記述や管理が大変になるかと思います。そういった場合は、DI管理をしてくれるgoogle/wireといったツールを利用すれば楽になるかと思います。

感想

最後まで読んで頂きありがとうございます!
Go初心者がレイヤードアーキテクチャ+DDDでAPIを実装してみました。
自分で実装してみると、Go言語や設計についての理解が捗りました。
Railsと比べて同じ機能を実装するにしても、中々のコード量を書かないといけないですね、、
とはいえ、MVCの設計はModelにビジネスロジックやDB操作が混在したり、Controllerにビジネスロジックが書かれがちです。ソフトウェアがスケールしていくことを考慮し、Goやレイヤードアーキテクチャを採用することのメリットは大きいと思います。

まだまだ未熟なエンジニアですが、モデリングスキル等も身につけ適切にソフトウェアを設計できるようになりたいといったモチベーションにもなりました!!

参考文献

今すぐ「レイヤードアーキテクチャ+DDD」を理解しよう。(golang)
【必須科目 DI】DIの仕組みをGoで実装して理解する
DDDを実践するための手引き(リポジトリパターン編)

Discussion