この記事は MicroAd Advent Calendar 2019 の21日目の記事です。
ところで、スター・ウォーズの最新作 『スカイウォーカーの夜明け』は昨日(2019/12/20)公開されましたね、みなさん観に行きましたか。
はじめに
1年前から DDD(ドメイン駆動設計) に興味を惹かれて実践しました。個人的に業務ロジックが複雑や仕様変更が頻繁発生するプログラムには、DDDの導入はメリット大きいので、まだ忘れてないうちに Demo を通して個人の理解を少し共有したいと思います。
Demo は簡潔で高い表現力が高い Scala 言語で書いていますが、DDD は設計手法の一つなので、基本的にOOP言語であればどんな言語でも実現できます。
Demo実装のリポジトリ: https://github.com/hotgoodway/currency-exchange
Demoの仕様
通貨両替のプログラムを作ります。
- 両替可能な通貨は 日本円 と アメリカドル のみ、両替レートはリアルタイムで変動する想定です。
- ユーザが 両替元金額、両替元通貨、両替先通貨 情報を入力してから、その時点の通貨レートを適用し 両替先金額 を計算してからユーザに提示する。
シンプルな仕様ですね。
DDD的な考え方
1. ドメインモデル層
この層の 関心事 は 業務ロジック です。
実世界の言葉を使って業務のモデリングを行いましょう。この層で技術用語が出てしまったら ダメ です。
まずは、業務に詳しいドメインエスキパートと議論しながら、このプログラムコンテキスト内のドメインモデル を ユビキタス言語 で洗い出します。
- 金額数量(amount): 金額の数量を表す。例: 100円の「100」部分、1.05ドルの「1.05」部分
- 通貨(currency): 金額の通貨を表す。例: 「日本円」、「アメリカドル」
- レート(rate): 両替レートの量の部分を表す。例: 「110.0」
- 金額(money): 実世界の金額を表す。通貨と金額数量が含まれる。
- 両替元の通貨(currency from): 両替元の通貨を表す。
- 両替先の通貨(currency to): 両替先の通貨を表す。
- 両替元の金額(exchange from): 両替元の金額を表す。
- 両替先の金額(exchange to): 両側先の金額を表す。
- 両替時間(exchange time): 両替が発生した時間。
- 両替レート(exchange rate): 指定時間の両替レート。例: 2019/12/21 12:30:55 に、アメリカドルから日本円に両替するレートは 110.0
次に、ドメインモデルの相互作用も検討しましょう。
- 両替先の金額 = 両替元の金額 X 両替レート
これで業務のモデリングが構築完了、そのまま実装コードに落とし込めますね。
// 金額数量
case class Amount(private val underlying: BigDecimal)
// 通貨
sealed trait Currency
object Currency {
case object JPY extends Currency // 日本円
case object USD extends Currency // アメリカドル
}
// レート
case class Rate(private val underlying: BigDecimal)
// 金額
case class Money(amount: Amount, currency: Currency)
// 両替元の通貨
case class CurrencyFrom(currency: Currency)
// 両替先の通貨
case class CurrencyTo(currency: Currency)
// 両替元の金額
case class ExchangeFrom(money: Money) {
// 両替先の金額計算
def * (rate: Rate, currencyTo: CurrencyTo): ExchangeTo =
ExchangeTo(money.amount * rate, currencyTo)
}
// 両替先の金額
case class ExchangeTo(money: Money)
// 両替時間
case class ExchangeTime(private val underlying: LocalDateTime)
// 両替レート
case class ExchangeRate(currencyFrom: CurrencyFrom, currencyTo: CurrencyTo, rate: Rate,
exchangeTime: ExchangeTime)
ここで注意すべきのは 集約 というDDDの設計戦術。金額数量、通貨 の集約で 金額 が作られ、さらに両替元の金額、両替レート も集約で作られます。
集約 戦術をうまく使えば 凝集度 の高いモデルが作られます。業務ロジックが適切なドメインモデルのに定着することによって、重複コードが無くなるし、ドメインモデルの 貧血症 を防ぐことができます。
2. アプリケーション層
2.1. リポジトリ
この層の 関心事 は ドメインモデル永続化のインターフェイス です。
ドメインモデルの 永続化(取得、保存) はリポジトリで行い、具体的な実装はその後のインフラ層で行いますが、この層ではリポジトリのインターフェイスのみ定義します。
Demoにはリアルタイム時に 両替レート の取得は必要なので定義していきましょう。
// 両替レートの永続化リポジトリ
trait CurrencyRateRepository {
def getBy(currencyFrom: CurrencyFrom, currencyTo: CurrencyTo,
exchangeTime: ExchangeTime): Either[Throwable, ExchangeRate]
}
2.2. サービス
この層の 関心事 は サービスのフロー です。
ドメインモデル、リポジトリ、外部APIのIFなど相互とやりとりしてサービスフローを構築します。
// 両替サービス
class Exchange(currencyRateRepository: CurrencyRateRepository) {
def apply(exchangeFrom: ExchangeFrom, currencyTo: CurrencyTo,
exchangeTime: ExchangeTime): Either[Throwable, ExchangeTo] =
for {
currencyRate <- currencyRateRepository.getBy(exchangeFrom.currency, currencyTo, exchangeTime)
exchangeTo = exchangeFrom * (currencyRate.rate, currencyRate.currencyTo)
} yield exchangeTo
}
3. インフラ層
この層の 関心事 は 技術的な実現 です。
技術的な実現はこの層で行います。アプリケーション層で定義したリポジトリなどのインターフェイスはこの層で実現します。
このような DIP戦術 によってアプリケーション層とインフラ層の依存関係が逆転され、インフラ層を依存関係の外側に持ち出すことができました。
-
アプリケーション層にインフラ層のインターフェイスがない場合の依存関係
-
ドメインモデル層 ⇦ アプリケーション層
-
インフラ層 ⇦ アプリケーション層
-
ドメイン層 ⇦ インフラ層
-
アプリケーション層にインフラ層のインターフェイスがある場合の依存関係
-
ドメインモデル層 ⇦ アプリケーション層 ⇦ インフラ層
-
ドメインモデル層 ⇦ インフラ層
※ A ⇦ B の意味: BはAに依存する
// アプリケーション層で定義したリポジトリの実現
class CurrencyRateRepository extends application.repository.CurrencyRateRepository {
// 詳細は省略
}
4. ユーザインターフェイス層
この層の 関心事 は プログラム内外世界の繋がり です。
名前通り、外部世界と繋がる窓口ですね。
外部世界からもらった情報をアダプター経由で ドメインモデル に変換し、アプリケーションサービスを処理させます。
その結果のドメインモデルは、またアダプター経由で外部世界が分かる情報に変換して返します。
// 外部世界からもらった文字列情報を金額モデルに変換するアダプター
object AmountAdapter {
def apply(string: String): Either[Throwable, Amount] = Try {
BigDecimal.apply(string)
} match {
case Success(value) if value > 0 => Right(Amount(value))
case Failure(_) => Left(new Throwable(s"Invalid value: ${string}"))
}
}
// 外部世界からもらった文字列情報を通貨モデルに変換するアダプター
object CurrencyAdapter {
def apply(string: String): Either[Throwable, Currency] =
string.toUpperCase() match {
case "JPY" => Right(JPY)
case "USD" => Right(USD)
case _ => Left(new Throwable(s"Not supported currency code: ${string}" ))
}
}
// 全ての始まりと終わり
object Main {
def main(args: Array[String]) {
println("1. Please input money amount:")
val amount = AmountAdapter.apply(StdIn.readLine())
println("2. Please input money currency:")
val currency = CurrencyAdapter.apply(StdIn.readLine())
println("3. Please input the currency code you want to exchange for:")
val currencyTo = CurrencyAdapter.apply(StdIn.readLine()).map(CurrencyTo(_))
val exchange = new Exchange(new CurrencyRateRepository)
(
for {
amount <- amount
currency <- currency
currencyTo <- currencyTo
exchangeTime = ExchangeTime.now
exchangeTo <- exchange.apply(ExchangeFrom.apply(amount, currency), currencyTo, exchangeTime)
} yield exchangeTo
) match {
case Left(t) => println(s"Input error, ${t.getMessage}")
case Right(to) => println(s"The money after exchanged is:")
println(s"${to.money.currency.toString} ${to.money.amount.toBigDecimal}")
}
}
}
おわりに
このDemoを通してDDD的な考え方を伝えたのでしょうか。あくまでも私自身の理解ですので何かあればご指摘ください。これからもDDDへの理解を深めていきたいと思います。
また、一部のコードを IOモナド で書くと更に品質向上ができますが、本記事の 関心事 ではないので割愛します。
以上です。