Upgrade to Pro — share decks privately, control downloads, hide ads and more …

過去の失敗例から再考するモデル駆動設計

 過去の失敗例から再考するモデル駆動設計

過去に僕が失敗した代表例から今ならどう設計するか、という観点でお話します。中心になるトピックは以下です
- 軽量DDDの功罪
- ドメインモデル貧血症対策
- 集約の境界定義の善し悪し

かとじゅん

August 31, 2019
Tweet

More Decks by かとじゅん

Other Decks in Programming

Transcript

  1. © Chatwork あなた誰? • 加藤潤一(@j5ik2o) • テックリード ◦ 大阪本社(福島) 本日も業務扱い

    ◦ レガシーシステム移行プロジェクトやってます • 2009年11月に初めてEric本を読む。これ本当に技 術書なの?(笑)と思いながら読んでた(エンティティ の章) • 2011年和訳本がでるときに翻訳レビューした。大 したことはしてない
  2. © Chatwork 最近の活動 • チケット料金モデリング ◦ 映画の料金を決定するモデルを実装する ◦ 値オブジェクトの設計に注力する ▪

    DBやWebAPIなどの入出力は一旦忘れる ◦ 僕の実装 ◦ 20人ぐらいがコードを書いてくれてた ▪ セプテーニさん、オプトさんでも自主開催してくれた • Qiita:ドメインロジックはドメインオブジェクトに凝集させる ◦ 内部データをそのまま暴露するGetterは貧血症の温床になるので気 を付けろ的な話
  3. © Chatwork アジェンダ • 過去に僕が失敗した代表例から今ならどう設計する か、という観点でお話します。中心になるトピックは 以下です ◦ 軽量DDDの功罪 ◦

    ドメインモデル貧血症対策 ◦ 集約の境界定義の善し悪し • ※失敗はうまくいかない方法を見つけただけ、成功へ のプロセス。Fail Fastで早く学ぶことが失敗の本質
  4. © Chatwork CASE1: 軽量DDDで躓いた話 • おこったこと ◦ DDDを学びはじめた頃、軽量DDDを実践した。開発メンバとだ けユビキタス言語を共有したつもりだった… •

    どうなったか ◦ 開発チームの外側では全然異なる言語が使われてしまい、やは り通訳が必要になってしまった ◦ 分析と設計(実装)が単一モデルになる前提ではないので、エン ティティはテーブルのことだと誤解し実装が手続き型に…。ト ランザクションスクリプト亜種になった 従来のフレームワークで経験した設計方法で判断してしまった
  5. © Chatwork FYI: 軽量DDDとは • 戦術的設計のパターンだけを利用 したDDDのこと • ユビキタス言語を反映しないドメ インモデルが作られる可能性

    ◦ 共通言語基盤に基づかないソ フトウェアを語るときは通訳 が必要になってしまう ◦ ソフトウェアが正しいか言語 によって証明できなくなる エンティティ サービス リポジトリ 戦術的パターン … 戦略的パターン ユビキタス言語 コアドメイン 境界づけられた コンテキスト コンテキストマッ プ こちらだけつまみ食い
  6. © Chatwork 軽量DDD想定が、トランザクションスクリプト亜種に… • この方法が一番簡単 ◦ レイヤ化アーキテクチャのためにパッケージを切る ◦ エンティティとサービスを作る(あれ?) ▪

    データはエンティティ(=テーブルモデル) ▪ ロジックはサービス ◦ データベースアクセスにリポジトリを使う • 実は軽量DDDにもなっていない ◦ エンティティはデータクラス、サービスはロジッククラス。 つまりトランザクションスクリプトになっている… ◦ リポジトリはDAOの代わりになっているだけ… 貧血症オブジェクトになっている
  7. © Chatwork ドメインオブジェクトっぽいが振る舞いがない… • カートの合計金額が上限を超えないなら、カートを追加できる • 属性もプリミティブ型を使っている。本当に正しい? case class Cart(

    id: Int, userAccountId: Int, limitPrice: Int, createdAt: Instant, updatedAt: Instant ) trait CartRepository { // ... } case class CartItem( id: Int, no: Int, itemId: Int, quantity: Int ) trait CartItemRepository { // ... }
  8. © Chatwork ドメインロジックが手続き型で書かれている… • ドメインロジックが手続き型。これは貧血症オブジェクトでありテーブルモデ ル…。このようなコードをアプリケーションサービスに書かれている… // 合計金額を計算 val totalPrice

    = cartItems.foldLeft(0) { case (r, e) => r + resolvePrice(e.itemId) * e.quantity } // 上限金額を超えていないなら require(totalPrice + resolvePrice(4) < cart.limitPrice) // カートにアイテムを追加 val newCartItems = cartItems :+ CartItem(id = 1, no = 4, itemId = 4, quantity = 1) val now = Instant.now val cart = Cart( id = 1, userAccountId = 1, limitPrice = 100, createdAt = now, updatedAt = now ) val cartItems = Seq( CartItem(id = 1, no = 1, itemId = 1, quantity = 1), CartItem(id = 2, no = 2, itemId = 2, quantity = 3), CartItem(id = 3, no = 3, itemId = 3, quantity = 5) )
  9. © Chatwork FYI: ドメインモデル貧血症とは • ドメインモデル貧血症 - Martin Fowler’s Bliki(ja)

    • 本物のドメインモデルに見える。 ◦ オブジェクト同士の関連があり、本物のような構造を持つ ◦ ただし、オブジェクトの振る舞いはわずかしかない ◦ ドメインロジックをドメインオブジェクトの中に入れないルールに 従っている。ドメインロジックはサービスオブジェクト群に存在する • データと処理を統合するアプローチとは真逆 ◦ 単なる手続き型設計(トランザクションスクリプト)と同じ • アプリケーションサービスはドメインロジックを配置する場所ではない、 それはドメインオブジェクトの役割。アプリケーションサービスは進行役
  10. © Chatwork ユビキタス言語を反映しないドメインモデル例 • 実装都合の用語法で作られたオブジェクト群。ビジネス側とエン ジニア側で対応付けが難しくなり、通訳が必要となる ◦ 注文ではなくリクエスト。あいまいな用語が通訳コスト ◦ 注文ではなく注文テーブルと注文詳細テーブル。本来一つの

    概念が複数のテーブルオブジェクトと対応づいている。利害 関係者と話すときは注意が必要 • ビジネスロジックを持たないドメインオブジェクト ◦ Getterしかないオブジェクト。どこでどんな計算が行われる かがわかにくい。ロジックの重複に気づけない
  11. © Chatwork 言語とモデルを作ってソフトウェアに反映する • 利害関係者間で作る・使う ◦ プロジェクトのすべての活動で ◦ 要求分析で使う言語体系を設計 (実装)に反映する

    • ユビキタス言語は要求分析から ◦ 要件「…すること」 ◦ 業務フローや利用シーン ◦ ユースケースやイベント • 右図を議論するなかで言語体系を揃 えていく。考えたモデルはコードに も反映する RDRA2.0ハンドブックより
  12. © Chatwork 関心事に対応する値オブジェクトを導入する • 値オブジェクトを導入する ◦ お金は単なる数値ではない。 量と通貨単位で成り立つ ◦ 加算・減算。減算は加算に

    よって作られている ◦ 通貨単位が同じでないと計算 できない • 値オブジェクトにロジックを凝集 させる • 分析を設計(実装)に反映する。実装 できなければ分析を見直す。要件 も見直すこともある。いったり来 たり final class Money(amount: BigDecimal, currency: Currency) { def negated: Money = new Money(-amount, currency) def add(other: Money): Money = { require(currency == other.currency) Money.adjustBy(amount + other.amount, currency) } def subst(other: Money): Money = add(other.negated) def breachEncapsulationOfAmount: BigDecimal = amount def breachEncapsulationOfCurrency: Currency = currency } val result = Money.yens(10).add(other)
  13. © Chatwork FYI: Time and Money Code Library • Evansが関わった日時とお金のための値オブジェクト・ライブラリ

    ◦ Time and Money Code Library ◦ baseunits-scala (許可を得てScala版としてfork) • 以下は営業日を計算して返すカレンダー値オブジェクト val calendar = BusinessCalendar() .addHolidaySpec(DateSpecification.fixed(1, 1, ZoneIds.Default)) // 元旦 . addHolidaySpec( DateSpecification.nthOccuranceOfWeekdayInMonth(1, DayOfWeek.Monday, 2, ZoneIds.Default) ) // 成人の日 .addHolidaySpec(DateSpecification.fixed(2, 11, ZoneIds.Default)) // 建国記念日 // … // それぞれの日が「営業日」にあたるかどうかチェック。 assert(calendar.isBusinessDay(CalendarDate.from(2010, 10, 8, ZoneIds.Default))) // 金曜日 assert(!calendar.isBusinessDay(CalendarDate.from(2010, 10, 11, ZoneIds.Default))) // 月曜日体育の日
  14. © Chatwork 貧血症のCartオブジェクトを解消した例(1/3) • オブジェクトは自己 防衛する。不正な値 は扱わない • コレクション型はそ のまま扱わずにドメ

    イン固有型を介して 使う • 値オブジェクトのま ま比較や計算ができ るようにする(ビジ ネスルールが守られ たまた計算できる) final class Cart(id: CartId, userAccountId: UserAccountId, limitPrice: Price, unitPriceResolver: ItemId => UnitPrice, cartItems: CartItems, createAt: Instant, updateAt: Instant) { def totalPrice: Price = cartItems.totalPrice(unitPriceResolver) require(limitPrice > totalPrice, "totalPrice is over than limitPrice") def addCartItems(otherCartItems: CartItems): Cart = new Cart(id, userAccountId, limitPrice, unitPriceResolver, cartItems + otherCartItems, createAt, updateAt) }
  15. © Chatwork 貧血症のCartオブジェクトを解消した例(2/3) final class CartItem(id: CartItemId, no: CartNo, itemId:

    ItemId, quantity: Quantity, createAt: Instant, updateAt: Instant) { def price(unitPriceResolver: ItemId => UnitPrice): Price = unitPriceResolver(itemId) * quantity } final class CartItems(values: Seq[CartItem]) { require(values.nonEmpty, ...) def +(other: CartItem): CartItems = new CartItems(values :+ other) def totalPrice(unitPriceResolver: ItemId => UnitPrice): Price = values.foldLeft(Price.zero) { case (totalPrice, cartItem) => totalPrice + cartItem.price(unitPriceResolver) } }
  16. © Chatwork 貧血症のCartオブジェクトを解消した例(3/3) final class UnitPrice(private[carts] val value: Int) {

    require(value > 0) def *(quantity: Quantity): Price = new Price(value * quantity.value) } object Price { val zero = Price(0) } final class Price(private[carts] val value: Int) extends Ordered[Price] { require(value > 0) def +(other: Price): Price = new Price(value + other.value) override def compare(that: Price): Int = value compare that.value }
  17. © Chatwork スケーラブルな設計とは • ドメインオブジェクトと言語の体系が一致している • ドメイン上の値はただのプリミティブ型ではなく、値の範 囲などのルールとそれに基づく計算能力を持つようになっ た •

    ドメインロジックの最小部品を値オブジェクトにして、組 み合わせてより高度な問題を解決する • 不変である値オブジェクトは、想定外の挙動を起こさない ため、どんなに複雑な条件で組み合わせても、予測可能な 設計を作り出せる
  18. © Chatwork CASE2: 集約の境界定義に試行錯誤した話 • 例えば、Conversation, Participants, Messages。 Conversationの一部なのか外部 なのか

    • テーブル脳で考えると、すべて が独立している集約。 • 一方でオブジェクト脳で考える と、Conversationは会話の場を 表すのでParticipants, Messagesを包含した集約 • どちらも試したことがある Conversation Participants Messages Conversation Participants Messages 全てが集約 集約は1つ
  19. © Chatwork すべてが集約の場合 case class Conversation(id: ConversationId, name: ConversationName, planType:

    PlanType) class Participants(values: Seq[Participant]) { def add(participants: Participants): Participants = new Participants(values ++ participants.values) def add(participant: Participant): Participants = ??? def count: Int = values.length } class Messages(values: Seq[Message]) { def add(messages: Messages): Messages = ??? def add(message: Message): Messages = ??? def count: Int = ??? } • 最初に試した実装 • テーブル脳ですべてを集約 として分けた例 • ドメインモデルっぽい?ア プリケーションコードを書 いたら問題が明確になった
  20. © Chatwork アプリケーションサービスのコード(悪い例) • ルールと振る舞いが分離。ルールを違反したコードが書ける…。 • Conversationに関する知識が分散している(貧血症) def addParticipantAndMessage(conversation: Conversation,

    participants: Participants, messages: Messages, participant: Participant, message: Message): (Participants, Messages) = { require(conversation.planType.participantCount >= participants.count + 1) require(conversation.planType.messageCount >= messages.count + 1) val newParticipants = participants.add(participant) val newMessages = messages.add(message) (newParticipants, newMessages) } アプリケーションサー ビスのコード
  21. © Chatwork 試行錯誤した例… • ルール適用と状態操作を一体化する…。でも何か違和感がある… • ファーストコレクションの操作はConversation経由で実行する • 集約が異なれば結果整合性の境界も異なる。プラン変更後も一次的に不 正な状態が観測できるかもしれない

    def addParticipantAndMessage(conversation: Conversation, participants: Participants, messages: Messages, participant: Participant, message: Message): (Participants, Messages) = { val newParticipants = conversation.addParticipant(participants, participant) val newMessages = conversation.addMessage(messages, message) (newParticipants, newMessages) }
  22. © Chatwork 集約が一つの場合(1/2) • Participants, Messagesは Conversationの内部オ ブジェクト。 • ルール適用と振る舞い

    は一つのオブジェクト に統合される • 強い整合性境界にまと めることができる(要件 による) class Conversation(id: ConversationId, name: ConversationName, participants: Participants, messages: Messages, planType: PlanType) { def addParticipant(addition: Participant): Conversation = { require(planType.participantCount > participants.count + 1) newInstance(participants = participants.add(addition)) } def addMessage(addition: Message): Conversation = { require(planType.messageCount > messages.count + 1) newInstance(messages = messages.add(addition)) } }
  23. © Chatwork 集約の境界定義の善し悪し • 大前提 ◦ ユースケースを実現できること Pros Cons すべてが集

    約 • ロックの競合頻度が下がる • 必要に応じてI/Oできる。レ イテンシが有利 • 細かく分けすぎると貧血症に なりやすい • 結果整合性で厳密なビジネ スルールは強制できない 集約がひと つ • ドメイン知識がドメインオブ ジェクトに凝集できる • ロックの競合頻度が上がる • I/Oレイテンシが不利
  24. © Chatwork 集約の境界定義に必要な観点 • ユースケースだけでは境界定義は定まらない • 集約の粒度がレイテンシに影響を与えるのは事実。説明が 難しいモデルは実装も大きくなり、I/Oにもコストも大きく なる傾向にある •

    振る舞いにフォーカスし小さい集約を実現するには、クエ リ責務を他に委譲する必要がある(CQRS, EventSourcing) Conversation def removeMessages(messageIds): Conversation private val messageIds: MessageIds 振る舞いを起こすために 必要な状態しか持たない のが理想
  25. © Chatwork FYI: Event Storming + 値オブジェクト・モデリング • 発見の順序は番号の流れ。Event Storming(Desingn

    Level)では③ま でだが、そこから先はロジックの主役は値オブジェクトに注目する コマンド イベント ③集約 エンティティ ④値オブジェクト ユースケース コマンド ②コマンド イベント ①イベント 状態遷移 振る舞い 利用 必要に応じてイベントを 使ってリードモデルを構 築する アクター
  26. © Chatwork まとめ: これまでを振り返って思うこと • 設計は仮説・検証・フィードバックの連続 ◦ FYI: PDR(Prep→Do→Review→PDR) をやっていこう。PDCAは生産管理用

    • 不確実性コーンを見極めながら、納期だけ ではなく設計もFail Fastしていこう。うま くいかなかったら、それは学びの機会 • 自分一人で解決できなければ、自ら情報を 共有しながら、コミュニティからヒントを 得られるようにしてきたい 不確実性コーン (アジャイルな見積もりと計画作りより )