DDD+擬クリーンアーキテクチャ in Go

ドメインルールを考えるときに技術的な関心から分離したいというDDDの主張には惹かれます。 一方でDDDはGoに合わないと言われたりもします。相性が悪い面もありそうだと思うけど、そういった主張の多くはクリーンアーキテクチャと混同していたりします。鵜呑みにせずに自分でいい塩梅を探したくなりました。

タイトル通り、DDD + なんちゃってクリーンアーキテクチャをGoで実装しながら悩んでみました。またGoのリハビリを兼ねていくつか定石も使ってみました。 これらについて記録しておきます。ドメイン部分は本当に無意味な実装です。はじめにここをベースに描き始めてドメインの実装に飽きたという流れです。 コードはここに残しておきます。

設計の全体像

はじめにファイル構成です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
$ tree
./
├── adapter
│   ├── controller
│   │   └── http
│   │       └── server.go
│   └── gateway
│       ├── dummy
│       │   ├── gateway.go
│       │   ├── repositories.go
│       │   └── repositories_test.go
│       ├── gateway.go
│       └── sqlite3
│           └── repos.go
├── cover.html
├── cover.profile
├── domain
│   ├── creature.go
│   ├── creature_test.go
│   ├── domaintest
│   │   └── hunter_repository.go
│   ├── errors.go
│   ├── event.go
│   ├── event_bus.go
│   ├── hunter.go
│   ├── hunter_repository.go
│   ├── material.go
│   ├── monster.go
│   ├── monster_repository.go
│   └── service
│       └── engine.go
├── game
├── go.mod
├── go.sum
└── main.go

9 directories, 24 files

どのようにレイヤーがなされているか説明していきます。 用語の確認と整理をします。

DDDではコード全体をドメインレイヤとアプリケーションレイヤに別けて考えます。 ドメインレイヤにはエンティティ・値・集約の他にリポジトリやファクトリ、イベント、それにサービスも含まれます。これらはUIや切り替えられるバックエンドおよびクライアントライブラリから独立させておきます。例えばリポジトリはインタフェースにしておくことが多くなると思います。もちろんチャンネルにするなど他の抽象化もできるでしょう。

ドメインサービスは複数のエンティティに関わる機能に対応しています。エンティティのメソッドにならないドメインルールです。 逆にアプリケーションレイヤはドメインルールを使う具体的なソフトウェアに対応します。バックエンドやUIとの繋ぎこみなどが含まれます。

クリーンアーキテクチャにはエンティティとユースケースというレイヤがあります。ネット記事を拾い読みしてるとそれぞれドメインのエンティティとドメインサービスに対応したコードの集約場所になっています。ユースケースはリポジトリを受け取って処理を行っています。

これを踏まえて下のようなディレクトリ構成にしました。

ディレクトリ 用途
domain DDDのドメインのエンティティ(サービス以外)
domain/service DDDのドメインサービス(クリーンアーキテクチャのユースケース)
adapter クリーンアーキテクチャのアダプタ
adapter/controller クリーンアーキテクチャのコントローラ
adapter/gateway クリーンアーキテクチャのゲートウェイ

ユースケースというディレクトリを切らずに domain 配下に service として格納しています。 最初はユースケースを独立させていましたがクリーンアーキテクチャの用語を使うと entity を独立させることになります。ユースケースのエンティティはDDDの値オブジェクト・イベント・集約など他の構成要素を含むかもしれませんが domain にしました。

ユースケースとそれ以外のドメインルールを分離してしまうとアダプタから使うのがユースケースに限定されてしまいそうだと感じたからです。 たとえば Go ではオーバーロードがないため GetHoge() GetHogeByID() といった類似関数が大量に作られそうだと感じたからです。 せっかくリポジトリ、ファクトリとインフラに依存せずにドメインを利用できるので、アダプタで直接使えばいいと思いました。

サブドメインが明確になったら、一部をサブパッケージに分離していきます。 ドメインサービスは次の2つの理由で domainservice サブパッケージに切り出しました。

  • アプリケーションから呼ばれる境界になることが 多い
  • ドメイン内の他の構成要素に依存する

adapter にはDDD本のアプリケーションレイヤが入ります。 domain がDDDな用語を使っているのにここからはクリーンアーキテクチャ的な用語を使います。 application とか cmd とか app とか考えたのですが役割がはっきりしないのとコマンド全体でアプリケーションなので言葉が衝突してしまいます。この adapater にはコントローラ・ゲートウェイ・プレゼンターゲートウェイの実装が置かれます。プレゼンターやゲートウェイはコントローラに依存しないように注意します。逆にコントローラはプレゼンター・ゲートウェイに依存しても構いません。これはコントローラはアプリケーションや一部の処理フロー全体に対応していると考えているからです。 残りの2つのコンポーネント群は永続化やデータ変換など処理フローの一部を担うライブラリに過ぎないのでコントローラを参照しないで実装できるはずと考えています。この原則以外には特に方針を決めなくてもいいと思ってます。クリーンアーキテクチャのプレゼンターはUIに出力を反映するコンポーネントです。しかしAPIサーバーやシングルコマンドなど単純な実装では不要だと考えています。せいぜい日時のロケールを調整するなど表示用のデータへの変換処理を独立させるだけで十分です。そのためGUIやREPLを実装する時に改めて検討することにします。どのようなアプリケーションいなるとプレゼンターが活かせるかというと次のどちらかを満たす場合だと思っています。

  • ドメインサービスを通過するループがある
  • 出力の反映先が複数箇所ある

