Go初心者がレイヤードアーキテクチャ+DDDでAPIを実装してみる
はじめに
この記事は実務で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の振る舞いを作っていきます。
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といった基本的な操作を定義しています。
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操作をする基本的なクエリを記述しています。
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にgormやsqlboilerなどがあります。)
usecase層
usecase層にアプリケーションのユースケースを記述していきます。
repositoryからUserのデータの取得や更新をする処理を呼び出し、Userを取得、作成、更新、削除するといったユースケースを記述していきます。
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を実装していきます。
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"))
}
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接続、各層で実装したオブジェクトのバインド、サーバー起動について記述していきます。
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