Goと50%くらいの理解ではじめるクリーンというかオニオンなアーキテクチャ
積読していたClean Architecture本を読了したのですが、いまいち実践的なイメージ湧かなかったため、オニオンアーキテクチャを実際にGoで実装したという話です。
想定読者
- クリーンアーキテクチャを学習したけどいまいちピンとこなかった人
- Goでレイヤードアーキテクチャを実装したい人
- オニオンアーキテクチャについて知りたい人
本記事で説明しないこと
- DDDについても少し触れる予定ですが、DDDの詳細については説明しません
- クリーンアーキテクチャの詳細
クリーンアーキテクチャがなぜピンとこないのか
個人的な感想です。
同心円の話になりがち
実際Clean Architecture本の中で、同心円を用いたアーキテクチャの話は数ページのみです。実際のプロジェクトを作成しようとすると、具体的なディレクトリ構成やパッケージの構成の話になりがちなためかと思いますが少なくともClean Architecture本で説明されているSOLID原則やコンポーネントの安定度と抽象度の話とかと同心円のアーキテクチャの話など含めてクリーンアーキテクチャという概念です。(著者はそう理解しました。)
そして、上記の同心円で重要なことは以下の2点であるとよく言われています。
- 依存関係は外側から内側への一方向のみに向かう
- 内側から外側への依存は依存関係逆転の原則にしたがうことで実現する
そのため、同心円のような4層のレイヤー構造でなくとも良いと書かれていますし、その名称などもそこまで重要ではと推察できます。
既にいろんなところで言われていますがあまり同心円にこだわりすぎない方がクリーンアーキテクチャを活用していけるのではないかなと思いました。
名称に惑わされる
上述した同心円の中心にはエンティティが存在しますが、DDD(ドメイン駆動開発)の文脈でもエンティティという名称が使われています。この二つはドメインを表現するという点ではまったくの別物でもない気がするのですがクリーンアーキテクチャとDDDとでやはり説明している概念が違うため、混同させると混乱してしまうかも知れません。(わたしはしました。)
加えて、わたしはJVM系での開発をいままでしたきたため、フレームワークとしてSpringを使用してきたのですが、その中でもEntityやRepository、Serviceという用語を多用することになりますが、これらもまたクリーンアーキテクチャやDDDで言われている用語とは異なる意味合いを持っています。
もしかしたら、Ruby on Railsなどのフレームワークを使用してきた方ですとMVCアーキテクチャと比較し、混同してしまう方もいるかもしれません。
もし、クリーンアーキテクチャを学習する上で他のアーキテクチャと混同し混乱してきたら名称は同じだが説明しているものは違うかも知れないということを意識すると理解が進むかも知れません。
以下の記事はアーキテクチャとDDDについてとてもわかりやすく解説されているので、興味がある方はぜひ読んでみてください。
具体的な実装例がない
少なくとも本書にはないです。調べればいくつか見つかりますがどれもディレクトリ・パッケージ構成が微妙に違いますし、名称もそれぞれで言語やフレームワークによる差異も出てきます。
また、SpringみたいなDIフレームワークを使用するならあまり考えなくてもいいのですがGoなどで実装しようとすると自分でDIコンテナを実装する必要があるので、そこの実装イメージも湧かないと全体的な実装イメージが湧かないと思います。
以下の記事はGoのクリーンアーキテクチャの実装例をまとめてくれているものです。
なので、いざクリーンアーキテクチャの学習を終えて、実装してみようとなったときに困惑します。(わたしはしました。)
繰り返しになりますが、クリーンアーキテクチャというかあの同心円が一番伝えたいことはおそらく概念的な話で名称や形はそれほど重要ではないため一番ピンときた構成でやるのがいいと思っています。
そこでオニオンアーキテクチャ
クリーンアーキテクチャを学んでピンときた方はそのままクリーンアーキテクチャを使用すればいいと思います。ただ、もしわたしと同じようになんとなく理解したけど具体的な実装イメージが湧かないという方はオニオンアーキテクチャの方がピンとくるかもしれません。
オニオンアーキテクチャとは
2008年にJeffery Palermoが以下の記事で提唱したアーキテクチャです。英語ですが文量はそこまで多くないので興味がある方は読んでみるとおもしろいと思います。
以下の記事が大変わかりやすいのでこちらを参照。
オニオンアーキテクチャだと何がうれしいのか
これもいろんなところで言われていますがオニオンアーキテクチャもクリーンアーキテクチャも基本的な概念は一緒です。どちらも目的は関心ごとの分離です。
一応、上記オニオンアーキテクチャについて書かれたブログ記事内で説明されているオニオンアーキテクチャの定義的なもの。
Key tenets of Onion Architecture:
- The application is built around an independent object model
- Inner layers define interfaces. Outer layers implement interfaces
- Direction of coupling is toward the center
- All application core code can be compiled and run separate from infrastructure
オニオン・アーキテクチャの主要な考え方:
- アプリケーションは、独立したオブジェクト・モデルを中心に構築される
- 内側のレイヤーはインターフェースを定義します。外側のレイヤーはインターフェースを実装する
- 結合の方向は中心に向かっている
- すべてのアプリケーションのコアコードは、インフラストラクチャとは別にコンパイルして実行することができる
オニオンアーキテクチャの図にあるレイヤーを見てみると一番外側にtests
とinfrastructure
、user interface
があります。testとDBなどの具体的な実装が含まれるinfrastructure層が一番外側にあるのは理解できるでしょう。
次にApplication Services
とDomain(Object) Services
レイヤーがあり、ぱっと見違いがわかりませんが図の例にRepositoryインターフェイスがDomainServicesレイヤーにあるのをみるとDB実装のインターフェイスをDomain Serviceレイヤーに置けばとりあえず良さそうです。
Application ServiceにはDomain Serviceを使用し適切にドメイン操作の取りまとめとトランザクションなどの処理を実施すればよさそうです。Spring経験者の方であればいわゆるServiceアノテーションを付けるクラスで伝わるでしょう。
中心のDomain(Object) Model
はアプリケーションのコアとなるビジネスモデル的概念がここに当てはまるでしょう。この部分は最もアプリケーションに影響のあるレイヤーのため何にも依存していません。
となるとControllerと呼ばれる部分の実装はどこやねんとなるのですが、オニオンアーキテクチャの図を見てみると一番外側のuser interfaceレイヤに置かれることになります。Controllerの実装がinfrastructureと同じ一番外側にあるのに違和感を感じましたが、オニオンアーキテクチャが提唱されている記事内では
CodeCampServerはASP.NET MVC Frameworkを使用しているので、SpeakerControllerはユーザーインターフェイスの一部となります。 このコントローラはASP.NET MVC Frameworkと結合しており、これを回避することはできない。(日本語訳)
とあります。Controllerの実装はフレームワークに強く依存しているので一番外側にあるということですね。これはSpringやRuby on Railsといったフレームワークでも同じことが言えるでしょう。
どうでしょう、クリーンアーキテクチャの同心円より実装のイメージがつきませんか?前述したQiitaの記事にもありましたが個人的にはクリーンアーキテクチャのUse Cases
レイヤとInterface Adapters
レイヤに何をどこに置いたらいいのかが結構ひとそれぞれな感がするのと名称もPresenters
やController
といったものもありディレクトリ名称もプロジェクトによって変わるのでこれが正解だよみたいなのがないのがわかりづらいと思ってます。
オニオンアーキテクチャもこれが正解ですみたいなのは当然ないのですが、まだクリーンアーキテクチャよりは選択肢が少なくわかりやすいかなということです。
実装してみる
Goで簡単なTodo APIをオニオンアーキテクチャで作成します。
作成した成果物はこちら
ディレクトリ構成は以下のような感じになりました。詳しくは後述します。
.
├── README.md
├── application
│ └── service
├── common
│ ├── todo.go
│ └── user.go
├── domain
│ ├── model
│ └── repository
├── go.mod
├── go.sum
├── go_onion_architecture.db
├── infrastructure
│ ├── db.go
│ └── repository
├── main.go
├── mocks
│ ├── mock_repository
│ ├── registory
│ └── service
├── registory
│ └── registory.go
├── test
│ └── container.go
├── testdata
│ └── golden
└── userinterface
├── echo.go
├── handler
├── request
└── response
domain.model
オニオンアーキテクチャの図の最も中心のDomain Model
の部分です。今回はシンプルに以下のようなモデルを作成しました。
package model
import "time"
type TodoId int64
type Title string
type Description string
type Todo struct {
Id TodoId `json:"id"`
Title Title `json:"title"`
Description Description `json:"description"`
CreatedAt time.Time `json:"created_at"`
DeleteAt time.Time `json:"delete_at"`
}
これがアプリケーションのコアとなるビジネスモデルになります。オニオンアーキテクチャの図の最も中心の概念のためどこにも依存しておらず、他のレイヤから依存されることになる部分です。
そのため、他のレイヤの変更の影響を受けず、逆にこのモデルの変更は他の全ての依存レイヤに影響をあたえることになります。
domain.repository
ここはDomain Services
レイヤです。レイヤの名称はServiceですがRepositoryという名称に馴染みがあるのでdomainディレクトリの配下にrepositoryというディレクトリを作成し以下のようなインターフェイスを配置しました。
package repository
import "github.com/JY8752/go-onion-architecture-sample/domain/model"
type TodoRepository interface {
Create(model.UserId, model.Title, model.Description) (model.TodoId, error)
List(model.UserId) ([]model.Todo, error)
Delete(model.TodoId) error
}
このレイヤにはSQLなどの具体的な実装を知ってはいけないのでインターフェイスのみを配置します。
application.service
ここはApplication Services
レイヤになります。Repositoryインターフェイスを使用してビジネスモデルの永続化や取得などを実施します。今回の例ではほとんどロジック的なものはありませんがここにサービスロジック的なものがくる想定です。
package service
import (
"github.com/JY8752/go-onion-architecture-sample/domain/model"
"github.com/JY8752/go-onion-architecture-sample/domain/repository"
)
type TodoService interface {
Create(model.UserId, model.Title, model.Description) (model.TodoId, error)
List(model.UserId) ([]model.Todo, error)
Delete(model.TodoId) error
}
type todoService struct {
todoRep repository.TodoRepository
}
func NewTodoService(todoRep repository.TodoRepository) TodoService {
return &todoService{
todoRep: todoRep,
}
}
func (t *todoService) Create(userId model.UserId, title model.Title, description model.Description) (model.TodoId, error) {
return t.todoRep.Create(userId, title, description)
}
func (t *todoService) List(userId model.UserId) ([]model.Todo, error) {
return t.todoRep.List(userId)
}
func (t *todoService) Delete(todoId model.TodoId) error {
return t.todoRep.Delete(todoId)
}
このServiceもインターフェイスと実装があり、配置場所に悩んだのですが今回は同じレイヤに配置しました。オニオンアーキテクチャでは外側のレイヤに実装、内側にインターフェイスというポイントがあるので内側のDomain Serviceレイヤにインターフェイスを配置してもいいのかもしれません。
infrastructure
一番外側のレイヤでDBの具体的な詳細を実装する場所です。今回はsqlite3を使用して実装しました。
package infrastructure
import (
"log"
"time"
"github.com/JY8752/go-onion-architecture-sample/domain/model"
"github.com/JY8752/go-onion-architecture-sample/domain/repository"
db "github.com/JY8752/go-onion-architecture-sample/infrastructure"
)
type todoRepository struct {
dbClient *db.DBClient
}
func NewTodoRepository(db *db.DBClient) repository.TodoRepository {
return &todoRepository{
dbClient: db,
}
}
func (t *todoRepository) Create(userId model.UserId, title model.Title, description model.Description) (model.TodoId, error) {
stmt, err := t.dbClient.Client.Prepare("INSERT INTO todos (user_id, title, description, created_at) VALUES (?, ?, ?, ?)")
if err != nil {
return 0, err
}
result, err := stmt.Exec(userId, title, description, time.Now())
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
if err != nil {
return 0, err
}
return model.TodoId(id), nil
}
func (t *todoRepository) List(id model.UserId) ([]model.Todo, error) {
stmt, err := t.dbClient.Client.Prepare("SELECT id, title, description, created_at FROM todos WHERE user_id = ? AND delete_at IS NULL")
if err != nil {
return nil, err
}
rows, err := stmt.Query(id)
if err != nil {
return nil, err
}
var todos []model.Todo
for rows.Next() {
var todo model.Todo
err = rows.Scan(&todo.Id, &todo.Title, &todo.Description, &todo.CreatedAt)
if err != nil {
log.Printf("err: %s\n", err.Error())
continue
}
todos = append(todos, todo)
}
return todos, nil
}
func (t *todoRepository) Delete(id model.TodoId) error {
stmt, err := t.dbClient.Client.Prepare("UPDATE todos SET delete_at = ? WHERE id = ?")
if err != nil {
return err
}
_, err = stmt.Exec(time.Now(), id)
if err != nil {
return err
}
return nil
}
もしDBをsqlite3からMongoに変更だったり、使用するORMを変更することになった場合にこのinfrastructureの実装をまるっと作り替えるだけですむように意識して実装するといいと思います。
user interface
いわゆるControllerとして実装される処理です。今回はecho
を使用して実装しました。
package handler
import (
"log"
"github.com/JY8752/go-onion-architecture-sample/common"
"github.com/JY8752/go-onion-architecture-sample/domain/model"
"github.com/JY8752/go-onion-architecture-sample/registory"
"github.com/JY8752/go-onion-architecture-sample/userinterface/request"
"github.com/JY8752/go-onion-architecture-sample/userinterface/response"
"github.com/labstack/echo/v4"
)
func TodoHandler(client *echo.Echo, registory registory.Registory) {
client.POST("/:userId/todos", func(c echo.Context) error {
// バリデーション
_, err := common.GetUserId(c.Param("userId"))
if err != nil {
return err
}
var r request.CreateTodoRequest
if err := c.Bind(&r); err != nil {
return err
}
id, err := registory.TodoService().Create(
model.UserId(r.UserId),
model.Title(r.Title),
model.Description(r.Description),
)
if err != nil {
return err
}
return c.JSON(200, response.CreateTodoResponse{Id: id})
})
client.GET("/:userId/todos", func(c echo.Context) error {
userId, err := common.GetUserId(c.Param("userId"))
if err != nil {
return err
}
todos, err := registory.TodoService().List(userId)
if err != nil {
log.Printf("err: %s\n", err.Error())
return c.JSON(404, []model.Todo{})
}
return c.JSON(200, response.GetTodosResponse{Todos: todos})
})
client.DELETE("/todos/:id", func(c echo.Context) error {
todoId, err := common.GetTodoId(c.Param("id"))
if err != nil {
return err
}
err = registory.TodoService().Delete(todoId)
if err != nil {
return err
}
return c.NoContent(204)
})
}
ここが一番悩んだんですがなるべくechoを切り捨てやすくしたかったのですが、どうしてもechoの実装に依存してしまうので結果的にこのような実装になりましたがもっといい感じの実装があると思います、たぶん。
registory
ここはオニオンアーキテクチャは関係ないのですが、echoのhandler関数からService -> Repositoryと呼び出していくのに依存関係の注入を行う必要があり、そのためのDIコンテナの実装です。
Springなどのフレームワークならばフレームワーク側がいい感じにやってくれますがGoの場合自作するかwireなどのモジュールを使用する必要があります。
package registory
import (
service "github.com/JY8752/go-onion-architecture-sample/application/service"
repository "github.com/JY8752/go-onion-architecture-sample/domain/repository"
db "github.com/JY8752/go-onion-architecture-sample/infrastructure"
infrastructure "github.com/JY8752/go-onion-architecture-sample/infrastructure/repository"
)
type Registory interface {
UserRep() repository.UserRepository
UserService() service.UserService
TodoRep() repository.TodoRepository
TodoService() service.TodoService
}
type registory struct {
dbClient *db.DBClient
}
func NewRegistory(db *db.DBClient) Registory {
return ®istory{
dbClient: db,
}
}
func (r *registory) UserRep() repository.UserRepository {
return infrastructure.NewUserRepository(r.dbClient)
}
func (r *registory) UserService() service.UserService {
return service.NewUserService(r.UserRep())
}
func (r *registory) TodoRep() repository.TodoRepository {
return infrastructure.NewTodoRepository(r.dbClient)
}
func (r *registory) TodoService() service.TodoService {
return service.NewTodoService(r.TodoRep())
}
registoryの実装はアーキテクチャの一番外側もしくは円の外側から全てのレイヤに依存しているイメージで大丈夫だと思います。実装は以下の記事を参考にさせていただきました。
main
これでだいたい実装は完了です。最後にmain関数は以下のようになりました。
package main
import (
db "github.com/JY8752/go-onion-architecture-sample/infrastructure"
"github.com/JY8752/go-onion-architecture-sample/registory"
ui "github.com/JY8752/go-onion-architecture-sample/userinterface"
)
func main() {
// db
db := db.NewDBClient("./go_onion_architecture.db")
defer db.Client.Close()
// registory
registory := registory.NewRegistory(db)
// echo
apiClient := ui.NewApiClient(registory)
apiClient.RegisterRoute()
apiClient.Start()
}
その他
今回、共通処理的なものをcommon
ディレクトリを作成し配置しましたが、このようなユーティリティは例外的に一番外側のレイヤにしました。
ただ、呼び出し側が全てこのユーティリティに依存することになるのとそもそもユーティリティを作るか作らないかみたいな話になりそうなのでこれも適切ではないかもしれません。
Clean Architecture本にはこのようなユーティリティはあらゆる箇所から呼ばれる可能性があるため安定度が高く、抽象度は低く、変更がされにくいコンポーネントであると書かれています。
もしかしたら、置くにしても中心のdomainレイヤに置く方が適切かもしれません。
あとは、config系や定数、キャッシュなどをどこに置くかみたいな話が実プロジェクトでは出てきそうですが基本的にはアプリケーションのロジックとは無関係で変更の可能性があるものは外側に、そうでなければ内側に置くような意識で実装すればいいと思います。
テストを書く
クリーンアーキテクチャやオニオンアーキテクチャのメリットとしてそれぞれのレイヤでテストが書きやすくなるといった点があるのでテストも書いていきます。
Clean Architecture本に「テストもシステムの一部であり、テスト対象の実装に強く依存している」とあります。テストが実装に依存していれば当然、実装に変更があった場合にテストも影響を受けるため修正する必要がでてきます。
テストがすぐ壊れると言われるのはこのような理由からでしょう。そのため、テストを壊れにくくするためになるべく実装に依存しないようにする方がいいと著者は思っています。つまり、mockを使おうねということです。
mockを使う使わないは意見がわかれるところでもあると思いますが、著者は上記のような理由から他のレイヤへの依存関係はmockにし、そのレイヤの責務にのみに焦点を当てて単体テストを書くことにしています。
infrastructureのテスト
今回はsqlite3を使用しているので、インメモリのDBをテスト用に起動しテストを実行します。
package infrastructure_test
import (
"os"
"testing"
"github.com/JY8752/go-onion-architecture-sample/domain/model"
"github.com/JY8752/go-onion-architecture-sample/domain/repository"
db "github.com/JY8752/go-onion-architecture-sample/infrastructure"
infrastructure "github.com/JY8752/go-onion-architecture-sample/infrastructure/repository"
"github.com/stretchr/testify/assert"
)
var todoRep repository.TodoRepository
func TestMain(m *testing.M) {
d := db.NewDBClient("file:infrastructure_test_db?mode=memory")
todoRep = infrastructure.NewTodoRepository(d)
code := m.Run()
d.Client.Close() // Exitするとdeferが実行されないので
os.Exit(code)
}
func TestCreate(t *testing.T) {
// when
userId := model.UserId(1)
id, err := todoRep.Create(userId, "title", "description")
if err != nil {
t.Fatal(err)
}
// then
todos, err := todoRep.List(userId)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 1, len(todos))
assert.Equal(t, id, todos[0].Id)
assert.Equal(t, model.Title("title"), todos[0].Title)
assert.Equal(t, model.Description("description"), todos[0].Description)
}
もし、MySQLなどのDBのテストをする場合、テスト実行時にコンテナの起動・停止をコード上で扱えるdockertestなどがおすすめです。
もし興味があれば以下の記事が参考になるかもしれません
serviceのテスト
ここではRepositoryの実装に依存したくないのでRepositoryはmockを使用します。今回はgomockを使用しました。
package service_test
import (
"testing"
"github.com/JY8752/go-onion-architecture-sample/application/service"
"github.com/JY8752/go-onion-architecture-sample/domain/model"
"github.com/JY8752/go-onion-architecture-sample/mocks/mock_repository"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestCreate(t *testing.T) {
// given
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := mock_repository.NewMockTodoRepository(ctrl)
m.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(model.TodoId(1), nil)
ts := service.NewTodoService(m)
// when
result, err := ts.Create(1, model.Title("title"), model.Description("description"))
if err != nil {
t.Fatal(err)
}
// when
assert.Equal(t, model.TodoId(1), result)
}
handlerのテスト
サービスのテスト同様、サービスの実装に依存したくないのでmockを使用します。
package handler_test
import (
"net/http/httptest"
"strings"
"testing"
"github.com/JY8752/go-onion-architecture-sample/domain/model"
mock_registory "github.com/JY8752/go-onion-architecture-sample/mocks/registory"
mock_service "github.com/JY8752/go-onion-architecture-sample/mocks/service"
"github.com/JY8752/go-onion-architecture-sample/userinterface/handler"
"github.com/golang/mock/gomock"
"github.com/labstack/echo/v4"
"github.com/sebdah/goldie/v2"
"github.com/stretchr/testify/assert"
)
const (
goldenDir = "../../testdata/golden/"
)
func TestCreateUserHandler(t *testing.T) {
// given
e := echo.New()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// エンドポイントの登録
service := mock_service.NewMockUserService(ctrl)
service.EXPECT().Create("user1").Return(model.UserId(1), nil)
registory := mock_registory.NewMockRegistory(ctrl)
registory.EXPECT().UserService().Return(service)
handler.CreateUserHandler(e, registory)
// リクエストの作成
body := `{"name": "user1"}`
w := httptest.NewRecorder()
r := httptest.NewRequest(echo.POST, "/users", strings.NewReader(body))
r.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
defer r.Body.Close()
// when
e.ServeHTTP(w, r)
// then
assert.Equal(t, 200, w.Code)
g := goldie.New(t, goldie.WithFixtureDir(goldenDir))
g.Assert(t, t.Name(), w.Body.Bytes())
}
handlerのテストはgolden testで実装しました。レスポンスのJSONの項目が増えてくるとアサーションが大変なため期待する情報をファイルで管理できるgolden testとしてテストを書くことで楽にテストを書くことができました。
Goでgolden testを書くためのモジュールはいくつかありましたが今回はgoldieを使用しました。
まとめ
何度も言うようですがクリーンアーキテクチャもオニオンアーキテクチャも重要なことはレイヤを分けることとそれぞれのレイヤの依存関係の方向です。目的は関心ごとの分離であり変更に強いシステムを作ることです。
そのため、具体的なこれが正解ですといったものはなくプロジェクトや組織、使用する言語によっても作り方は変わっていくと思います。
抽象的な概念なので完全に理解することは難しいため、まずは自分のスタイルで納得感のあるものをまずは作ってみると理解につながるかもしれません。
とにかく、重要だなと思ったことはフレームワークやDBなど外部依存の部分はいつでも捨てられるように実装することです。また、概念を理解しなければ実装していても腑に落ちないと思いますのでClean Architecuture本をまだ読んだない方はぜひ読んでみることをおすすめします。
今回は以上です🐼
Discussion