省略。(1〜3章のおさらい)
ドメインモデルを構築する際に頻出するパターンを見る。
- Simple values
- Combinations of values with AND
- Choices with ORdered
- Workflows
newtype
を使ってドメインの型を定義すると安全だよ、という話。
ちなみに、Haskellでは newtype
を剥がすのにかかるコストは0なので、気軽に使える。
type
だと型安全が失われてしまうので使用しないようにする。
Haskellの入門記事にもよく出てくる話題のため、詳しいことは省略。
直積型/直和型の話。
また、 undefined
をうまく使って、設計段階で実装がよくわからない状態でも型でモデリングしよう、とのこと。
Haskellの入門書には必ず出てくる話題なので、省略。
ワークフローを「関数型」で表現する。
type ValidateOrder = UnvalidatedOrder -> ValidatedOrder
みたいな感じ。
まずワークフローのアウトプットの検討から始める。
ワークフローのアウトプットが 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つのインプットが常に両方とも必要で、互いに強く結びついているなら後者のアプローチが良い。 (タプルで渡す方法もあるが、レコード型を新しく定義するほうが良い。)
上で挙げた
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入門書にはよく出てくる話題。
上は「エラーが発生するかもしれない」という文脈だが、「非同期」という文脈を重ねたいときは? →モナド変換子の出番!
DDDの文脈では、エンティティ及び値オブジェクトが出てくる。最初に値オブジェクトから検討する。
値オブジェクトとは、オブジェクト同士の同等性は評価できるが、同一性は持たないもの。 すなわち、2つのオブジェクトで、オブジェクト内の値がすべて同じであれば、一方を他方で置き換えられる。
実装の際は、新しい型を定義したときに、 deriving (Eq)
しておくと良い。
エンティティとは、それを構成するものが変わっても、同一性を持っているもの。例として、
- 人
- 名前や住所が変わっても同じ人。
- 注文
- 全く同じ内容を持つ2つの注文は、別の注文。
- 顧客情報
- …
これらはライフサイクルを持つ。詳しいことはDDD本に書いてあるので省略。
いわゆる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を生成する必要がある)
選択型の場合には、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を扱うための情報が
InvoiceId
とInvoiceInfo
に分散しており、扱いづらいこと。 - 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
前述したとおり、Haskellでは Eq
型クラスのインスタンスを定義すれば良い。
(F#だと、データ型作成時にデフォルトで適用される型クラスがあるのかな?)
Idを表すフィールドが複数存在する場合 (例えば、 OrderLine
でId用のフィールドとして OrderId
と ProductId
が存在する場合)も、
同様に Eq
型クラスのインスタンスを定義すれば良い。
いわゆる関数型プログラミングでは、値はデフォルトでイミュータブルである。 このとき、
- 値オブジェクトでは
- 値オブジェクトのどこかが変更されるときには、新しいオブジェクトを作成する。
- エンティティでは
- エンティティをコピーして、変更された情報で置き換える。
ここでは、 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では「集約」と予備、トップレベルのエンティティを集約ルートと呼ぶ。
集約は整合性の境界として働く。
例えば、 Order
が注文の合計金額を持っている場合。 changeOrderLinePrice
で価格を変更したとき、合計金額も変更しないと、整合性が取れない。
どのように整合性を取るかを知っているのは、集約ルートのみ。なので、すべての更新は集約ルートのレベルで実施すべき。
また、集約内では不変性が強制される。
例えば、 OrderLine
が少なくとも1件存在すること、という不変条件があれば、 OrderLine
が1件しかないときに OrderLine
の削除を試みるとエラーになる。
Order
に関連付けて Customer
の情報も持ちたい場合。
data Order = Order { orderId :: OrderId
, customerId :: CustomerId
, orderLines :: [OrderLine]
}
のようにする。直接 Customer
を持つと、 Customer
が更新された場合に同時に Order
も更新する必要があり、つらい。
このようにした場合、 Customer
の情報すべてが欲しい場合、まず CustomerId
を抽出してからそれをキーにして検索する、という動きをする。
これはすなわち、 Customer
と Order
は異なる独立した集約である、と言える。
各々が各々の内部の整合性に責任を持ち、集約同士のコネクションは集約ルートのidだけで表現する。
他の重要な側面として、集約は一貫性の基本的な単位である。 DB等からデータの取得・保存をするとき、集約全体を取得・保存する必要がある。 各々のDBトランザクションは、単一の集約で実行する。複数の集約や、集約の境界をまたがったトランザクションはだめ。
同様に、オブジェクトをシリアライズするときも、集約全体をシリアライズする。