ScalaとGolangでDDDを実装比較してみた
パラダイムの違う言語であるScala(PlayFramework)とGolang(Gin)を使い、同じ要件をDDDで実装しました。
DDDの実装例や言語毎の比較はあまり世間ではあまり見かけないので、ナレッジシェアも兼ねてまとめました。
前提
- ScalaとGolangは別々のパラダイムから来ており、それぞれのメリット/デメリットが有ります
- 純粋に「やってみたらどうなるんだろう?」を実践したものであり、特定言語を持ち上げたりディスったりしたいわけではありません
- 実装は私が 現職 での経験を踏まえつつ自分なりにアレンジしたもの(特にScalaでの実装)です
- 「どんなケースにも対応できるDDDの正解実装」といわけではありません
経緯/モチベーション
- 業務ではDDDに沿ったweb APIを開発していることもあり、自分でイチから何か作ってみたかった
- そもそもゼロからアプリケーションを作る経験が少ないのでやってみたかった
- Scala版を作った段階で「別パラダイムの言語でDDDするならどうなるのか」というのが気になったのでGolangでも同じ内容のものを実装してみた
サンプルコード
今回作ったものは以下に掲載しています。本記事では紹介しきれない部分がたくさんありますので、詳細を知りたい方はご覧ください!
ここで紹介している実装の参考元も以下レポジトリのREADMEに掲載しています。
- Scala版: yu-croco/ddd_on_scala_sample
- Golang版: yu-croco/ddd_on_golang_sample
結論
Scala x DDD
- (はるか昔から言われているように)DDDとScalaの相性は非常に良い
- OOPとしての表現力が良い
- 関数型の力は偉大
- 技術的な観点(共通基盤処理など)は関数型が大活躍する
- Effなどを使うとUseCase層での型パズルを回避できる
Golang x DDD
- 案外キレイにUseCase層を作れる
- Golang特有の呼び出し側でのエラーハンドリングなど有るが、工夫してやれば仕様をコードで表現することはできそう
- 構造化プログラミングのため愚直/冗長な実装はある程度仕方ない
- 「Scalaだったらこれできるのに〜」というシーンは多々あったが、パラダイムがそもそも違うので仕方ない
- 大規模DDDとなるとコードがとんでもない量になるので、基本的にはマイクロサービスに向いていると思う
- 上記の点を上回るメリットが有る場合には採用する価値がありそう
構成
ハンターがモンスターにダメージを与えたり素材を剥ぎ取るというケースを実装しました。
アプリケーション全体の構成は、いわゆるオニオンアーキテクチャを用い、app配下は以下のようになっています。ScalaとGolangで使用するライブラリなどが異なるので若干の差分はありますが、大枠は同じです。
Scala
.
├── Module.scala
├── adapter
│ ├── controllers
│ ├── helpers
│ └── json
├── domain
│ ├── helpers
│ ├── model
│ ├── package.scala
│ ├── repository
│ ├── service // ドメインサービス
│ └── specs // 仕様クラス
├── infrastructure
│ ├── dto // slickのcodegen
│ ├── helpers
│ ├── queryimpl
│ └── repositoryimpl
├── query
│ ├── hunter
│ └── monster
└── usecase
├── helpers
├── hunter
├── monster
└── package.scala
Golang
.
├── adapter
│ └── controller
├── domain
│ ├── model
│ ├── repository
│ └── service // ドメインサービス
├── errors
│ └── error.go // アプリケーション全体のエラーハンドリング
├── infrastructure
│ ├── db.go
│ ├── dto // Gormで使っているモデルなど
│ ├── queryImpl
│ ├── repositoryImpl
│ └── seeds
├── query
│ ├── hunterQuery.go
│ └── monsterQuery.go
└── usecase
├── hunter
└── monster
Domain層
エンティティ周り
Scalaの場合
Value Objectが存在するために何らかの条件がある場合には、条件を満たさないオブジェクト生成はしたくありません。そのため、Value Objectを生成する際に 必ず成功or失敗のどちらかとなる
ファクトリメソッドを用意することで、オブジェクトの生成が不完全なものとならないようにした(完全コンストラクタの実現)。traitなどである程度基盤を共通化できるので、検証対象が増えてもコード量はそれほど増えないと思いますので、これはScalaの良い点だと思います。
単にString/Longなどの型が合っていればいいケースでは不要ですが、「Nameは5文字以上20文字以下のStringである必要がある」といった場合などには有効ではないかと思います。今回のサンプルでは試験的にHunterIdに適応させてみました。
また、エンティティの振る舞い(メソッド)には失敗を伴うものがあるので、そういったものには Either[DomainError, Monster]
というように成功/失敗が分かるようにしています。
case class Hunter(
id: HunterId,
name: HunterName,
life: HunterLife,
defencePower: HunterDefensePower,
offensePower: HunterOffensePower,
huntedMaterials: Seq[HuntedMonsterMaterial],
attackDamage: Option[HunterAttackDamage] = None
) {
def attack(monster: Monster, givenDamage: HunterAttackDamage): Either[DomainError, Monster] =
monster.attackedBy(givenDamage)
}
case class HunterId(value: String) extends AnyVal
// HunterId生成時に値が意図した形式であるかをチェックする
object HunterId extends DomainIDFactory[HunterId] {
def error: DomainError = DomainError.create("hunterIdの形式に誤りがあります")
}
Golangの場合
エンティティは構造体で表現し、それに対応するメソッドを実装しています。これ自体は特にひねったことはしていないと思います。Scalaと同じくエンティティを構成する個々のValue Objectの型も用意しています。
Scalaと同様に完全コンストラクタを実装してみましたが、ベタ書きになりました。結構冗長&コンストラクタが増えるとコードも比例して増える感じなので、いい感じの方法があると嬉しいなぁという感じです...。
また、各メソッドでは失敗(条件を満たさないなど)の情報を返すための AppError
というものを用意しています。
type Hunter struct {
Id HunterId `json:"hunterId"`
Name HunterName `json:"hunterName"`
Life HunterLife `json:"life"`
DefencePower HunterDefencePower `json:"defencePower"`
OffensePower HunterOffensePower `json:"offensePower"`
HuntedMaterials HuntedMonsterMaterials
AttackDamage HunterAttackDamage `json:"attackDamage"`
}
type HunterId int
type HunterName string
type HunterLife int
func (hunter *Hunter) AttackedBy(givenDamage MonsterOffensePower) (*Hunter, *errors.AppError) {
var err errors.AppError
diff := hunter.Life.Minus(givenDamage)
if hunter.Life == 0 {
err = errors.NewAppError("ハンターは既に倒れています")
return nil, &err
}
if diff >= 0 {
hunter.Life = diff
} else {
hunter.Life = HunterLife(0)
}
return hunter, &err
}
// hunterIdの完全コンストラクタ
func NewHunterId(id int) (*HunterId, *errors.AppError) {
if id <= 0 {
err := errors.NewAppError("HunterIdは1以上の値にしてください")
return nil, &err
}
hunterId := HunterId(id)
return &hunterId, nil
}
...
RepositoryとRepositoryImplのDI
Scalaの場合
PlayFrameworkがデフォルトで強力なDI機構を持っているのでそのまま使えて便利でした。
追加でcodingwell/scala-guiceを使うとより簡潔に書けます。
trait HunterRepository {
def findById(id: HunterId): Future[Option[Hunter]]
}
class HunterRepositoryImpl()...
// DI
class Module extends AbstractModule with ScalaModule {
override def configure(): Unit = {
bind[HunterRepository].to[HunterRepositoryImpl]
}
}
Golangの場合
GinにはDIコンテナが入っていないので、代替案として自前で実装します。
*外部ライブラリとしてはいくつかDIコンテナが存在するようです
調べたところGolangでは interface
を用いすことでDI(厳密にDIと言って良いのかはちょっと判断つかないのですが)を実現するようでした。
type HunterRepository interface {
FindById(id int) (*model.Hunter, *errors.AppError)
}
type HunterRepositoryImpl struct{}
func NewHunterRepositoryImpl() HunterRepository {
return &HunterRepositoryImpl{}
}
func (repositoryImpl *HunterRepositoryImpl) FindById(id int) (*model.Hunter, *errors.AppError) {
/// ...
}
interfaceを使ってrepositoryImplが備えるメソッドを強制しています。これでドメイン層でのRepositoryは抽象的な型の定義だけにとどめ、詳細実装を隠匿できます。
PlfayFrameworkのようなDIツールがないのでインターフェイスと実装の結びつけは直接的なものになってしまいますが、 インターフェイスと実装を切り分けられる
というのは達成できているので個人的には良いかなと思っています。
*やる前まではもっと面倒な実装になるかなと思っていました...。
UseCase層
Scalaの場合
atnos-org/effを使うことで、文章のようにコードで仕様を表現することができました(美しい!!)。
Effに関してはこちらの記事にまとめていますので、興味の有る方はどうぞ!
ScalaのEffを使ってDDDのUseCase層をいい感じに書いてみる
class AttackMonsterUseCase @Inject()(hunterRepository: HunterRepository, monsterRepository: MonsterRepository)(
implicit ec: ExecutionContext) {
def program[R: _future: _useCaseEither](hunterId: HunterId, monsterId: MonsterId): Eff[R, Monster] =
for {
hunter <- hunterRepository.findById(hunterId).toUCErrorIfNotExists("hunter").toEff
monster <- monsterRepository.findById(monsterId).toUCErrorIfNotExists("monster").toEff
hunterAttackDamage = HunterAttackService.calculateDamage(hunter, monster)
damagedMonster <- hunter.attack(monster, hunterAttackDamage).toUCErrorIfLeft().toEff
savedMonster <- monsterRepository.update(damagedMonster).raiseIfFutureFailed("monster").toEff
} yield savedMonster
}
Golangの場合
Golang特有の呼び出し側でのエラーハンドリングがありますが、AppErrorというエラーハンドリングを用意したことでエラーはadapter層で変換するだけで済むようにしました。
*errorを返すのでもいいと思うのですが、複数のエラーを一つのレスポンスで返したいケースが有るため、自前で用意しました。
これにより、ユースケース層ではドメイン層の処理を呼び出しに徹することができて仕様を表現するだけの実装に仕上がりました。
func AttackMonsterUseCase(hunterId int, monsterId int) (*model.Monster, *errors.AppError) {
hunter, hunterFindErr := hunterRepository.FindById(hunterId)
if hunterFindErr.HasErrors() {
return nil, hunterFindErr
}
monster, monsterFindErr := monsterRepository.FindById(monsterId)
if monsterFindErr.HasErrors() {
return nil, monsterFindErr
}
hunterAttackDamage := service.CalculateAttackMonsterDamage(hunter, monster)
damagedMonster, attackErr := hunter.Attack(monster, hunterAttackDamage)
if attackErr.HasErrors() {
return nil, attackErr
}
updatedMonster, updateErr := monsterRepository.Update(damagedMonster)
if updateErr.HasErrors() {
return nil, updateErr
}
return updatedMonster, nil
}
所感
ScalaとGolangというパラダイムの違う言語でDDDを実装してみて「この言語ではこう書くといい感じになるのか」という言語の活かし方について発見が多く、実践して良かったです。特にGolangでUseCase層をスッキリ書けるとは思ってもみなかったので、正直驚きでした。
どんな言語もそれぞれある目的に合わせて作られておりメリットデメリットを持っているので、それらを考慮した上で技術選定/設計/実装ができるようになると、強いエンジニアに一歩近づけるのだろうなぁと思いました。
Scala(PlayFramework)はnon-blocking、Golang(Gin)はblockingなので、そこの前提をどちらかに統一しておくべきだった..(指摘頂いて気づきました)。
Discussion