8
4

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 5 years have passed since last update.

Go で Clean Architecture を実践する

Last updated at Posted at 2019-09-13

概要

手探りしながら、 Golang でクリーンアーキテクチャを実装しました。
今後のために残しておきます。こうしたほうがいいな、って思ったら適宜更新していきたい。

ツッコミや、個人的にはこう思う、みたいなの大歓迎です。

クリーンアーキテクチャとは

ここで新しく説明するよりも、このQiitaを読んだほうが良いので割愛します。

この図だけないとイメージしづらいので持ってきます。
image.png

実践

パッケージ・ディレクトリ構造

肝は依存関係をコントロールすることにあるので、円毎にディレクトリを用意しました。
また、usecase は単独で切り出し、中心に考えます。

.
├── adapters        # 緑色
├── app             # 赤色
├── domain          # 黄色
├── external        # 青色
└── usecases        # 赤色(定義)

これを元に、ファイルを順番に作っていきます。

ユースケースの抽出

今回はよく見かける「ユーザ登録」を例に考えていきます。
この時点では処理の内容を深堀りする必要はないので、大雑把なパラメタと名前だけ決めます。

ユースケース名 処理内容
サインアップ メールアドレスとパスワードを受け取ってユーザを登録する

決まったら、usecases 配下に interface を配置します。

.
├── adapters
├── app
├── domain
├── external
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。
UseCase の Interface と、Input / Output を定義しておきます。

usecases/sign_up_usecase.go
package usecases

type ISignUpUseCase interface {
        SignUp(input SignUpUseCaseInput) (output *SignUpUseCaseOutput, err error)
}

type SignUpUseCaseInput struct {
        Email    string
        Password string
}

type SignUpUseCaseOutput struct {
}

UseCase内の処理を考える

サインアップ内で何をするか考えます。一旦はシンプルに以下とします。

  • メールアドレスが他のユーザに登録されていないかチェックする
  • ユーザを保存する

ドメインを定義する

~~ドメインがよくわかってないので、~~処理内容から、とりあえず重要そうなモノを取り出します。

  • ユーザを保存する

これをドメインとして定義します。

.
├── adapters
├── app
├── domain
│   └── user
│       └── user.go
├── external
└── usecases
    └── sign_up_usecase.go

ドメインはこのアプリケーションにとって最重要なものであるので、パッケージを専用で切ってみます。
ソースコードはこちら。

domain/user/user.go
package user

type User struct {
        Email    string
        Password string
}

UseCaseの実装準備

UseCaseの実装をしていきます。赤色の実装は app の下ですね。 実装として、 Interactor を用意します。

.
├── adapters
├── app
│   └── interactors
│       └── sign_up_interactor.go
├── domain
│   └── user
│       └── user.go
├── external
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。

app/interactors/sign_up_interactor.go
package interactors

import (
        "errors"
        "github.com/sat8bit/golang-clean-architecture/usecases"
)

type SignUpInteractor struct {
}

func NewSignUpInteractor() SignUpInteractor {
        return SignUpInteractor{}
}

func (i SignUpInteractor) SignUp(input usecases.SignUpUseCaseInput) (output *usecases.SignUpUseCaseOutput, err error) {
        return nil, errors.New("not implemented")
}

NewSignUpInteractor() の返却型は ISignUpUseCase であるべきな気もしていて、今絶賛悩み中です。
現状は、ISignUpUseCase の実装が SignUpInteractor である、みたいな表現は DI 側で表現しています。

話ついでにDIも追加しておきます。

.
├── adapters
├── app
│   └── interactors
│       └── sign_up_interactor.go
├── di
│   └── di.go
├── domain
│   └── user
│       └── user.go
├── external
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。
今の時点では Injection も何もありません。

di/di.go
package di

import (
        "github.com/sat8bit/golang-clean-architecture/app/interactors"
        "github.com/sat8bit/golang-clean-architecture/usecases"
)

func SignUpUseCase() usecases.ISignUpUseCase {
        return interactors.NewSignUpInteractor()
}

動作させたい!

UseCase を実行するだけの main を作っておきましょう。
今回はmainを作りますが、実際はUseCaseのテストコードを書けばよいです。

