ビジネスロジックを「型」で表現するOOPのための関数型DDD / Functional And Type-Safe DDD for OOP (original) (raw)

©2024 Loglass Inc.
自己紹介
")

©2024 Loglass Inc.
型と自動テストの力を探求する者です
")

5
©2024 Loglass Inc.
ログラスについて
企業価値を向上する
経営管理...")

©2024 Loglass Inc.
")

©2024 Loglass Inc.
今日のテーマ
ビジネスロジックを「型」で表現する
O...")

©2024 Loglass Inc.
一番お伝えしたいこと
関数型のエッセンスを適度に取り...")

©2024 Loglass Inc.
Domain-driven design (DDD...")
programming is the innovative combo that will get you there. In this pragmatic, down-to-earth guide, you'll see how applying the core principles of functional programming can result in software designs that model real-world requirements both elegantly and concisely - often more so than an object-oriented approach. —『Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F#』Scott Wlaschin著 訳)ドメイン駆動設計 (DDD)と関数型プログラミングを組み合わせることで、革新的なソフト ウェア設計を実現することができます。この実用的で実践的なガイドでは、 関数型プログラミ ングの中核となる原則を適用することで、実世界の要求をエレガントかつ簡潔にモデル化す るソフトウェア設計が、オブジェクト指向のアプローチよりも実現できることを説明します。 関数型DDDとは?

©2024 Loglass Inc.
関数型DDDとは? 個人的意訳
関数型のエッセンス...")

©2024 Loglass Inc.
ビジネスロジックを型で表現できると何がいいの?
c...")
String val status: TaskStatus val completedAt: LocalDateTime? // 中略 } enum class TaskStatus { InProgress, Completed } よくある書き方 val task = Task("買い物", TaskStatus.InProgress, null) val task = Task( "買い物", TaskStatus.Completed, LocalDateTime.now(), ) // NG: 進行中なのに完了時刻持ち val task = Task( "買い物", TaskStatus.InProgress, LocalDateTime.now() ); - 実装ミスがコンパイルフェーズで検知できるようになる - 例)完了ステータスのタスクのみ完了日時を持つ

©2024 Loglass Inc.
ここまで
型の力が強化されたDDDで
ビジネスロジ...")

©2024 Loglass Inc.
関数型?
ウチはオブジェクト指向の言語なんですが?...")

©2024 Loglass Inc.
OOP × DDDを行っている
既存のプロジェクト...")

©2024 Loglass Inc.
そもそも関数型のエッセンスを取り入れ始めているプロ...")

©2024 Loglass Inc.
オブジェクト指向か?関数型か?は
近年では曖昧にな...")

©2024 Loglass Inc.
オブジェクト指向か?関数型か?
という観点より
良...")

©2024 Loglass Inc.
2. DDDってなんだっけ?
")

©2024 Loglass Inc.
DDD (Domain-Driven Design...")

©2024 Loglass Inc.
DDD (Domain-Driven Design...")

©2024 Loglass Inc.
DDDのモデリング
モデリング

©2024 Loglass Inc.
3. 実践とテクニック
")

©2024 Loglass Inc.
発注システムのユースケース図
※詳細はこちら『 ド...")

©2024 Loglass Inc.
発注システムのオブジェクト図
")

©2024 Loglass Inc.
発注システムのドメインモデル図(≒オブジェクト図を...")

©2024 Loglass Inc.
ドメインモデル図にルールを加えていく
")

©2024 Loglass Inc.
nullableがたくさんあってややこしい
")

©2024 Loglass Inc.
状態ごとの項目と遷移を整理する
")

©2024 Loglass Inc.
状態ごとの項目と遷移を整理する
")

©2024 Loglass Inc.
これらのルールの実装ミスを
コンパイルで防げたら
...")

©2024 Loglass Inc.
注文の状態をEnumで実装すると
class Or...")
OrderId, val customerId: CustomerId, val shippingAddress: Address, val lines: List, val status: OrderStatus, val confirmedAt: LocalDateTime?, val cancelledAt: LocalDateTime?, val cancelReason: String?, val shippingStartedAt: LocalDateTime?, val shippedBy: ShipperId?, val scheduledArrivalDate: LocalDate?, } enum class OrderStatus { UNCONFIRMED, CONFIRMED, CANCELLED, SHIPPING, }

©2024 Loglass Inc.
class Order {
val orderId...")
val customerId: CustomerId, val shippingAddress: Address, val lines: List, val status: OrderStatus, val confirmedAt: LocalDateTime?, val cancelledAt: LocalDateTime?, val cancelReason: String?, val shippingStartedAt: LocalDateTime?, val shippedBy: ShipperId?, val scheduledArrivalDate: LocalDate?, } enum class OrderStatus { UNCONFIRMED, CONFIRMED, CANCELLED, SHIPPING, } nullableなものが多くなる。 状態によっては必須な項目がある。 データ不整合が起きる可能性がある。 注文の状態をEnumで実装すると

©2024 Loglass Inc.
実行時にしかミスに気付けない。
深刻なデータ不整合...")

©2024 Loglass Inc.
ではどうするか?

©2024 Loglass Inc.
完全に別クラスとして定義する
class Unco...")
val customerId: CustomerId, val shippingAddress: Address, val lines: NonEmptyList, ) class ConfirmedOrder( val orderId: OrderId, // 中略 val confirmedAt: LocalDateTime, ) class CancelledOrder( val orderId: OrderId, // 中略 val confirmedAt: LocalDateTime, val cancelledAt: LocalDateTime, val cancelReason: String?, ) class ShippingOrder( val orderId: OrderId, // 中略 val confirmedAt: LocalDateTime, val shippingStartedAt: LocalDateTime, val shippedBy: ShipperId, val scheduledArrivalDate: LocalDate, )

©2024 Loglass Inc.
ここまでをまとめると

©2024 Loglass Inc.
ここまでをまとめると

©2024 Loglass Inc.
直積集合について

©2024 Loglass Inc.
直和集合について

©2024 Loglass Inc.
直和集合について

©2024 Loglass Inc.
代数的データ型 = 直積集合と直和集合の掛け合わせ
")

©2024 Loglass Inc.
代数的データ型を使って Orderを再実装
sea...")
val orderId: OrderId val customerId: CustomerId val shippingAddress: Address val lines: List class UnconfirmedOrder( override val …, ) : Order class ConfirmedOrder( …, val confirmedAt: LocalDateTime, ) : Order class CancelledOrder( …, val confirmedAt: LocalDateTime, val cancelledAt: LocalDateTime, val cancelReason: String?, ) : Order class ShippingOrder( …, val confirmedAt: LocalDateTime, val shippingStartedAt: LocalDateTime, val shippedBy: ShipperId, val scheduledArrivalDate: LocalDate, ) : Order }

©2024 Loglass Inc.
代数的データ型を使って Orderを再実装
sea...")
val orderId: OrderId val customerId: CustomerId val shippingAddress: Address val lines: List class UnconfirmedOrder( override val …, ) : Order class ConfirmedOrder( …, val confirmedAt: LocalDateTime, ) : Order class CancelledOrder( …, val confirmedAt: LocalDateTime, val cancelledAt: LocalDateTime, val cancelReason: String?, ) : Order class ShippingOrder( …, val confirmedAt: LocalDateTime, val shippingStartedAt: LocalDateTime, val shippedBy: ShipperId, val scheduledArrivalDate: LocalDate, ) : Order } データ不整合がなくなった

©2024 Loglass Inc.
val order = ShippingOrder...")
= "壊れていた", ) => コンパイルエラー 代数的データ型を使って Orderを再実装 class CancelledOrder( …, val confirmedAt: LocalDateTime, val cancelledAt: LocalDateTime, val cancelReason: String?, ) : Order class ShippingOrder( …, val confirmedAt: LocalDateTime, val shippingStartedAt: LocalDateTime, val shippedBy: ShipperId, val scheduledArrivalDate: LocalDate, ) : Order } ShippingOrderは cancelReasonを持っていないので コンパイルエラー

