Slide 1

Slide 1 text

“Designing with types” に学ぶ Kotlin における型を利用したモデリング術 by @omuomugin 2022/12/12 Kotlin Fest Reject Conference 2022

Slide 2

Slide 2 text

自己紹介 佐々木俊亮 (@omuomugin) Job: 株式会社リクルート ソフトウェアエンジニア 結婚領域で新規サービスの開発 SpringBoot (Kotlin) + Next.js 2 / 37

Slide 3

Slide 3 text

今日話すこと Designing with Types と Domain Modeling Made Functional の内容を 中心に時間が限られているので以下に限定して発表します 1. 直和 を用いた状態の表現 2. 状態遷移図の実装 特に書籍の中では、DDD の精神から関数型のテクニックなども扱って いるので興味があればぜひ読んでみてください ※ 直和の説明は後ほどするのでお待ちを 3 / 37

Slide 4

Slide 4 text

発表の元となる書籍とブログの紹介 Designing with Types というブログシリーズ Domain Modeling Made Functional の著者で もある Scott Wlaschin さんが書いたもの ブログシリーズが 2013 年で、本が 2018 年に 出版されており、本の内容の中核はこのブロ グシリーズで話してるものを改変したもの ( 読んだ個人の感想) Scott Wlaschin さんが F# を好んで利用するよ うで、本もブログも F# での説明となっている 4 / 37

Slide 5

Slide 5 text

前提知識編 5 / 37

Slide 6

Slide 6 text

直和と直積 data class Hoge(val a: A, val b: B) // A = {a_1, a_2, a_3, ..., a_n} ( 要素数 n) // B = {b_1, b_2, b_3, ..., b_m} ( 要素数 m) A と B という型はそれぞれ取れる値の集合が定義されてる このとき Hoge の値としての等価を考えたときに Hoge は n * m の値 を取り得る このように取り得る値が 積 によって決まるような性質の型を 代数的データ型 (a.k.a Algebraic Data Type , ADT ) の世界では 直積型 (a.k.a Product Type ) と呼ぶ 前提知識編 6 / 37

Slide 7

Slide 7 text

直和と直積 data class Hoge(val fuga: A | B) // もちろん Kotlin にこんな記法はないのでイメージ // A = {a_1, a_2, a_3, ..., a_n} ( 要素数 n) // B = {b_1, b_2, b_3, ..., b_m} ( 要素数 m) Typescript などを利用したことがある人にとっては馴染みのある | は、 A か B のうちのいずれかである という意味 この場合 Hoge がとり得る値は n + m となる このように取り得る値が 和 によって決まるような性質の型を 代数的データ型 の世界では 直和型 (a.k.a Sum Type , Union , Tagged Union Type ) と呼ぶ 前提知識編 7 / 37

Slide 8

Slide 8 text

Kotlin による直和の表現 Sealed Class を利用するのが一般的 サブクラスがコンパイルタイムで限定できるようにする言語機能 以下の記述は、 Hoge は HogeWithA と HogeWithB のいずれかである という ことを表現してる sealed class Hoge data class HogeWithA(val a: A) : Hoge() data class HogeWithB(val b: B) : Hoge() ※ Sealed Class は、実は Java 17 で Java にも正式に言語機能として追 加されている JEP 409 (JEP = JDK Enhancement Proposal) 前提知識編 8 / 37

Slide 9

Slide 9 text

さて準備ができたので直和を使う例を見ていこう 9 / 37

Slide 10

Slide 10 text

2 つの例に沿って、以下の具体例を紹介 1. 直和 を用いた状態の表現 2. 状態遷移図の実装 例 1: メールアドレスの認証状態 例 2: EC サイトにおけるカートの内容 10 / 37

Slide 11

Slide 11 text

例 1: メールアドレスの認証状態 メールアドレスには、認証済みのものとそうでないものがある 新規作成する場合には常に「未認証のメールアドレス」となる パスワードリセットのメールはセキュリティ観点で「認証済メール アドレス」にしか送信してはならない 11 / 37

Slide 12

Slide 12 text

ドメインオブジェクトのライフサイクル Domain Driven Design の文脈においても語られているようにビジネス ドメインにとって重要なモデルというのは様々な状態を持つ したがって「状態」というものをいかにモデリングしていくかが重要 となる すべてのオブジェクトにはライフサイクルがある。オブジェクト は誕生した後、おそらく様々な状態を経て、最終的には死ぬ。死 ぬと言っても、アーカイブされるか削除されるということだ。 — エリック・エヴァンスのドメイン駆動設計 “ “ 12 / 37

Slide 13

Slide 13 text

( 再掲 ) 例 1: メールアドレスの認証状態 メールアドレスには、認証済みのものとそうでないものがある 新規作成する場合には常に「未認証のメールアドレス」となる パスワードリセットのメールはセキュリティ観点で「認証済メール アドレス」にしか送信してはならない 13 / 37