.
├── adapters
├── app
│   └── interactors
│       └── sign_up_interactor.go
├── di
│   └── di.go
├── domain
│   └── user
│       └── user.go
├── external
├── main
│   └── main.go
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。
既存登録チェックがあるので、2回サインアップしてみるコードにします。

main/main.go
package main

import (
        "github.com/sat8bit/golang-clean-architecture/di"
        "github.com/sat8bit/golang-clean-architecture/usecases"
        "log"
)

func main() {
        u := di.SignUpUseCase()
        input := usecases.SignUpUseCaseInput{Email: "example@mail.address.com", Password: "abcd1234"}

        if _, err := u.SignUp(input); err != nil {
                log.Printf("Error is %s", err)
        } else {
                log.Print("SignUp succeeded.")
        }

        if _, err := u.SignUp(input); err != nil {
                log.Printf("Error is %s", err)
        } else {
                log.Print("SignUp succeeded.")
        }
}

実行してみます。

sat8bit $ go run main/main.go 
2019/09/13 21:58:15 Error is not implemented
2019/09/13 21:58:15 Error is not implemented

SignUpInteractor の実装

実装を予定している2つの処理は、何れも永続化データの操作になるので、UserRepository の準備を進めます。

UserRepository は緑の Gateway に位置するのですが、依存関係を緑→赤に保つために、まずは定義を app の内部で完結させます。

.
├── adapters
├── app
│   ├── gateways
│   │   └── user_repository.go
│   └── interactors
│       └── sign_up_interactor.go
├── di
│   └── di.go
├── domain
│   └── user
│       └── user.go
├── external
├── main
│   └── main.go
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。
気をつけたいのは、UseCaseから見たときにどう使いたいかを定義します。

app/gateways/user_repository.go
package gateways

import (
        "github.com/sat8bit/golang-clean-architecture/domain/user"
)

type IUserRepository interface {
        // Email からユーザを検索したい
        FindByEmail(email string) (user *user.User, err error)
        // User を保存したい
        Save(user user.User) error
}

Interface が定義できたので、 SignUpInteractor に依存と実装を追加します。

ソースコードはこちら。

app/interactors/sign_up_interactor.go
package interactors

import (
        "errors"
        "github.com/sat8bit/golang-clean-architecture/app/gateways"
        "github.com/sat8bit/golang-clean-architecture/domain/user"
        "github.com/sat8bit/golang-clean-architecture/usecases"
)

type SignUpInteractor struct {
        userRepository gateways.IUserRepository
}

func NewSignUpInteractor(
        userRepository gateways.IUserRepository,
) SignUpInteractor {
        return SignUpInteractor{
                userRepository: userRepository,
        }
}

func (i SignUpInteractor) SignUp(input usecases.SignUpUseCaseInput) (output *usecases.SignUpUseCaseOutput, err error) {
        if user, _ := i.userRepository.FindByEmail(input.Email); user != nil {
                return nil, errors.New("email already exists")
        }

        user := user.User{
                Email:    input.Email,
                Password: input.Password,
        }

        if err := i.userRepository.Save(user); err != nil {
                return nil, err
        }

        return &usecases.SignUpUseCaseOutput{}, nil
}

これで、赤の円まで完成です。
SignUpInteractorが、 app 配下と domain 配下にしか依存してないことがポイントです。
これによって、赤の円より外側、すなわちインターフェースやフレームワークの変更からビジネスロジックを守ることができます。

ちなみに、この時点だとDIが解決できないので動かせません。
次のリポジトリの実装まで我慢。

UserRepository の実装

今回はサンプルなので、変数にオンメモリで保持する UserRepository を実装します。
Gateway は緑の円になるので、 adapters 配下に用意します。

.
├── adapters
│   └── gateways
│       └── user_repository.go
├── app
│   ├── gateways
│   │   └── user_repository.go
│   └── interactors
│       └── sign_up_interactor.go
├── di
│   └── di.go
├── domain
│   └── user
│       └── user.go
├── external
├── main
│   └── main.go
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。
あくまで動作させるためのサンプルなので、スレッドセーフでないとか、毎回Newしたらstore共有されないとかそういうのは一旦置いておいていただけると・・・

adapters/gateways/user_repository.go
package gateways

import (
        "errors"
        "github.com/sat8bit/golang-clean-architecture/domain/user"
)

type UserRepository struct {
        store map[string]user.User
}

