282
238

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

DDDを意識しながらレイヤードアーキテクチャとGoでAPIサーバーを構築する

Last updated at Posted at 2020-06-06

今の現場で初めてDDDに触れたので、よく採用されるアーキテクチャとしてレイヤードアーキテクチャを自分で0から実装してみました。
言語もよくセットで採用されているGoを採用してみました。

この記事の目的

  • 0から実装して体系的にDDDとレイヤードアーキテクチャを学ぶ
  • DDDに触れたことがない方にもわかりやすく説明する

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

要約(引用)すると「ドメインの知識に焦点を当てた設計手法」です。

たとえば電子カルテのシステムを例に取ってみます。
電子カルテには患者情報や手術の予定、入院ベッドの空き具合などの概念があると考えられます。
医療関係者ではないソフトウェアエンジニアは実際につかうユーザー(医療関係者)が直面している問題やドメイン(領域)の概念、事象を理解することが必要です。
それらを理解し、ソフトウェアに落とし込む。落とし込み続けることを実践する開発手法です

詳しくはこちらの記事を参照していただければと思います
https://codezine.jp/article/detail/11968

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

スクリーンショット 2020-06-03 8.45.46.png

図のように各層ごとに責務を切り分け、依存の方向を一方向にするようなアーキテクチャです。

純粋なレイヤードアーキテクチャでは上のような図となるのですが、DDDではDIP(依存関係逆転の原則)を用いて下のような依存関係になります。
スクリーンショット 2020-06-03 8.57.55.png

infrastructureがdomainに依存するようになりました。
このようにDDDではdomainを中心にして、その中心に向かって依存するようにしていきます。(これはよく比較されるクリーンアーキテクチャなどでも同様ですね。)

各層の役割

domain

アーキテクチャの中心となるもっとも重要な層です。
業務の関心事が集まっています
ドメインのルールやデータの加工などのビジネスロジックが置かれます。

infrastructure

技術的基盤へのアクセスを提供する層です。
データを永続化するためにDBの操作をしたり、メールを送信するために外部のメール送信サービスとやり取りをしたりします。

usecase

その名の通りユースケースを担当します。
interface層から受け取ったリクエストをもとにデータの参照や保存、削除などを制御します
applicationと呼ばれることもあります。

interface

ユーザーからのリクエストを受け取ったり、usecase層からのレスポンスをユーザーに返す層です。

ソースコード解説

ここからは実際のソースコードを見ながら解説していきます
ソースコードは以下のリンクから取得できます。
https://github.com/ryokky59/go-layered-architecture-sample

dockerをインストールしてあれば動かせるようになっているのでよろしければ動かしながら見てみてください。

domain層

domain層はmodelとrepositoryに分けています。

まず、modelです。
modelにはそのmodelの構造体やビジネスロジックが置かれています。
taskがどういうものかを定義していきます。(今回はコンストラクタとセッターのみ)
理想はこのファイルを見ればtaskがどういうmodelかわかることです。
今回はtitleは何かしら値が必要だということがわかります。

domain/model/task.go
package model

import (
	"errors"
)

// Task taskの構造体
type Task struct {
	ID      int
	Title   string
	Content string
}

// NewTask taskのコンストラクタ
func NewTask(title, content string) (*Task, error) {
	if title == "" {
		return nil, errors.New("titleを入力してください")
	}

	task := &Task{
		Title:   title,
		Content: content,
	}

	return task, nil
}

// Set taskのセッター
func (t *Task) Set(title, content string) (error) {
	if title == "" {
		return errors.New("titleを入力してください")
	}

	t.Title = title
	t.Content = content

	return nil
}

次はrepositoryです。
ここにはDBとのやりとりを定義します(今回はCRUDを定義しています)
domain層には技術的関心事を実装してはいけないというルールがあります。
技術的関心事というのは「DBにMySQLを使って〜」や「ORMを使って〜」などです。
これらをdomain層に記述しないことによってdomain層が特定の技術に依存しないようになります。

なのでここにはCRUDの各メソッドをinterfaceとして定義しておいて、実装が書いてあるinfrastructure層がこのdomain層に依存するようにしてあげます。(この部分が依存関係逆転の原則を適用しているところです)

domain/repository/task.go
package repository

import (
	"sample/domain/model"
)