Slide 14

Slide 14 text

まず簡単に考えると isVerified のようなフラグでの制御が思いつく data class EmailAddress( val address: String val isVerified: Boolean ) // パスワードリセットのメールの送信 fun sendPasswordResetEmail(email: EmailAddress) { if(email.isVerified) { emailSendService.send(to = email.address, body = ....) } } しかしフラグだと「パスワードリセットのメールの送信」の際に isVerified の値を参照し忘れたり、 isVerified を誤って更新・代入 してしまうケースがある → もっと安全かつ明確に状態を表現したい 例1: メールアドレスの認証状態 14 / 37

Slide 15

Slide 15 text

そこで直和を利用して型として 2 つの状態を表現する sealed class EmailAddress(abstract val address: String) data class UnverifiedEmailAddress(override val address: String): EmailAddress() data class VerifiedEmailAddress(override val address: String) : EmailAddress() // パスワードリセットのメールの送信 // 認証済みのメールアドレスに対してのみ呼び出し可能 fun sendPasswordResetEmail(emailAddress: VerifiedEmailAddress) { emailSendService.send(to = emailAddress.address, body = ....) } → 安全かつ明確に状態を表現できている 例1: メールアドレスの認証状態 15 / 37

Slide 16

Slide 16 text

状態遷移図の実装 fun create(emailAddress: String): EmailAddress { return UnverifiedEmailAddress(emailAddress) } fun verfied(email: EmailAddress): EmailAddress { // when には 網羅(exhaustive) 性があるので else などが不要 return when(email) { is UnverifiedEmailAddress -> VerifiedEmailAddress(email.address) is VerifiedEmailAddress -> email // そのまま返す } } 例1: メールアドレスの認証状態 16 / 37

Slide 17

Slide 17 text

補足 : when の exhaustive 性について Sealed Class は、サブクラスが有限であることがわかっているため when の exhaustive 性 の恩恵を受けることができる fun verfied(email: EmailAddress): EmailAddress { // 例えば、 `WaitingVerifyEmailAddress` という状態が // 加わった時には、 `when` がコンパイルエラーを起こしてくれる return when(email) { is VerifiedEmailAddress -> email // そのまま返す is UnverifiedEmailAddress -> VerifiedEmailAddress(email.address) is WaitingVerifyEmailAddress -> {} // ここの分岐がないとコンパイルエラーになる } } 例1: メールアドレスの認証状態 17 / 37

Slide 18

Slide 18 text

Tips 本書の実装からさらに発展させて自分たちのプロダクトで 採用している実装方法を紹介します 18 / 37

Slide 19

Slide 19 text

Tips1: ファクトリを利用する // `create` はファクトリとして用意することでより新規作成する場合には常に「未認証のメールアドレス」となる // という仕様を明確にコードで表現することもできる data class UnverifiedEmailAddress private constructor( override val address: String ): EmailAddress() { companion object { fun create(emailAddress: String): UnverifiedEmailAddress { return UnverifiedEmailAddress(emailAddress) } } } // 利用例 val email = UnverifiedEmailAddress.create("[email protected]") 例1: メールアドレスの認証状態 19 / 37

Slide 20

Slide 20 text

Tips2: ドメインモデルのアクションとして定義する // `verified` を `UnverifiedEmailAddress` のアクションとして定義することで // 状態遷移がより明確に表現することもできる data class UnverifiedEmailAddress( override val address: String ) : EmailAddress() { fun verify(): VerifiedEmailAddress { return VerifiedEmailAddress(email.address) } } // 利用例 // それぞれの結果が Immutable になっていてテスタビリティも高い val unverifiedEmailAddress = UnverifiedEmailAddress.create("[email protected]") val verifiedEmailAddress = unverifiedEmailAddress.verify() 例1: メールアドレスの認証状態 20 / 37

Slide 21

Slide 21 text

なんとなく雰囲気を掴めたと思うのでもう 1 つ の例でも直和を活用してみましょう 21 / 37

Slide 22

Slide 22 text

例 2: EC サイトにおけるカート カートには、「空の状態」、「未購入」、「購入済み」の 3 つの状 態がある アクションとしては以下がある カートにアイテムを追加する 購入する 22 / 37

Slide 23

Slide 23 text

sealed class Cart object EmptyCart : Cart() data class ActiveCart(val unpaidItems: List) : Cart() data class PaidCart(val paidItems: List, val payment: Float) : Cart() -> 3 つの状態が明確に実装から読み取れる ※ field を持たないような Sealed Class の子は object で表現せよとい う lint の warning が出る 例2: EC サイトにおけるカート 23 / 37

Slide 24

Slide 24 text

状態遷移図の実装 (add item) // もちろん Tips2 のように EmptyCart や ActiveCart のアクションとして実装することも可能 fun addItem(cart: Cart, item: Item): Cart { return when(cart) { is EmptyCart -> ActiveCart(listOf(item)) is ActiveCart -> ActiveCart(cart.unpaidItems + item) is PaidCart -> throw Error() } } 例2: EC サイトにおけるカート 24 / 37

Slide 25

Slide 25 text

状態遷移図の実装 (pay) // もちろん Tips2 のように ActiveCart のアクションとして実装することも可能 fun pay(cart: Cart: payment: Float): Cart { return when(cart) { is EmptyCart -> throw Error() is ActiveCart -> PaidCart(cart.unpaidItems, payment) is PaidCart -> throw Error() } } 例2: EC サイトにおけるカート 25 / 37

Slide 26

Slide 26 text

まとめ 2 つの例に沿って、以下の具体例を紹介した 1. 直和 を用いた状態の表現 2. 状態遷移図の実装 例 1: メールアドレスの認証状態 例 2: EC サイトにおけるカートの内容 26 / 37

Slide 27

Slide 27 text

余談 時間が余れば発表する Null Object Pattern 直和でしか表現できないデータ構造 27 / 37

Slide 28

Slide 28 text

余談 1: Null Object Pattern 28 / 37

Slide 29

Slide 29 text

if (customer == null) { plan = BillingPlan.basic(); } else { plan = customer.plan; } doSomething(plan) 上のコード一見わかりやすいが customer が null の場合のビジネス 要求が正確に理解できない おそらく 「新規のお客様の場合にはデフォルトのプランが適用され る」 とかだと思う 余談1: Null Object Pattern 29 / 37

Slide 30

Slide 30 text

null と non-null の 2 つの状態を明確に状態として直和を用いて表現 してみる sealed class Customer object NewRegisteredCustomer : Customer() data class ExistingCustomer(val plan: BillingPlan) : Customer() val plan = when(customer) { is NewRegisteredCustomer -> BillingPlan.basic() is ExistingCustomer -> customer.plan } doSomething(plan) → 「新規のお客様の場合にはデフォルトのプランが適用される」 がコ ードから明らか 余談1: Null Object Pattern 30 / 37

Slide 31

Slide 31 text

Null Object Pattern null を利用せずに明確に型を作成して表現するデザインパターンのこ とを Null Object Pattern と呼ぶ 特に Kotlin の場合には nullable にする必要がなくなるのでこのパタ ーンはコードに安全性をもたらしてくれる 参考 Introduce Null Object The Null Object Pattern by Bobby Woolf 余談1: Null Object Pattern 31 / 37

Slide 32

Slide 32 text

余談 2: 直和でしか表現できないデータ構造 32 / 37

Slide 33

Slide 33 text

data class ContactInfo( val emailContactInfo: EmailContactInfo, val postalContactInfo: PostalContactInfo ) この状態で 「連絡情報は、 Email もしくは 住所どちらかが設定されて いれば良い」 という要求の変更が起こった際にはどうする? data class ContactInfo( val emailContactInfo: EmailContactInfo?, val postalContactInfo: PostalContactInfo? ) → どちらも任意となってしまった 余談2: 直和でしか表現できないデータ構造 33 / 37

Slide 34

Slide 34 text

つまるところ 3 パターンのみが許容できるということ 住所 / メールアドレス あり なし あり ○ ○ なし ○ x sealed class ContactInfo data class EmailOnlyContactInfo(val emailContactInfo: EmailContactInfo): ContactInfo() data class PostalOnlyContactInfo(val postalContactInfo: PostalContactInfo): ContactInfo() data class EmailAndPostalContactInfo( val emailContactInfo: EmailContactInfo, val postalContactInfo: PostalContactInfo ): ContactInfo() -> 3 つの状態が明確になった上に Nullable を利用せずに済んでいる 余談2: 直和でしか表現できないデータ構造 34 / 37

Slide 35

Slide 35 text

2 つの例に沿って、以下の具体例を紹介 1. 直和 を用いた状態の表現 2. 状態遷移図の実装 例 1: メールアドレスの認証状態 例 2: EC サイトにおけるカートの内容 また余談として以下を紹介 Null Object Pattern 直和でしか表現できないデータ構造 35 / 37

Slide 36

Slide 36 text

以上 一緒に働く人を募集中なので気軽に Twitter の DM などで連絡をください @omuomugin 36 / 37

Slide 37

Slide 37 text

参考 Domain Modeling Made Functional by Scott Wlaschin Designing with Types by Scott Wlaschin Null Object The Null Object Pattern by Bobby Woolf Primitive Obsession エリック・エヴァンスのドメイン駆動設計 37 / 37