このような要件が出てきた時点で、UI更新をプレゼンターを実装します。またコントローラにプレゼンターを渡せるように変更します。こうすることでコントローラとUIを疎結合にできます。

クリーンアーキテクチャはユースケースにコマンドパターンを適用していたりプレゼンターでUIへの反映を提案していたりGUIを強く意識しているなぁと思いました。

ref.

コーディングでの決断

ドメインサービスの実装パターン

ドメインサービスには具体的なリポジトリを渡さないとなりません。いくつかのクリーンアーキテクチャの実装例ではユースケース(ドメインサービス)1つ1つでインスタンスを作り Do(), Exec() メソッドで実行する例がみられました。これはGoFのコマンドパターンです。UIに処理を登録できるようにしてUIと処理を分離することに使います。

プレゼンターと同じく不用意に使うとわかりにくいので選びませんでした。なので全てのドメインサービスを提供する実行エンジンを準備することにしました。これは実装が簡素ですが、一部のサービスしか使わない場合にも全てのリポジトリのインスタンスが必要になってしまいます。これを解決するには2つ考えています。

  • nil などダミーリポジトリで我慢する
  • ユースケース毎にリポジトリファクトリを受け付けるように変更する

2つめについては下のようになります。これをやるとユースケースの依存が明確になります。ただ色々なユースケース毎に個別のエンジンを実装するのは面倒なので1つめの nil を設定できる全てのリポジトリファクトリを満たす型を作って渡すようになりそうな気がしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type AppleEngine interface {
  GetAppleRepository() AppleRepository
}

type OrangeEngine interface {
  GetOrangeRepository() OrangeRepository
}

type fruitEngine interface {
  AppleEngine
  OrangeEngine
}

func usecaseApple(eng AppleEngine) { ; }
func usecaseOrange(eng OrangeEngine) { ; }

context.Contextを渡す

DDDの説明がJavaとかScalaとかで説明されることが多くて context.Context を忘れがちだけど domain 配下でも使うことにしました。とくにリポジトリやイベントの発行で渡します。 ドメインルールを設計するときに技術的詳細を混ぜたくない。 そこが惹かれたところになる。 context.Context はキャンセルとか技術的側面を扱うけど、これを渡すだけでは依存は起きません。Goで実装する時は標準で使うのでドメインに渡すことにしました。

1
2
3
4
type AppleRepository interface {
  FindByID(ctx context.Context, id int)
  Save(ctx context.Context, apple *Apple)
}

この判断でデータベースのトランザクション内の操作などを自然に実現できます。 context.Context にはセッションに所属するデータを渡せます。RDBMSのトランザクションはセッションに依存するので渡しても問題ありません。リポジトリは渡されたコンテキストからトランザクションを取り出して使います。 ただし取り出しに失敗しても正しく動くようにする必要があります。

ゲートウェイをリポジトリファクトリとする

リポジトリがコンテキストを受け取れるようにした。コンrテキストにトランザクションを渡す場所はコントローラにした。コントローラが全体史の処理を把握しているのでトランザクションの操作も自然だと考えた。一方でトランザクションをサポートするかどうかはリポジトリの実装に依存する。そのためゲートウェイがインタフェースを公開することにした。

つぎのように定義しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Gateway interface {
  AppleRepository() AppleRepository
  OrangeRepository() OrangeRepository
}

type Transactional interface {
  ContextWithTx(ctx context.Context) (c context.Context, commit, abort context.CancelFunc)
}

type TransactionalGateway interface {
  Transactional
  Gateway
}

したみたいな感じで実装する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var _ TransactionalGateway = (*TransactionalGatewayImpl)(nil)

type txKey struct{}

func (g *TransactionalGatewayImpl) ContextWithTx(p context.Context) (ch context.Context, commit, abort context.CancelFunc) {
  txV := &tx{}
  ch = context.WithValue(p, txKey{}, txV)
  var cancel context.CancelFunc
  ch, cancel = context.WithCancel(ch)
  return ch, func() { txV.Commit(); cancel() }, func() { txV.Abort(); cancel() }
}

// example of getting a transaction from ctx
func getTx(ctx context.Context) {
  if txV, ok ctx.Value(txKey{}).(*tx); ok {
    return txV
  }
  return defaultTx
}

コンテキストに渡す値のキーはプライベートな型にすることで外から変更できないようにしています。ゲートウェイにはコミットやロールバックが重複して呼ばれても安全にする責任があります。またコントローラーはゲートウェイがトランザクションをサポートしていることも知っています。