// TaskRepository task repositoryのinterface
type TaskRepository interface {
	Create(task *model.Task) (*model.Task, error)
	FindByID(id int) (*model.Task, error)
	Update(task *model.Task) (*model.Task, error)
	Delete(task *model.Task) error
}

infrastructure層

特定の技術基盤にアクセスする層です。
ここで先程のrepositoryに記述してあったinterfaceに合わせて、中身のメソッドを実装していきます。
domain層に定義、infrastructure層に実装、と分けることでプロジェクトで使っているDBやORMが変更したとしてもinfrastructure層の実装のみを修正すればいいだけになります。
今回はDBにMySQL、ORMにgormを使っています。

infra/task.go
package infra

import (
	"sample/domain/model"
	"sample/domain/repository"

	"github.com/jinzhu/gorm"
)

// TaskRepository task repositoryの構造体
type TaskRepository struct {
	Conn *gorm.DB
}

// NewTaskRepository task repositoryのコンストラクタ
func NewTaskRepository(conn *gorm.DB) repository.TaskRepository {
	return &TaskRepository{Conn: conn}
}

// Create taskの保存
func (tr *TaskRepository) Create(task *model.Task) (*model.Task, error) {
	if err := tr.Conn.Create(&task).Error; err != nil {
		return nil, err
	}

	return task, nil
}

// FindByID taskをIDで取得
func (tr *TaskRepository) FindByID(id int) (*model.Task, error) {
	task := &model.Task{ID: id}

	if err := tr.Conn.First(&task).Error; err != nil {
		return nil, err
	}

	return task, nil
}

// Update taskの更新
func (tr *TaskRepository) Update(task *model.Task) (*model.Task, error) {
	if err := tr.Conn.Model(&task).Update(&task).Error; err != nil {
		return nil, err
	}

	return task, nil
}

// Delete taskの削除
func (tr *TaskRepository) Delete(task *model.Task) error {
	if err := tr.Conn.Delete(&task).Error; err != nil {
		return err
	}

	return nil
}

usecase層

ここではユースケースに沿った処理の流れを実装します。
データの取得や保存などでDBにアクセスするときもdomain層のrepositoryを介してアクセスすることによって、infrastructure層ではなくdomain層のみに依存させています。

usecase/task.go
package usecase

import (
	"sample/domain/model"
	"sample/domain/repository"
)

// TaskUsecase task usecaseのinterface
type TaskUsecase interface {
	Create(title, content string) (*model.Task, error)
	FindByID(id int) (*model.Task, error)
	Update(id int, title, content string) (*model.Task, error)
	Delete(id int) error
}

type taskUsecase struct {
	taskRepo repository.TaskRepository
}

// NewTaskUsecase task usecaseのコンストラクタ
func NewTaskUsecase(taskRepo repository.TaskRepository) TaskUsecase {
	return &taskUsecase{taskRepo: taskRepo}
}

// Create taskを保存するときのユースケース
func (tu *taskUsecase) Create(title, content string) (*model.Task, error) {
	task, err := model.NewTask(title, content)
	if err != nil {
		return nil, err
	}

	createdTask, err := tu.taskRepo.Create(task)
	if err != nil {
		return nil, err
	}

	return createdTask, nil
}

// FindByID taskをIDで取得するときのユースケース
func (tu *taskUsecase) FindByID(id int) (*model.Task, error) {
	foundTask, err := tu.taskRepo.FindByID(id)
	if err != nil {
		return nil, err
	}

	return foundTask, nil
}

// Update taskを更新するときのユースケース
func (tu *taskUsecase) Update(id int, title, content string) (*model.Task, error) {
	targetTask, err := tu.taskRepo.FindByID(id)
	if err != nil {
		return nil, err
	}

	err = targetTask.Set(title, content)
	if err != nil {
		return nil, err
	}

	updatedTask, err := tu.taskRepo.Update(targetTask)
	if err != nil {
		return nil, err
	}

	return updatedTask, nil
}

// Delete taskを削除するときのユースケース
func (tu *taskUsecase) Delete(id int) error {
	task, err := tu.taskRepo.FindByID(id)
	if err != nil {
		return err
	}

	err = tu.taskRepo.Delete(task)
	if err != nil {
		return err
	}

	return nil
}

interface層

リクエスト、レスポンスを取り扱う層です。
usecase層と切り離すことでリクエストやレスポンスがどんな形に変わってもここの修正だけで済むようになります。
今回はHTTPリクエストを受け取り、レスポンスをjsonとして返すようにしています。

