Skip to content

Instantly share code, notes, and snippets.

@chupaaaaaaan
Last active May 20, 2020 15:20
Show Gist options
  • Save chupaaaaaaan/5fc190812b73f434f1a6c4827891292b to your computer and use it in GitHub Desktop.
Save chupaaaaaaan/5fc190812b73f434f1a6c4827891292b to your computer and use it in GitHub Desktop.
Domain Modeling Made Functional 読書メモ 第5章

Domain Modeling with Types

Reviewing the Domain Model

省略。(1〜3章のおさらい)

Seeing Patterns in a Domain Model

ドメインモデルを構築する際に頻出するパターンを見る。

  • Simple values
  • Combinations of values with AND
  • Choices with ORdered
  • Workflows

Modeling Simple Values

newtype を使ってドメインの型を定義すると安全だよ、という話。 ちなみに、Haskellでは newtype を剥がすのにかかるコストは0なので、気軽に使える。 type だと型安全が失われてしまうので使用しないようにする。 Haskellの入門記事にもよく出てくる話題のため、詳しいことは省略。

Modeling Complex Data

直積型/直和型の話。 また、 undefined をうまく使って、設計段階で実装がよくわからない状態でも型でモデリングしよう、とのこと。 Haskellの入門書には必ず出てくる話題なので、省略。

Modeling Workflows with Functions

ワークフローを「関数型」で表現する。

type ValidateOrder = UnvalidatedOrder -> ValidatedOrder

みたいな感じ。

Working with Complex Inputs and Outputs

まずワークフローのアウトプットの検討から始める。 ワークフローのアウトプットが outputA AND outputB のようになる場合(同時に複数のアウトプットを持つ場合)。 ‘order-placing’のドメインモデルによれば、レコード型として

data PlaceOrderEvents = PlaceOrderEvents { acknowledgmentSent :: AcknowledgmentSent
                                         , orderPlaced :: OrderPlaced
                                         , billableOrderPlaced BillableOrderPlaced
                                         }

のようにアウトプットを定義できる。

このときは、 UnvalidatedOrder をインプットとして、ワークフローを

type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents

のようにかける。

他方、ワークフローのアウトプットが outputA OR outputB のようになる場合(いずれかのアウトプットとなる場合)。 ‘categorizing the inbound mail as quotes or orders’ (来たメールが見積もりか注文かのカテゴライズを行う)のケースでは、 CategorizedMail をメールカテゴリを表す型として、選択型を用いて

newtype EnvelopeContents = EnvelopeContents String
data CategorizedMail = Quote QuoteForm
                     | Order OrderForm
type CategorizeInboundMail = EnvelopeCOntents -> CategorizedMail

となる。

次にワークフローのインプットを検討する。 インプットが OR で表される選択型であれば、そのまま使えばよい。 インプットが AND であれば、2つのアプローチがある。

関数の引数として分割して渡す
type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder
    
新しいレコードを定義して渡す
data CalculatePricesInput = CalculatePricesInput { orderForm :: OrderForm
                                                 , productCatalog :: ProductCatalog
                                                 }
type CalculatePrices = CalculatePricesInput -> PricedOrder
    

上のケースでは、 ProductCatalog が「本当のインプット」というよりは依存性により必要になっているだけなので、 新しいレコード型を定義するよりは関数の引数として渡したほうが良さげ(とある)。 こうすると、いわゆるDependency Injectionと同様の機能を使えることになる。

一方で、もし2つのインプットが常に両方とも必要で、互いに強く結びついているなら後者のアプローチが良い。 (タプルで渡す方法もあるが、レコード型を新しく定義するほうが良い。)

Documenting Effects in the Function Signature

上で挙げた

type ValidateOrder = UnvalidatedOrder -> ValidatedOrder

は、常に ValidatedOrder を返すように見えるが、実際にはいつも成功するわけではない。 というわけで、失敗するかもしれない型で書く。

type ValidateOrder = UnvalidatedOrder -> Either ValidationError ValidatedOrder
data ValidationError = ValidationError { fieldName :: String, errorDescription :: String }

もしくは

type ValidateOrder = UnvalidatedOrder -> Except ValidationError ValidatedOrder
data ValidationError = ValidationError { fieldName :: String, errorDescription :: String }

これもhaskell入門書にはよく出てくる話題。

上は「エラーが発生するかもしれない」という文脈だが、「非同期」という文脈を重ねたいときは? →モナド変換子の出番!

A Question of Identity: Value Objects

DDDの文脈では、エンティティ及び値オブジェクトが出てくる。最初に値オブジェクトから検討する。

値オブジェクトとは、オブジェクト同士の同等性は評価できるが、同一性は持たないもの。 すなわち、2つのオブジェクトで、オブジェクト内の値がすべて同じであれば、一方を他方で置き換えられる。

実装の際は、新しい型を定義したときに、 deriving (Eq) しておくと良い。

A Question of Identity: Entities

エンティティとは、それを構成するものが変わっても、同一性を持っているもの。例として、

名前や住所が変わっても同じ人。
注文
全く同じ内容を持つ2つの注文は、別の注文。
顧客情報

これらはライフサイクルを持つ。詳しいことはDDD本に書いてあるので省略。