ref.

domaintestモジュールを提供した

リポジトリの実装が実際に動くのか確認するテストヘルパーを domain/domaintest に定義しました。リポジトリの実装が正しく動くか簡易のシナリオテストを行ないます。

1
2
3
4
5
6
func ExecTestAppleRepositories(t *testing.T, repo domain.HunterRepository) {
  // ...
  t.Run("FindByID() returns saved hunter by Save()", func(t *testing.T) {
    // ...
  }
}

はじめは export_test.go を定義したらサポート関数を外のパッケージから使いたかったのですが無理でした。この方法は同じディレクトリ内に置いたテスト用の外部パッケージから読めるというだけだった。 いまは domaintest とパッケージが独立していることでユーザーにヘルパー関数の利用を促しやすいと感じています。

ライブラリのロガーを外から切り替える

domain/serviceadapter はライブラリなので直接ログを出すわけにいきません。下のようにロガーを切り替えられるようにしています。まじめにやる場合はプライベートな変数にして切り替え用の関数を提供するようにします。 sync.Mutex による排他制御も必要になると思います。

1
2
3
4
5
6
7
8
var Logger *log.Logger

func logf(format string, v ...interface{}) {
  if Logger == nil {
    return
  }
  Logger.Printf(format, v...)
}

ref.

コーディングのtips

設計判断ではない実装パターンなどをメモします。スニペットにしておきたいです。

gorilla/mux をルーティングライブラリとして使う

つぎのようにルーティングを登録できます。 http.Handler を満たすのでそのまま http.ListenAndServe() に使えます。

1
2
3
4
5
6
7
import 	"github.com/gorilla/mux"

//....
r := mux.NewRouter().StrictSlash(true)
r.Router.HandleFunc("/attack/{hunter_id}/{monster_id}", r.attackByIDs).
  Method("POST")
http.ListenAndServe(":8080", r)

http.HandlerFunc の実装

ハンドラの中で Content-Type などを設定します。 json.Encoder は便利ですね。

1
2
3
4
5
6
func (w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"hoge": "apple"})
    return
}

GracefulShutdownは公式ですでに入っていた

公式のサンプルコードにタイムアウトを追加しただけです。ServerではTLS、タイムアウトを詳細に設定できるのでオプションを確認してだいたい覚えておく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
srv := &http.Server{
  Addr: ":8080",
  Handler: handler,
}

idleConnsClosed := make(chan struct{})
go func() {
  sigint := make(chan os.Signal, 1)
  signal.Notify(sigint, syscall.SIGINT, syscall.SIGTERM)
  <-sigint

  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  defer cancel()
  if err := srv.Shutdown(ctx); err != nil {
    log.Printf("HTTP server Shutdown: %v", err)
  }
  close(idleConnsClosed)
}()

if err := srv.ListenAndServe(); err != http.ErrServerClosed {
  log.Fatalf("HTTP server ListenAndServe: %v", err)
}

<-idleConnsClosed

ref.

errors.As でエラー処理する

Go1.13からは次のように特定のエラーか判断できる。 これには intrface{ Unwrap() error } を実装している必要がある。fmt.Errorsで %w を使うとラップされるので手短に実装したい時はそれで十分。

1
2
3
4
5
var errNotFound *doamin.errNotFound
if errors.As(e, *errNotFound) {
  // ...
  return e
}

goleakでgoroutineリークを検出する

goleak を使うことでゴルーチンリークが起きてると検出されるのでテストに加えておくのがいいと思いました。

1
2
3
4
5
6
7
8
import (
  "testing"
  "go.uber.org/goleak"
)

func MainTest(m *testing.M) {
  goleak.VerifyTestMain(m)
}

ref.

テストカバレッジを計測する

したのコマンドでカバレッジを記録できます。

1
2
$ go test -coverprofile=cover.profile ./... -v
$ go tool cover --html=./cover.profile -o cover.html

ふりかえって

DDDの原則ではトランザクションで複数の集約を超えて永続化するような設計をするなと言われます。インフラが複数の永続化命令を整合性を保って管理できるとは限らないという理由があります。

今回Goで書いていて context.Context をドメインに渡してしまえばトランザクションに関する情報を受け渡せて、なんとかなってしまうと思った。 ドメインの設計を簡素に保てるならアダアプタやインフラ部分で複雑さを吸収する必要がないので嬉しいと思っているので、目指す単純さはそこにあると考えている。

煩雑になりがちなドメインの設計に集中するためにクリーンアーキテクチャ派生の構造を考えました。 書いてみるとDDDに必要な言語機能はそろっていてゴタゴタせずに実装できました。

一方で簡素な機能要件に対して高度な非機能要件を伴うシステムを実装するにはオーバーヘッドなど別の面の考慮が必要になる。そのようなシステムやライブラリは技術に強く依存することが多くなります。そのようなシステムでは、どのようなモジューラビリティが必要になりオーバーヘッドが小さくて済むかを考える必要がありそうです。

comments powered by Disqus