©2024 Loglass Inc.

©2024 Loglass Inc.

©2024 Loglass Inc.

©2024 Loglass Inc.
代数的データ型でモデルの状態を表現し、
データ不整...")

©2024 Loglass Inc.
状態遷移を型で表現したい
")

©2024 Loglass Inc.
状態遷移を型で表現したい
")

©2024 Loglass Inc.
普通に書くと
sealed interface O...")
cancel(cancelReason: String?, now: LocalDateTime): CancelledOrder { return when (this) { is UnconfirmedOrder -> throw Exception("未確定の注文はキャンセルできません ") is ConfirmedOrder -> CancelledOrder( ..., cancelledAt = now, cancelReason = cancelReason, ) is CancelledOrder -> throw Exception("キャンセル済みの注文はキャンセルできません ") is ShippingOrder -> throw Exception("発送済みの注文はキャンセルできません ") } } }

©2024 Loglass Inc.
sealed interface Order {
...")
String?, now: LocalDateTime): CancelledOrder { return when (this) { is UnconfirmedOrder -> throw Exception("未確定の注文はキャンセルできません ") is ConfirmedOrder -> CancelledOrder( ..., cancelledAt = now, cancelReason = cancelReason, ) is CancelledOrder -> throw Exception("キャンセル済みの注文はキャンセルできません ") is ShippingOrder -> throw Exception("発送済みの注文はキャンセルできません ") } } } 4ケースのテストが必要 普通に書くと