interface/handler/task.go
package handler

import (
	"net/http"
	"strconv"

	"sample/usecase"

	"github.com/labstack/echo"
)

// TaskHandler task handlerのinterface
type TaskHandler interface {
	Post() echo.HandlerFunc
	Get() echo.HandlerFunc
	Put() echo.HandlerFunc
	Delete() echo.HandlerFunc
}

type taskHandler struct {
	taskUsecase usecase.TaskUsecase
}

// NewTaskHandler task handlerのコンストラクタ
func NewTaskHandler(taskUsecase usecase.TaskUsecase) TaskHandler {
	return &taskHandler{taskUsecase: taskUsecase}
}

type requestTask struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

type responseTask struct {
	ID      int    `json:"id"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

// Post taskを保存するときのハンドラー
func (th *taskHandler) Post() echo.HandlerFunc {
	return func(c echo.Context) error {
		var req requestTask
		if err := c.Bind(&req); err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		createdTask, err := th.taskUsecase.Create(req.Title, req.Content)
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		res := responseTask{
			ID:      createdTask.ID,
			Title:   createdTask.Title,
			Content: createdTask.Content,
		}

		return c.JSON(http.StatusCreated, res)
	}
}

// Get taskを取得するときのハンドラー
func (th *taskHandler) Get() echo.HandlerFunc {
	return func(c echo.Context) error {
		id, err := strconv.Atoi((c.Param("id")))
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		foundTask, err := th.taskUsecase.FindByID(id)
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		res := responseTask{
			ID:      foundTask.ID,
			Title:   foundTask.Title,
			Content: foundTask.Content,
		}

		return c.JSON(http.StatusOK, res)
	}
}

// Put taskを更新するときのハンドラー
func (th *taskHandler) Put() echo.HandlerFunc {
	return func(c echo.Context) error {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		var req requestTask
		if err := c.Bind(&req); err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		updatedTask, err := th.taskUsecase.Update(id, req.Title, req.Content)
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		res := responseTask{
			ID:      updatedTask.ID,
			Title:   updatedTask.Title,
			Content: updatedTask.Content,
		}

		return c.JSON(http.StatusOK, res)
	}
}

// Delete taskを削除するときのハンドラー
func (th *taskHandler) Delete() echo.HandlerFunc {
	return func(c echo.Context) error {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		err = th.taskUsecase.Delete(id)
		if err != nil {
			return c.JSON(http.StatusBadRequest, err.Error())
		}

		return c.NoContent(http.StatusNoContent)
	}
}

DI

各層の説明は先程のinterface層までで終わりです。
最後にDI(依存性の注入)しているところを説明します。
今回はmain.goにDIポイントを記述しました。

main関数内の上から3行目までのところでDIを行っています。
ここで各層に記述されていたコンストラクタ(New〇〇といった名前の関数)が使われます。

api/main.go
package main

import (
	"sample/config"
	"sample/infra"
	"sample/interface/handler"
	"sample/usecase"

	_ "github.com/jinzhu/gorm/dialects/mysql"
	"github.com/labstack/echo"
)

func main() {
	taskRepository := infra.NewTaskRepository(config.NewDB())
	taskUsecase := usecase.NewTaskUsecase(taskRepository)
	taskHandler := handler.NewTaskHandler(taskUsecase)

	e := echo.New()
	handler.InitRouting(e, taskHandler)
	e.Logger.Fatal(e.Start(":8080"))
}

さいごに

ここまで読んでいただきありがとうございます。
普段なにげなく実装していますが、人に説明するのはとても大変だなと書いてて思いました笑
ただ、記事にしたおかげで自分の中で理解がとても深まったので良かったです。

まだDDDには値オブジェクトだったりファクトリだったり色んな要素があるので気になった方はぜひ調べてみてください(もしかしたらまた記事にするかもしれません)

今回使ったソースコードのサンプルはこちらになります。
https://github.com/ryokky59/go-layered-architecture-sample

参考文献

【Golang + レイヤードアーキテクチャー】DDD を意識して Web API を実装してみる

今すぐ「レイヤードアーキテクチャ+DDD」を理解しよう。(golang)

Go言語でClean Architectureを実現して、gomockでテストしてみた

282
238
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
282
238

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?