Identifiers for Entities

いわゆるIdやキーを使って、エンティティの同一性を検証する。 例えば、 Contract について、以下のようになる。

newtype ContractId = ContractId Int deriving (Eq)
data Contract = Contract { contractId :: ContractId
                         , phoneNumber :: ...
                         , emailAddress :: ...
                         }
instance Eq Contract where
    c1 == c2 = contractId c1 == contractId c2

(実際には、UUIDなどでIdを生成する必要がある)

Adding Identifiers to Data Definitions

選択型の場合には、Idをどこに入れるかで2つのアプローチがある。

outside approach
newtype UnpaidInvoiceInfo = UnpaidInvoiceInfo
newtype PaidInvoiceInfo = PaidInvoiceInfo
data InvoiceInfo = UnpaidInvoiceInfo | PaidInvoiceInfo
newtype InvoiceId = InvoiceId Int
data Invoice = Invoice { invoiceId :: InvoiceId
                       , invoiceInfo :: InvoiceInfo
                       }
    

この方法の問題は、例えば「Paid」のInvoiceを扱うための情報が InvoiceIdInvoiceInfo に分散しており、扱いづらいこと。

inside approach
newtype InvoiceId = InvoiceId Int
data UnpaidInvoice = Unpaid { invoiceId :: InvoiceId
                            , ...
                            }
data PaidInvoice = Paid { invoiceId :: InvoiceId
                        , ...
                        }
data Invoice = UnpaidInvoice | PaidInvoice
    

実際に使うときにはこんな感じ。

invoice = Paid { invoiceId = ... }
print $ case invoice of
          Unpaid {..} -> invoiceId
          Paid {..} -> invoiceId
    

Implementing Equality for Entities

前述したとおり、Haskellでは Eq 型クラスのインスタンスを定義すれば良い。 (F#だと、データ型作成時にデフォルトで適用される型クラスがあるのかな?) Idを表すフィールドが複数存在する場合 (例えば、 OrderLine でId用のフィールドとして OrderIdProductId が存在する場合)も、 同様に Eq 型クラスのインスタンスを定義すれば良い。

Immutability and Identity

いわゆる関数型プログラミングでは、値はデフォルトでイミュータブルである。 このとき、

値オブジェクトでは
値オブジェクトのどこかが変更されるときには、新しいオブジェクトを作成する。
エンティティでは
エンティティをコピーして、変更された情報で置き換える。

Aggregates

ここでは、 Order (1つの注文)と OrderLine (注文の明細の一行に対応する)を見る。 Order は、明らかにエンティティである。 OrderLine も、例えば数量を変更したとしても同一性が崩れるわけではないので、これもエンティティとみなせる。

では、 OrderLine が変更されたとき、それが所属する Order も変更されるのか?→Yes. OrderLine が変更されても、自動的に Order が変更されるわけではないことに注意。

changeOrderLinePrice order orderLineId newPrice = let orderLine = filter (\x -> olId x == orderLineId) $ orderLines order
                                                      newOrderLine = orderLine { price = newPrice }
                                                      newOrderLines = (:) newOrderLine $ filter (\x -> olId x /= orderLineId) $ orderLines order
                                                  in order { orderLine = newOrderLines }

みたいな感じになるかな?(ちょっと実装が非効率)

こんなふうに、「サブエンティティ」の一つを変更しただけでも、 Order レベルで作業する必要がある。 それぞれが自身のIDを持つエンティティのコレクションがあり、それらを含むトップレベルのエンティティもある。 こういった状況は、DDDでは「集約」と予備、トップレベルのエンティティを集約ルートと呼ぶ。

Aggregates Enforce Consistency and Invariants

集約は整合性の境界として働く。 例えば、 Order が注文の合計金額を持っている場合。 changeOrderLinePrice で価格を変更したとき、合計金額も変更しないと、整合性が取れない。 どのように整合性を取るかを知っているのは、集約ルートのみ。なので、すべての更新は集約ルートのレベルで実施すべき。

また、集約内では不変性が強制される。 例えば、 OrderLine が少なくとも1件存在すること、という不変条件があれば、 OrderLine が1件しかないときに OrderLine の削除を試みるとエラーになる。

Aggregate Reference

Order に関連付けて Customer の情報も持ちたい場合。

data Order = Order { orderId :: OrderId
                   , customerId :: CustomerId
                   , orderLines :: [OrderLine]
                   }

のようにする。直接 Customer を持つと、 Customer が更新された場合に同時に Order も更新する必要があり、つらい。 このようにした場合、 Customer の情報すべてが欲しい場合、まず CustomerId を抽出してからそれをキーにして検索する、という動きをする。

これはすなわち、 CustomerOrder は異なる独立した集約である、と言える。 各々が各々の内部の整合性に責任を持ち、集約同士のコネクションは集約ルートのidだけで表現する。

他の重要な側面として、集約は一貫性の基本的な単位である。 DB等からデータの取得・保存をするとき、集約全体を取得・保存する必要がある。 各々のDBトランザクションは、単一の集約で実行する。複数の集約や、集約の境界をまたがったトランザクションはだめ。

同様に、オブジェクトをシリアライズするときも、集約全体をシリアライズする。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment