読書メモ:Domain Modeling Made Functional (Part 2, Chapter 4~7)
概要
サンプルコードをRustに置き換えて遊んでたリポジトリは以下
Domain Modeling Made Functional の読書メモシリーズ は こちら
Part2 Modeling the Domain
ここからはサンプルコードが出てくるが、F#なのでRustに書き直しながら理解を深める
Chapter4 Understanding Types
Type Signature
fn add1(x: isize, y: isize) -> isize {
x + y
}
fn add(x: isize, y: isize) -> impl Fn(isize, isize) -> isize {
move |x, y| x + y
}
fn square_plus_one(x: isize) -> isize {
let square = move |x| x * x;
square(x) + 1
}
Functions with Generic Types
fn are_equal<T: PartialEq>(x: T, y: T) -> bool {
x == y
}
"Values" vs. "Objects" vs. "Variables"
- 関数型言語ではよく "values" を使うが、OOPではよく"objects" を使う
- Values は immutable (なのでvariablesとは呼ばない) なただのデータ
- Objects はデータと振る舞いをカプセル化したものなのでstateを持ったデータ
- 関数型言語では "Values" を基本的に使う( "Objects" や "Variables" よりも)
Composition of Types
struct AppleFruit {}
struct BananaFruit {}
struct CherriesFruit {}
// AND type
struct FruitSnack {
apple: AppleFruit,
banana: BananaFruit,
cherries: CherriesFruit,
}
// OR type
enum FruitSnackEnum {
Apple(AppleFruit),
Banana(BananaFruit),
Cherries(CherriesFruit),
}
"Product Types" vs. "Sum Types"
- AND type は "Product Types" とも呼ばれる
- OR type は "Sum Types" , "Tagged Unions", "Discriminated union", "Chois types" とも呼ばれる
Simple Types
type Kilometers = i32;
rust だと型エイリアスのこと? -> chaper5読んでわかった違う。 引数1つしか取らない tuple type の struct のこと
struct Kilometers(i32);
Building a Domain Model by Composing Types
type CheckNumber = i32;
type CardNumber = String;
enum CardType {
Visa,
MasterCard,
}
struct CreditCardInfo {
card_type: CardType,
card_number: CardNumber,
}
enum PaymentMethod {
Cash,
Check(CheckNumber),
Card(CreditCardInfo),
}
type PaymentAmount = i64;
enum Currency {
EUR,
USD,
}
struct Payment {
amount: PaymentAmount,
currency: Currency,
method: PaymentMethod,
}
// PayInvoice = UnpaidInvoice -> Payment -> PaidInvoice
// ConvertPaymentCurrency = Payment -> Currency -> Payment
enum に値渡すのどう使うんだと思ってたけど、こういう使い方すれば良さそう
Modeling Optional Values
普通にOption使えばいいぐらいの話だった。
Modeling Errors
これもResult使えば良さそう。
Modeling No Value at All
これも ()
unit型 を使えば良さそう
Modeling Lists and Collections
これも(ry
Organizing Types in File and Projects
domainごとに以下の2ファイル作る構成にするらしい(あんまりメリットとかはピンときてない)
Foo.Types.rs
Foo.Functions.rs
Chapter5 Domain Modeling with Types
Seeing Patterns in Domain Model
- Simple values
- OrderId とか ProductCode とか int や String だけど名前のついているもの
- Combinations of values with AND
- address , order など
- Choices with OR
- quote, unitQuantity など
- Workflow
- input -> output に変換するビジネスプロセス
Modeling Simple Values
struct CustomerId(i64);
struct OrderId(i64);
fn process_customer_id(id: &CustomerId) -> String {
let inner_val = id.0; // 内部的な値を取り出して使うこともできる
format!("id:{}", inner_val)
}
fn sample() {
let id = OrderId(42);
process_customer_id(&id); // ここでエラーになる
}
実態が同じ i64
でも取り違えた時に型エラーにできるように simple values を作っておいた方がいい
制約をかけてsimple valuesを生成する方法については次のchapterで
リアルタイム性が必要で性能面で気になるなら型エイリアスを使う方法もある(型の安全性は若干失われるが)
Modeling Complex Data
徐々に作っていくので、最初は Unknown Type を使って徐々にモデリングしていく
use std::error::Error;
type Undefined = dyn Error;
type CustomerInfo = Undefined;
type SippingAddress = Undefined;
type BillingAddress = Undefined;
type OrderLine = Undefined;
type BillingAmount = Undefined;
struct Order {
customer_info: CustomerInfo,
sipping_address: SippingAddress,
billing_address: BillingAddress,
order_lines: Vec<OrderLine>,
billing_amount: BillingAmount,
}
Choice Type はこんな感じ
enum ProductCode {
WidgetCode(i32),
GizmoCode(i32)
}
enum OrderQuantity {
UnitQuantity(i32),
Kilogram(i32)
}
Modeling Workflows with Functions
Function で 1step づつ方変換しながら workflow を実装していく的なことが書いてある。ここはまあそうだよねぐらい。
A Question of Identity: Value Objects
DDDのValue Objects の説明。Rust的には単に PartialEq
をderiveすると全要素で比較されるので実現できる。
#[derive(Debug, PartialEq)]
struct WidgetCode(String);
#[derive(Debug, PartialEq)]
struct PersonalName {
first_name: String,
last_name: String,
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_widget_code_1() {
let widget_code1 = WidgetCode("W1234".to_string());
let widget_code2 = WidgetCode("W1234".to_string());
assert_eq!(widget_code1, widget_code2)
}
#[test]
fn test_widget_code_2() {
let widget_code1 = WidgetCode("W1234".to_string());
let widget_code2 = WidgetCode("W12345".to_string());
assert_ne!(widget_code1, widget_code2)
}
#[test]
fn test_name_1() {
let name1 = PersonalName {
first_name: "aaa".to_string(),
last_name: "bbb".to_string(),
};
let name2 = PersonalName {
first_name: "aaa".to_string(),
last_name: "bbb".to_string(),
};
assert_eq!(name1, name2)
}
#[test]
fn test_name_2() {
let name1 = PersonalName {
first_name: "aaa".to_string(),
last_name: "bbb".to_string(),
};
let name2 = PersonalName {
first_name: "ccc".to_string(),
last_name: "ddd".to_string(),
};
assert_ne!(name1, name2)
}
}
A Question of Identity: Entities
Entities(一意識別できるIDを持ったもの)をどう実装するか。Rustでは PartialEq
を自前で実装することで実現できそう。
#[derive(Debug, Clone)]
struct ContactId(i64);
#[derive(Debug, Clone)]
struct Contact {
contact_id: ContactId,
phone_number: String,
email_address: String,
}
impl PartialEq for Contact {
fn eq(&self, other: &Self) -> bool {
self.contact_id.0 == other.contact_id.0
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_equal() {
let contact_a = Contact {
contact_id: ContactId(1),
phone_number: "123-4567".to_string(),
email_address: "aaaaa@example.com".to_string(),
};
let contact_b = Contact {
phone_number: "890-1234".to_string(),
email_address: "bbbb@example.com".to_string(),
..contact_a.clone()
};
assert_eq!(contact_a, contact_b)
}
#[test]
fn test_not_equal() {
let contact_a = Contact {
contact_id: ContactId(1),
phone_number: "123-4567".to_string(),
email_address: "aaaaa@example.com".to_string(),
};
let contact_b = Contact {
contact_id: ContactId(2),
..contact_a.clone()
};
assert_ne!(contact_a, contact_b)
}
}
基本的にimmutableなデータを使ってworkflowを構築していくが、Entityは状態が変わる可能性がある。その場合は clone して一部書き換えたものを使うというユースケースが頻繁に発生する。RustだとEntityにはCloneをderiveしておいた方が良さそう。
Aggregate
Order と OrderLine のようにどちらも Entity だが依存関係がある場合、依存関係のトップレベルのEntityをAggregateと呼ぶ。
#[derive(Debug, PartialEq, Clone)]
struct Quantity(i64);
#[derive(Debug, PartialEq, Clone)]
struct Price(i64);
#[derive(Debug, PartialEq, Clone)]
struct OrderLineId(i64);
#[derive(Debug, PartialEq, Clone)]
struct CustomerId(i64);
#[derive(Debug, PartialEq, Clone)]
struct ProductCode(String);
#[derive(Debug, PartialEq, Clone)]
struct Product {
product_code: ProductCode,
}
#[derive(Debug, PartialEq, Clone)]
struct OrderLine {
order_line_id: OrderLineId,
product: Product,
quantity: Quantity,
price: Price,
}
#[derive(Debug, PartialEq, Clone)]
struct Order {
order_lines: Vec<OrderLine>,
customer_id: CustomerId,
total_price: Price,
}
fn change_order_line_price(order: &Order, order_line_id: &OrderLineId, new_price: &Price) -> Order {
let order_line_pos = order
.order_lines
.iter()
.position(|line| line.order_line_id == *order_line_id)
.unwrap(); // ほんとはエラー処理必要
let order_line = order.order_lines.get(order_line_pos).unwrap().clone();
let new_order_line = OrderLine {
price: new_price.clone(),
..order_line
};
let mut new_order_lines = order.order_lines.clone();
let _old_order_line = std::mem::replace(&mut new_order_lines[order_line_pos], new_order_line);
let new_total_price = new_order_lines
.iter()
.map(|line| line.price.0 * line.quantity.0)
.sum::<i64>();
Order {
order_lines: new_order_lines,
total_price: Price(new_total_price),
..order.clone()
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_change_order_line_price() {
let order_line = OrderLine {
order_line_id: OrderLineId(1),
product: Product {
product_code: ProductCode("W-123".to_owned()),
},
quantity: Quantity(1),
price: Price(1_000),
};
let order = Order {
order_lines: vec![
OrderLine {
order_line_id: OrderLineId(1),
..order_line.clone()
},
OrderLine {
order_line_id: OrderLineId(2),
..order_line.clone()
},
],
customer_id: CustomerId(1),
total_price: Price(2_000),
};
let expected = Order {
order_lines: vec![
OrderLine {
order_line_id: OrderLineId(1),
price: Price(2_000),
..order_line.clone()
},
OrderLine {
order_line_id: OrderLineId(2),
..order_line.clone()
},
],
customer_id: CustomerId(1),
total_price: Price(3_000),
};
let actual = change_order_line_price(&order, &OrderLineId(1), &Price(2_000));
assert_eq!(actual, expected)
}
}
Chapter6 Integrity and Consistency in the Domain
- integrity (or validity):データが正しくビジネスルールに沿っているかどうか
- consistency:ドメインモデルの異なるパーツの関係性が事実と一致しているか
IntegrityとConsistencyを区別せずにvalidityって言ってしまってた気がする
The Integrity of Simple Values
use anyhow::{anyhow, Result};
#[derive(Debug, PartialEq, Clone)]
struct UnitQuantity(i64);
impl UnitQuantity {
fn try_new(quantity: i64) -> Result<Self> {
if quantity < 1 {
return Err(anyhow!("UnitQuantity can not be negative"));
} else if quantity > 1_000 {
return Err(anyhow!("UnitQuantity can not be more then 1_000"));
}
Ok(UnitQuantity(quantity))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unit_quantity() {
let actual = UnitQuantity::try_new(100).unwrap();
assert_eq!(actual, UnitQuantity(100));
}
}
rust でやる場合は try_new を生やせば良さそう。デフォルトの構造体生成の口は潰せないので、new or try_new を使うように気をつける必要はありそう。
Unit of Measure
単位を表す型を使う方法。これは便利そうだけどRustの標準の機能だと表現できなさそう。uom とか使えばできそうではある。
Endorcing Invariants with the Type System
incariants: 状態が不変なもの。空ではない配列の例などが出ているがこれもRust標準だと難しそう nonempty などを使う必要がある(F#でも標準では無理そう)
Capturing Business rule in the Type System
validかどうかや、片方必須などをロジックではなく型で表現する方法
#[derive(Debug, PartialEq, Clone)]
struct EmailAddress(String);
#[derive(Debug, PartialEq, Clone)]
struct VerifiedEmailAddress(String);
enum CustomerEmail {
Verified(VerifiedEmailAddress),
Unverified(EmailAddress),
}
struct EmailContactInfo {
email: VerifiedEmailAddress,
}
struct PostalContactInfo {
postal_code: String,
}
enum ContactInfo {
Email(EmailContactInfo),
Postal(PostalContactInfo),
Both(EmailContactInfo, PostalContactInfo),
}
struct Contact {
name: String,
contact_info: ContactInfo,
}
#[derive(Debug, PartialEq, Clone)]
struct UnvalidatedAddress(String);
#[derive(Debug, PartialEq, Clone)]
struct ValidatedAddress(String);
fn address_validation_service(address: &UnvalidatedAddress) -> Option<ValidatedAddress> {
let address_str = address.clone().0;
if address_str.len() < 1 {
return None;
}
Some(ValidatedAddress(address_str))
}
struct UnvalidatedOrder {
sipping_address: UnvalidatedAddress,
}
struct ValidatedOrder {
sipping_address: ValidatedAddress,
}
Consistency
Aggregate が一つの場合、Aggregateへの操作はatomicになるはずなのでその操作の中でConsistencyを保てるようなコードを書く必要がある 異なるコンテキストを跨ぐ場合、APIを使ってConsistencyを確認したりする。 スターバックスは2フェーズコミットを使わない にもあるように実際のビジネスの現場においてはスループット最大化のために厳格なConsistencyチェックが行えない場合もある
同じコンテキストのAggregateを跨ぐ場合 "トランザクション毎に一つAggregateしか更新されない" の原則を守ればいいが、A->Bに預金を移動させるような場合そうもいかない。そういう場合はAccountというAggregate2つを更新するのではなく。Money TransferのようなAggregateを作って2つのアカウントのIDのみを持たせるなどする。
Chapter7 Modeling Workflows as Piplines
Workflow Input
Command系のinputをどうモデリングしていくかの実例
Modeling an Order as a Set of States
Orderに対するworkflowを考えるときに、一つのモデルに複数のflagを持つようなモデリングより、state毎に型を変えてしまうようなモデリングの方が適しているという話
State Machines
操作によって状態が移り変わっていくものを表すのにstate machineを使う
- Verified Email Address <-> Unverified Email Address
- Empty Cart -> Actiive Cart -> Paid Cart
なぜstate machineを使うのか?
- 各stateが異なる妥当な振る舞いを持つ場合
- 全てのstateが明確にdocumentedな場合
- 起こりうる全ての可能性を考慮するためのデザインツールとして
Rustでstate machineを作ってみる
struct Item {}
struct ActiveCartData {
unpaid_items: Vec<Item>,
}
struct PaidCartData {
paid_items: Vec<Item>,
payment: f64,
}
enum ShoppingCart {
EmptyCart,
ActiveCart(ActiveCartData),
PaidCart(PaidCartData),
}
fn add_cart(cart: ShoppingCart, item: Item) -> ShoppingCart {
match cart {
ShoppingCart::EmptyCart => {
let mut items = Vec::new();
items.push(item);
ShoppingCart::ActiveCart(ActiveCartData {
unpaid_items: items,
})
}
ShoppingCart::ActiveCart(mut data) => {
data.unpaid_items.push(item);
ShoppingCart::ActiveCart(data)
}
ShoppingCart::PaidCart(data) => ShoppingCart::PaidCart(data),
}
}
fn make_payment(cart: ShoppingCart, payment: f64) -> ShoppingCart {
match cart {
ShoppingCart::EmptyCart => ShoppingCart::EmptyCart,
ShoppingCart::ActiveCart(data) => ShoppingCart::PaidCart(PaidCartData {
paid_items: data.unpaid_items,
payment,
}),
ShoppingCart::PaidCart(data) => ShoppingCart::PaidCart(data),
}
}
Modeling Each Step in the Workflow with Types
- validation step
- Unvalidated Order -> Result<ValidatedOrder, ValidationError>
- pricing step
- ValidatedOrder -> PricedOrder
- acknowledge order step
- PricedOrder からacknowledgeを送る
Documenting Effects
- 非同期処理などは Async<Result<T, E>> などで影響を表す
The Complete Pipline
main_api.rs
// -------------------------------------------
// Input data
// -------------------------------------------
pub struct UnvalidatedOrder {
order_id: i32,
customer_info: UnvalidatedCustomer,
sipping_address: UnvalidatedAddress,
}
pub struct UnvalidatedCustomer {
name: String,
email: String,
}
pub struct UnvalidatedAddress {
street: String,
city: String,
state: String,
zip: String,
}
// -------------------------------------------
// Input Command
// -------------------------------------------
pub struct Command<Data> {
data: Data,
timestamp: String,
user_id: String,
}
pub type PlaceOrderCommand = Command<UnvalidatedOrder>;
// -------------------------------------------
// Public API
// -------------------------------------------
pub struct OrderPlaced {
order_id: i32,
}
pub struct BillableOrderPlaced {
order_id: i32,
}
pub struct OrderAcknowledgementSent {
order_id: i32,
}
/// Success output of PlaceOrder workflow
pub enum PlaceOrderEvent {
OrderPlaced(OrderPlaced),
BillableOrderPlaced(BillableOrderPlaced),
OrderAcknowledgementSent(OrderAcknowledgementSent),
}
/// Failure output of PlaceOrder workflow
pub struct PlaceOrderError {
order_id: i32,
reason: String,
}
pub trait PlaceOrderWorkflow {
fn place_order(command: PlaceOrderCommand) -> Result<Vec<PlaceOrderEvent>, PlaceOrderError>;
}
place_order_workflow
use crate::main_api::{
PlaceOrderCommand, PlaceOrderError, PlaceOrderEvent, UnvalidatedAddress, UnvalidatedCustomer,
UnvalidatedOrder,
};
// -------------------------------------------
// Order life cycle
// -------------------------------------------
struct CustomerInfo {}
struct Address {}
// validate state
struct ValidatedOrderLine {}
struct ValidatedOrder {
order_id: String,
customer_info: CustomerInfo,
sipping_address: Address,
billing_address: Address,
order_lines: Vec<ValidatedOrderLine>,
}
// priced state
struct PricedOrderLine {}
struct PricedOrder {
order_id: String,
customer_info: CustomerInfo,
sipping_address: Address,
billing_address: Address,
order_lines: Vec<PricedOrderLine>,
}
// all states combined
enum Order {
Unvalidated(UnvalidatedOrder),
Validated(ValidatedOrder),
Priced(PricedOrder),
}
// -------------------------------------------
// Definitions of Internal Step
// -------------------------------------------
struct ProductCode {}
// ----- Validate order -----
// services used by validate_order
trait CheckProductCodeExists {
fn check_product_code_exists(product_code: &ProductCode) -> bool;
}
struct AddressValidationError {}
struct CheckedAddress {}
trait CheckAddressExists {
fn check_address_exists(
address: &UnvalidatedAddress,
) -> Result<CheckedAddress, AddressValidationError>;
}
struct ValidationError {}
trait ValidateOrder {
fn validate_order(
check_product_code_exists: impl CheckProductCodeExists,
check_address_exists: impl CheckAddressExists,
order: UnvalidatedOrder,
) -> Result<ValidatedOrder, Vec<ValidationError>>;
}
// ----- Price order -----
// services used by price_order
trait GetProductPrice {
fn get_product_price(product_code: &ProductCode) -> f32;
}
struct PricingError {}
trait PriceOrder {
fn price_order(
get_product_price: impl GetProductPrice,
order: ValidatedOrder,
) -> Result<PricedOrder, PricingError>;
}