©2024 Loglass Inc.
普通に書くと
sealed interface O...")
cancel(cancelReason: String?, now: LocalDateTime): CancelledOrder { return when (this) { is UnconfirmedOrder -> throw Exception("未確定の注文はキャンセルできません ") is ConfirmedOrder -> CancelledOrder(...) is CancelledOrder -> throw Exception("キャンセル済みの注文はキャンセルできません ") is ShippingOrder -> CancelledOrder( ..., cancelledAt = now, cancelReason = cancelReason, ) } } } 実装ミスしてしまう

©2024 Loglass Inc.
Orderクラス全体
sealed interfa...")
class UnconfirmedOrder(...) : Order { fun confirm(now: LocalDateTime): ConfirmedOrder } class ConfirmedOrder(...) : Order { fun cancel(cancelReason: String?, now: LocalDateTime): CancelledOrder {...} fun startShipping(shipperId: ShipperId, now: LocalDateTime): ShippingOrder {...} } class CancelledOrder(...) : Order class ShippingOrder(...) : Order }

©2024 Loglass Inc.
呼び出し元
class CancelOrderUs...")
OrderRepository, ) { fun execute(orderId: OrderId, cancelReason: String?) { val order = orderRepository.findById(orderId) ?: throw Exception("注文が見つかりませんでした。ID: ${orderId.value}") val now = LocalDateTime.now() when (order) { is UnconfirmedOrder -> throw Exception("未確定の注文はキャンセルできません") is ConfirmedOrder -> order.cancel(cancelReason, now) is CancelledOrder -> throw Exception("キャンセル済みの注文はキャンセルできません") is ShippingOrder -> throw Exception("発送済みの注文はキャンセルできません") } } }

©2024 Loglass Inc.
呼び出し元
class CancelOrderUs...")
OrderRepository, ) { fun execute(orderId: OrderId, cancelReason: String?) { val order = orderRepository.findById(orderId) ?: throw Exception("注文が見つかりませんでした。ID: ${orderId.value}") val now = LocalDateTime.now() when (order) { is UnconfirmedOrder -> throw Exception("未確定の注文はキャンセルできません") is ConfirmedOrder -> order.cancel(cancelReason, now) is CancelledOrder -> throw Exception("キャンセル済みの注文はキャンセルできません") is ShippingOrder -> order.cancel(cancelReason, now) } } } ShippingOrderにはcancelメソッドがないので コンパイルエラー

©2024 Loglass Inc.
呼び出し元
class CancelOrderUs...")
OrderRepository, ) { fun execute(orderId: OrderId, cancelReason: String?) { val order = orderRepository.findById(orderId) ?: throw Exception("注文が見つかりませんでした。ID: ${orderId.value}") val now = LocalDateTime.now() when (order) { is UnconfirmedOrder -> throw Exception("未確定の注文はキャンセルできません") is ConfirmedOrder -> order.cancel(cancelReason, now) is CancelledOrder -> throw Exception("キャンセル済みの注文はキャンセルできません") is ShippingOrder -> throw Exception("発送済みの注文はキャンセルできません") } } } 結局呼び出し元に押し付けただけでは? 🤔 これ意味あるの?

©2024 Loglass Inc.
ルールを型で表現できた
という観点で意味があります
")

©2024 Loglass Inc.
ルールとハンドリングを分けて考える
ルールの適用 ...")

©2024 Loglass Inc.
ルールとハンドリングを分けて考える
ルールの適用 ...")
Order { fun cancel( cancelReason: String?, now: LocalDateTime ): CancelledOrder {...} } when (order) { is UnconfirmedOrder -> throw Exception("未確定の注文は ~") is ConfirmedOrder -> order.cancel(cancelReason, now) is CancelledOrder -> throw Exception("キャンセル済みの注文は ~") is ShippingOrder -> throw Exception("発送済みの注文は ~") } ルール違反を どう伝えるか?

©2024 Loglass Inc.
ルールとハンドリングを分けて考える
ルールの適用 ...")

©2024 Loglass Inc.
ルールとハンドリングを分けて考える
ルールの適用 ...")

©2024 Loglass Inc.
関数の全域性から考えてみる
")

