Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Railway Oriented Programming を オニオンアーキテクチャに適用する...

YuitoSato
October 25, 2024

Railway Oriented Programming を オニオンアーキテクチャに適用する by kotlin-result / Railway Oriented Programming in Onion Architecture by kotlin-result

YuitoSato

October 25, 2024
Tweet

More Decks by YuitoSato

Other Decks in Technology

Transcript

  1. © 2024 Loglass Inc. 2024.10.25 株式会社ログラス 佐藤有斗 Railway Oriented Programming

    を オニオンアーキテクチャに適用する by kotlin-result
  2. © 2024 Loglass Inc. 佐藤 有斗(ゆいと) 株式会社ログラス プロダクト開発部 エンジニア 株式会社ログラスに2020年12月に入社。

    主に新規事業の立ち上げを担当。得意分野は型と自動テスト。 KotlinでたまにOSSを作ったり、他のOSSにコントリビュート したりしてます。 Yuito Sato
  3. © 2024 Loglass Inc. Railway Oriented Programming とは? • 正常ケースと異常ケースの2つのレールを型で表現しながら設計をする手法

    ◦ いわゆる関数型的なエラーハンドリングをする設計手法 ◦ RustでいうResult。OkとErrの二つの型を持つ ◦ Kotlinではkolin-resultのResult、Arrow.ktのEitherが該当する 引用: Railway Oriented Programming https://fsharpforfunandprofit.com/rop/
  4. © 2024 Loglass Inc. kotlin-result • KotlinでRailway Oriented Programmingができるライブラリ Result<out

    V, out E> // 例)文字列からメモというモデルを作成する関数の型 (String) -> Result<Memo, CreateMemoError> 参考: kotlin-result https://github.com/michaelbull/kotlin-result
  5. © 2024 Loglass Inc. 使用例 fun createMemo(text: String): Result<Memo, CreateMemoError>

    { return if (text.length <= 140) { Ok(Memo(text)) } else { Err(CreateMemoError()) } }
  6. © 2024 Loglass Inc. Railway Oriented Programming の良いとこ • 呼び元でエラーハンドリングを強制できる

    ◦ 検査例外がないKotlinだと例外ハンドリングを強制できない ◦ 標準のResult型はハンドリングする例外を指定できない • 複雑な処理を制御しやすい ◦ 処理の合成がしやすい(直列と並列の処理の合成)
  7. © 2024 Loglass Inc. Railway Oriented Programming の良いとこ • 呼び元でエラーハンドリングを強制できる

    ◦ 検査例外がないKotlinだと例外ハンドリングを強制できない ◦ 標準のResult型はハンドリングする例外を指定できない • 複雑な処理を制御しやすい ◦ 処理の合成がしやすい(直列と並列の処理の合成) 時間の都合上 今日はこちらのみ
  8. © 2024 Loglass Inc. Railway Oriented Programming で処理を直列に合成する • ユーザ作成(バリデーション→ユーザを作成→通知メールを送信)

    バリデーション ユーザ作成 通知メール送信 Request ValidateRegister UserRequestError Validated Request User CreateUserError Unit SendEmailError
  9. © 2024 Loglass Inc. // バリデーション fun validateRegisterUserRequest( request: RegisterUserRequest,

    ): Result<ValidatedRegisterUserRequest, ValidateRegisterUserRequestError> { ... } // ユーザー作成 fun createUser( request: ValidatedRegisterUserRequest, ): Result<User, CreateUserError> { ... } // メールを送信 fun sendCreateUserNotificationEmail( createdUser: User, ): Result<Unit, SendCreateUserNotificationEmailError> { ... }
  10. © 2024 Loglass Inc. Railway Oriented Programming で処理を直列に合成する • andThenで処理を合成(binding,

    flatMapも同じことが可能) バリデーション ユーザ作成 通知メール送信 Request ValidateRegister UserRequestError Validated Request User CreateUserError Unit SendEmailError andThen andThen
  11. © 2024 Loglass Inc. // ユーザー登録処理。各処理を連結 fun registerUser( request: RegisterUserRequest,

    ): Result<User, RegisterUserError> { return validateCreateUserRequest(request) // バリデーション .mapError { error -> ... } .andThen { validatedRequest -> createUser(validatedRequest).mapError { error -> ... } // ユーザー作成 } .andThen { createdUser -> sendCreateUserNotificationEmail(createdUser).mapError { error -> ... } // 通知メール送信 } }
  12. © 2024 Loglass Inc. Railway Oriented Programming で処理を並列に合成する • zipで処理を合成

    zip CreateUserError UseName CreateUserError String UserName, Email Email CreateUserError String 一つ目のエラーを返す User
  13. © 2024 Loglass Inc. fun validateAndCreate(userName: String, email: String): Result<User,

    CreateUserError> { return zip( { UserName.validateAndCreate(userName) }, { Email.validateAndCreate(email) }, ) { validatedUserName, validatedEmail -> User( id = UserId.generate(), userName = validatedUserName, email = validatedEmail, ) } }
  14. © 2024 Loglass Inc. • zipOrAccumulateで処理を合成 zipOrAccumulate List<CreateUserError> UseName CreateUserError

    String Email CreateUserError String zipOrAccumulateでエラーをまとめて返す UserName, Email User
  15. © 2024 Loglass Inc. fun validateAndCreate(userName: String, email: String): Result<User,

    List<CreateUserError>> { return zipOrAccumulate( { UserName.validateAndCreate(userName) }, { Email.validateAndCreate(email) }, ) { validatedUserName, validatedEmail -> User( id = UserId.generate(), userName = validatedUserName, email = validatedEmail, ) } }
  16. © 2024 Loglass Inc. List<Result<V, E>>を並列に処理する • いわゆる一括処理はcombineで処理を並列に合成できる combine CreateUserError

    User CreateUserError Request List<User> User CreateUserError Request User CreateUserError Request User User User 一つ目のエラーを返す
  17. © 2024 Loglass Inc. // ユーザー作成処理を並列に合成 fun createUsers( request: ValidatedRegisterUserRequest,

    ): Result<List<User>, CreateUserError> { val resultUserList: List<Result<User, CreateUserError>> = requests.map { createUser(it) } val userListResult: Result<List<User>, CreateUserError> = resultUserList.combine() return userListResult }
  18. © 2024 Loglass Inc. エラーをまとめて返すなどもカスタムすればできる • combineOrAccumlateはデフォルトでないので自作が必要 combineOr Accumulate List<CreateUserError>

    User CreateUserError Request User CreateUserError Request User CreateUserError Request List<User> User User User CreateUserError CreateUserError 3つの内2つ失敗した
  19. © 2024 Loglass Inc. fun bulkCreateUser( requests: List<ValidatedRegisterUserRequest>, ): Result<List<User>,

    List<CreateUserError>> { val resultUserList: List<Result<User, CreateUserError>> = requests.map { createUser(it) } val userListResult: Result<List<User>, List<CreateUserError>> = resultUserList.combineOrAccumulate() return userListResult }
  20. © 2024 Loglass Inc. import com.github.michaelbull.result.partition // partitionメソッドを使って意外と簡単に自作が可能 fun <V,

    E> List<Result<V, E>>.combineOrAccumulate(): Result<List<V>, List<E>> { val (values, errors) = this.partition() return if (errors.isEmpty()) { Ok(values) } else { Err(errors) } }
  21. © 2024 Loglass Inc. kotlin-resultの処理の合成まとめ • 処理を「直列」に合成したい(成功したら次へ) ◦ andThen、binding、flatMapを使う •

    処理を「並列」に合成したい(お互いの結果に依存しない) ◦ zip, combineを使う • 処理を「並列」に合成したいかつエラーをまとめたい ◦ zipOrAccumulateを使う。(combineOrAccumulateは拡張が必要)
  22. © 2024 Loglass Inc. Railway Oriented Programming & オニオンアーキテクチャ -

    どの層でResultを使うの? Domain Application Presentation Infrastructure DB
  23. © 2024 Loglass Inc. Railway Oriented Programming & オニオンアーキテクチャ -

    どの層でResultを使うの? Domain Presentation Infrastructure DB Application
  24. © 2024 Loglass Inc. Railway Oriented Programming & オニオンアーキテクチャ -

    どの層でResultを使うの?→Application層とDomain層で使う Domain Presentation Infrastructure Application 異常値は 基本例外を投げて ハンドリング しない App層からの エラーを 例外に変換 メソッドは基本 Resultを返す Domain、 Repositoryの 処理を呼び出し、 処理を合成
  25. © 2024 Loglass Inc. Domain層 - 基本Resultを返す class User(...) {

    companion object { fun validateAndCreate( userName: String, email: String, ): Result<User, ValidateAndCreateUserError> {...} } }
  26. © 2024 Loglass Inc. Application層 - Resultを返す処理を結合 class RegisterUserUseCase( private

    val userRepository: UserRepository, private val createUserNotificationEmailSender: CreateUserNotificationEmailSender, ) { fun execute( param: RegisterUserDto, ): Result<Unit, RegisterUserUseCaseError> { return User.validateAndCreate(param.userName, param.email) .mapError { error -> ValidateAndCreateUserUseCaseError(error) }.andThen { createdUser -> userRepository.insert(createdUser) createUserNotificationEmailSender .send(createdUser) .mapError { error -> SendCreateUserNotificationEmailUseCaseError(error) } } } }
  27. © 2024 Loglass Inc. Application層 - Resultを返す処理を結合 Domain層処理① Domain層処理② 保存処理

    エラー値 成功値 成功値 エラー値 Unit andThen andThen データ取得 データ リクエスト
  28. © 2024 Loglass Inc. Presentation層 - Resultのエラーを 例外に変換 class UserController(

    private val registerUserUseCase: RegisterUserUseCase, ) { fun registerUser( request: RegisterUserRequest ): String { val dto = request.toDto() val result = registerUserUseCase.execute(dto) return result .map { "OK" } .getOrThrow { error -> error.toException() } } } fun RegisterUserUseCaseError.toException(): BadRequestException { return when (this) { is RegisterUserUseCaseError.ValidateAndCreateUserUseCaseError -> … is RegisterUserUseCaseError.SendCreateUserNotificationEmailUseCaseError -> … // ... } }
  29. © 2024 Loglass Inc. Infrastructure層 - 基本Resultを返さない - 基本自力復帰できないエラーが多いため -

    復帰できるエラーがある場合は返す - 例)メールの送信エラーなど // Domain層 interface UserRepository { fun insert(user: User) } // Infrastructure層(Domain層のInterfaceを実装) class UserJDBCRepository(): UserRepository { override fun insert(user: User) { // ユーザー登録処理 … } }
  30. © 2024 Loglass Inc. 補足)ユーザーが自力復帰できないエラーはResultで管理しない - 500になるようなエラーは基本ハンドリングしない Domain層処理① Domain層処理② 保存処理

    エラー値 成功値 成功値 エラー値 Unit データ取得 データ リクエスト 500エラー (データ不整合) 500エラー (DB落ちてた)
  31. © 2024 Loglass Inc. まとめ: Railway Oriented Programming & オニオンアーキテクチャ

    - どの層でResultを使うの?→Application層とDomain層で使う Domain Presentation Infrastructure Application 異常値は 基本例外を投げて ハンドリング しない App層からの エラーを 例外に変換 メソッドは基本 Resultを返す Domain、 Repositoryの 処理を呼び出し、 処理を合成
  32. © 2024 Loglass Inc. エラー時に自動でロールバックされない問題 class RegisterUserUseCase( private val userRepository:

    UserRepository, private val createUserNotificationEmailSender: CreateUserNotificationEmailSender, ) { // SpringBootのアノテーションで例外投げられたら自動ロールバック @Transactional fun execute( param: RegisterUserDto, ): Unit { … } }
  33. © 2024 Loglass Inc. 実験: カスタムアノテーション @Retention(AnnotationRetention.RUNTIME) @Transactional(propagation = Propagation.REQUIRED)

    annotation class ResultTransactional @Component @Aspect class ResultTransactionalAspect { @Around("@annotation(ResultTransactional)") fun handleResultTransaction(joinPoint: ProceedingJoinPoint): Any? { val proceeded = joinPoint.proceed() val isErr = proceeded.javaClass.name == "com.github.michaelbull.result.Failure" if (isErr) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() } return proceeded } } - @ResultTransactional というアノテーションを自作 ⚠ 動くことは確認しましたが、 ログラスではまだ実運用していません
  34. © 2024 Loglass Inc. 使ってみる class RegisterUserUseCase( private val userRepository:

    UserRepository, private val createUserNotificationEmailSender: CreateUserNotificationEmailSender, ) { // 返り値がErrなら自動でロールバック @ResultTransactional fun execute( param: RegisterUserDto, ): Unit { … } }