GolangでWeb APIを書くにあたって色々模索して、Clean Architecture
などしっくりきてる部分もあるが、インピーダンスミスマッチの対処法が分からないという状況
私はJava系の経験がないため、「境界付けられたコンテキストを適切に置きながら、インピーダンスミスマッチを吸収している」実装というのがどのようになされているのか分からないが、少なくともGolangの特性は(ドメインよりも)レイヤーの抽象化のためにあるように思う。
Goの説明は特にしないですが、DDDやってる方なら雰囲気は掴めると思うので読んでもらえると嬉しいです。
TL; DR
- Goはinterfaceによるレイヤーの抽象化と非同期処理とかうまく書けたら万々歳なのであって、clean architectureを実現できた時点で満足し、あとはドメインの境界線とか考えずDBファーストで効率の良いコードを書けば良い(という結論に至ってしまった自分にアドバイス頂きたい)
インピーダンス・ミスマッチングしている振る舞い
例えばSNSのようなものでフォロー機能を作るとして、本当はユーザー集約の中でユーザーの振る舞いとしてこう書きたいんだけど、
func (u *User) Follow(userID UserID) {
u.FollowingIDs = append(u.FollowingIDs, userID)
}
// usecase/user.go
u.Follow(userID)
uu.userRepository.save(ctx, u)
これだとunfollowしたいときにできない(まさかフォローレコード全量削除してもう一度作成とかないよね、、いや、そういうことなのか?)
ドメインに振る舞いが関数として書かれていることが大事なのであって、中身は何でもいいかと思ってテーブル設計に寄せにいくと、
func (u *User) Follow(userID UserID) *follow.Follow {
return &follow.Follow{
FollowingID: u.ID,
FollowerID: userID,
}
}
// usecase/user.go
f := u.Follow(userID)
uu.followRepository.Create(ctx, f)
これはUnfollow
を書いたときに中身が全くUnfollowの振る舞いを表せずrepositoryのメソッド頼りなため、「じゃあフォロー集約でやれ」ということになりかねない(最終的にそういう結論になるんですが)。
「リストを一括でSaveに投げたい」&「全量置換はしたくない」の両立を考えると、CSVインポートのようにフラグを立てる方法を思いついた。
type Command int
const (
DoNothing Command = iota
Create
Delete
)
type User struct {
FollowingIDs map[UserID]Command
}
func (u *User) Follow(userID UserID) {
u.FollowingIDs[userID] = Create
}
一瞬天才かと思ったが、Update
の実装がかなり残念なことになるのに気づいたのと、やはりFollowingIDs
という名前でIDが配列で入っていないのは違和感がある(CQRSちゃんと実践すれば解決するのかもしれない!)
そんなわけで、結局Follow
メソッドをUser
に生やす良い方法は思いつかなかった。
ここら辺結構調べたんだけど結論が出ていないサイトばかりで(詳しい方いればぜひ教えてください)
「フォロー」という機能についてドメイン特性を考えてみる
ユースケースとしては、
- ユーザーが他のユーザーをフォロー/フォロー解除する。
- ユーザーのフォローイング/フォロワー数を表示する。
- ユーザーのフォローイング/フォロワー一覧を表示する。
RDB的には、FK付きの中間テーブル作成の一択である。
一方DDD的には、振る舞いが全てユーザー起点のため、ユーザー集約を介した振る舞いが望ましいだろう。
よくある人数制限のようなものは考えづらいが、仮に荒らし対策のため短時間の連続フォローを制限するといったロジックを入れようとすると、ユーザー集約がより適切ということになると思う(ユーザーのステータスを制限状態にするといったロジックを自然に書ける)。
また、アプリケーションが成長して、フォロー以外にもミュートや通知、ブロックなどの「ユーザーに対する設定」が増えてくると、一々中間テーブルを増やすのではなくuser_config
のような一つのテーブルで各状態を管理する方が適切かもしれない。
その場合にも、呼び出し側はu.Mute(userID)
のような形で書ければ非常に直感的ではあると思う。
Clean Architectureとは相性が良い(レイヤーの抽象化)
Golangというのは基本できないことだらけなんだけど、逆に言うとベストプラクティスは定まりやすい(はず、、まだ普及段階なだけであって)
例えばinterface
によるポリモーフィズムをサポートしているため、実装の詳細を抽象に依存させることができる。
(こういうやつです)
import (
"context"
"sql"
"path/to/domain/user" // 抽象(ドメイン)に依存
)
type userRepository struct {
db *sql.DB
}
// 抽象(ドメイン)に依存
func NewUserRepository(db *sql.DB) user.UserRepository {
return &userRepository{db}
}
// 実装の詳細
func (r *userRepository) Save(ctx context.Context, u *user.User) error {
}
これ自体は可読性も上がるし、言語の特性をシンプルに活かした例で、ベストプラクティスの一つと言えると思う。
あとはgoroutine
ももちろん役立つ場面は多いんだけど、今回の話にはあまり関係ないので省略。
とにかくレイヤーを切るのは言語的にもしっくりくる、というのが伝えたかった。
だが一方でドメインをどう切ればいいか分からない。
なんかネットに転がってるのは「僕の考えた最強のDDD」みたいなのばかりなので、実運用でどう実装されているのか知りたい。
ネットに転がってる事例
EF Core
だと状態を記憶するからオブジェクト更新してsave
するだけでいいんだと。よく知らないけどGolangで重たいORMは使いたくないので。。
というかORMで差分検知とか削除フラグとかサポートしてないと無理ゲーだよねこれって。Gormとか使ってないけどできてもUpsert
ぐらいだと思う。
CQRS
でいうCommand
には全量取得の必要はないからlimitすればいいと言ってるんだけど、それはCreate
だからであってDelete
のときには通用しなくない?(order揃えて無理やり特定するとか、、まさかね)
という趣旨の質問が7ヶ月前にあって、他にもテーブル設計に沿った場合に比べてのパフォーマンスに関する質問が2ヶ月前にあるけど返答なし。こんなのが検索上位に出てくるので「僕の考えた最強のDDD」と思われても仕方ないと思う。
私もコミュニティ機能のようなものを作りたいので参考にさせていただいたのだが、肝心の永続化処理がまさかの中間テーブルも作ってない状態でちょっと面食らった。
もしかしたら触れるまでもないほど簡単に解決できる問題なのか、、?もしそうならぜひ教えていただきたい。