©2024 Loglass Inc.
関数の全域性 / 全域関数(Totality / ...")
A mathematical function links each possible input to an output. In functional programming we try to design our functions the same way, so that every input has a corresponding output. These kinds of functions are called total functions. —『Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F#』Scott Wlaschin著 訳)数学の関数は、可能性のある各入力を出力に結びつけます。関数型プログラミングで は、すべての入力が対応する出力を持つ ように、同じように関数を設計しようとします。この ような関数は全域関数と呼ばれます。

©2024 Loglass Inc.
注文のキャンセルに話を戻す

©2024 Loglass Inc.
正常な値を返す範囲まで入力を絞り
全域関数にする...")

©2024 Loglass Inc.
ロジック内でDBアクセスしたいところ
")

©2024 Loglass Inc.
例えばここ
")

©2024 Loglass Inc.
どうするか?
class Unconfirmed...")
ProductRepository // DI ) { companion object { // Kotlinのstatic method記法 fun create( customerId: CustomerId, // ユーザー入力 shippingAddress: CreateAddressParam, // ユーザー入力 lines: List, // ユーザー入力 ): UnconfirmedOrder { val products = productRepository.listBy(...) … } } - ProductRepositoryをDIする? ProductRepositoryの全てに依存してしま ユニットテストが書きづらくなる。

©2024 Loglass Inc.
どうするか?
class CreateOrder...")
ProductRepository, private val orderRepository: OrderRepository, ) { fun execute(param: CreateOrderParam) { val order = Order.UnconfirmedOrder .create( param.customerId, param.shippingAddress, param.lines, { productId -> productRepository.findById(productId) }, ) orderRepository.insert(order) } } - 呼び出し元 class UnconfirmedOrder(...) { companion object { fun create( customerId: CustomerId, shippingAddress: CreateAddressParam, lines: List, getProduct: (ProductId) -> Product?, ): UnconfirmedOrder { … } } }

©2024 Loglass Inc.
どうするか? class CreateOrder...")
ProductRepository, private val orderRepository: OrderRepository, ) { fun execute(param: CreateOrderParam) { val targetProductIds = param.lines.map { line -> line.productId } val targretProducts = productRepository.listByIds(targetProductIds) val order = Order.UnconfirmedOrder .create( …, { productId -> targetProducts.find { product -> product.id == productId } } ) orderRepository.insert(order) } } - 呼び方を工夫すればよい - getProduct内の処理を どう注入するかは自由

©2024 Loglass Inc.
どうするか? class CreateOrder...")
ProductRepository, private val orderRepository: OrderRepository, ) { fun execute(param: CreateOrderParam) { val targetProductIds = param.lines.map { line -> line.productId } val targretProducts = productRepository.listByIds(targetProductIds) val order = Order.UnconfirmedOrder .create( …, { productId -> targetProducts.find { product -> product.id == productId } } ) orderRepository.insert(order) } } - 呼び方を工夫すればよい - getProduct内の処理を どう注入するかは自由 ①: 今回の注文で参照している全ての 商品IDのリストを取得

©2024 Loglass Inc.
どうするか? class CreateOrder...")
ProductRepository, private val orderRepository: OrderRepository, ) { fun execute(param: CreateOrderParam) { val targetProductIds = param.lines.map { line -> line.productId } val targretProducts = productRepository.listByIds(targetProductIds) val order = Order.UnconfirmedOrder .create( …, { productId -> targetProducts.find { product -> product.id == productId } } ) orderRepository.insert(order) } } - 呼び方を工夫すればよい - getProduct内の処理を どう注入するかは自由 ②: 上で取得した商品IDで 今回使いたい商品だけを先に取得

©2024 Loglass Inc.
どうするか? class CreateOrder...")
ProductRepository, private val orderRepository: OrderRepository, ) { fun execute(param: CreateOrderParam) { val targetProductIds = param.lines.map { line -> line.productId } val targretProducts = productRepository.listByIds(targetProductIds) val order = Order.UnconfirmedOrder .create( …, { productId -> targetProducts.find { product -> product.id == productId } } ) orderRepository.insert(order) } } - 呼び方を工夫すればよい - getProduct内の処理を どう注入するかは自由 ③: ロジック中に欲しい商品は 先読みした商品リストから取得。 ここでDBアクセスは発生しない

©2024 Loglass Inc.
ドメインモデルで外部の依存を
扱いたい場合は高階...")

©2024 Loglass Inc.
最後にまとめ
関数型のエッセンスを適度に取り入れ...")

©2024 Loglass Inc.
最後にまとめ
nullableたくさんで
ややこ...")

©2024 Loglass Inc.
最後にまとめ
代数的データ型で
モデルの状態を
...")

©2024 Loglass Inc.
最後にまとめ
全域関数を使って
状態遷移を型で表現
")

©2024 Loglass Inc.
最後にまとめ
高階関数を使うことで
ドメインモデ...")

©2024 Loglass Inc.
最後にまとめ
関数型のエッセンスを適度に取り入れ...")

")