func NewUserRepository() UserRepository {
        return UserRepository{
                store: map[string]user.User{},
        }
}

func (r UserRepository) FindByEmail(email string) (user *user.User, err error) {
        if u, ok := r.store[email]; ok {
                return &u, nil
        }
        return nil, errors.New("user not already exists")
}

func (r UserRepository) Save(user user.User) error {
        if _, ok := r.store[user.Email]; ok {
                return errors.New("email already exists.")
        }

        r.store[user.Email] = user
        return nil
}

続いてDIも修正します。
パッケージ名が gateways で被るので、Interface の方に別名をつけています。

di/di.go
package di

import (
        "github.com/sat8bit/golang-clean-architecture/adapters/gateways"
        gwif "github.com/sat8bit/golang-clean-architecture/app/gateways"
        "github.com/sat8bit/golang-clean-architecture/app/interactors"
        "github.com/sat8bit/golang-clean-architecture/usecases"
)

func SignUpUseCase() usecases.ISignUpUseCase {
        return interactors.NewSignUpInteractor(
                UserRepository(),
        )
}

func UserRepository() gwif.IUserRepository {
        return gateways.NewUserRepository()
}

起動してみましょう。

sat8bit $ go run main/main.go 
2019/09/13 22:01:35 SignUp succeeded.
2019/09/13 22:01:35 Error is email already exists

うまく動いたようですね。

WebAPIにする

最後に、httpで叩けるように Controller を作成します。
Controller も緑の円になるので、 adapters 配下に用意します。

.
├── adapters
│   ├── controllers
│   │   └── sign_up_controller.go
│   └── gateways
│       └── user_repository.go
├── app
│   ├── gateways
│   │   └── user_repository.go
│   └── interactors
│       └── sign_up_interactor.go
├── di
│   └── di.go
├── domain
│   └── user
│       └── user.go
├── external
├── main
│   └── main.go
└── usecases
    └── sign_up_usecase.go

ソースコードはこちら。
ここも最低限の動作をさせる参考程度です。

adapters/controllers/sign_up_controller.go
package controllers

import (
        "encoding/json"
        "fmt"
        "github.com/sat8bit/golang-clean-architecture/usecases"
        "io"
        "net/http"
)

func NewSignUpController(signUpUseCase usecases.ISignUpUseCase) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                if r.Method != http.MethodPost {
                        w.WriteHeader(http.StatusMethodNotAllowed)
                        return
                }

                body := make([]byte, r.ContentLength)
                length, err := r.Body.Read(body)
                if err != nil && err != io.EOF {
                        w.WriteHeader(http.StatusBadRequest)
                        return
                }

                var j map[string]string
                err = json.Unmarshal(body[:length], &j)
                if err != nil {
                        w.WriteHeader(http.StatusBadRequest)
                        return
                }

                _, err = signUpUseCase.SignUp(usecases.SignUpUseCaseInput{
                        Email:    j["email"],
                        Password: j["password"],
                })

                if err != nil {
                        w.WriteHeader(http.StatusInternalServerError)
                        w.Write([]byte(fmt.Sprintf("%s", err)))
                        return
                }

                w.WriteHeader(http.StatusOK)
        }
}

DI も書く。

di/di.go
package di

import (
        "github.com/sat8bit/golang-clean-architecture/adapters/controllers"
        "github.com/sat8bit/golang-clean-architecture/adapters/gateways"
        gwif "github.com/sat8bit/golang-clean-architecture/app/gateways"
        "github.com/sat8bit/golang-clean-architecture/app/interactors"
        "github.com/sat8bit/golang-clean-architecture/usecases"
        "net/http"
)

func SignUpController() http.HandlerFunc {
        return controllers.NewSignUpController(
                SignUpUseCase(),
        )
}

func SignUpUseCase() usecases.ISignUpUseCase {
        return interactors.NewSignUpInteractor(
                UserRepository(),
        )
}

func UserRepository() gwif.IUserRepository {
        return gateways.NewUserRepository()
}

main も差し替え。

main/main.go
package main

import (
        "github.com/sat8bit/golang-clean-architecture/di"
        "net/http"
)

func main() {
        c := di.SignUpController()
        http.Handle("/signUp", c)
        http.ListenAndServe(":8080", nil)
}

まとめ

以上の実装で、以下のように整理できました。

image.png

8
4
1

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
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?