Slide 1

Slide 1 text

No content

Slide 2

Slide 2 text

Goal ビジネスロジック上の失敗をResult型で表現する PHPにおけるResult型の実装方法を知る 2/49

Slide 3

Slide 3 text

梶川 琢馬 / 𝕏 @kajitack 株式会社TechBowl / プロダクトエンジニア 複数のプロダクトでPHPを使った開発を経験してきました。 運動不足解消のために始めたトライアスロンにハマってます。 3/49

Slide 4

Slide 4 text

4/49

Slide 5

Slide 5 text

5/49

Slide 6

Slide 6 text

try-catchの課題 6/49

Slide 7

Slide 7 text

try-catchの課題 // 何が起きるか実行するまで分からない try { $user = $userRepository->find($userId); // NotFoundException? $payment = $paymentService->charge($user, $amount); // PaymentException? ValidationException? $notificationService->notify($user, $payment); // NetworkException? } catch (Exception $e) { // どの処理で、どんなエラーが起きた? // ビジネスエラー?技術的エラー? } どの例外が発生するか分からない @throws に強制力がない エラー処理の場所が曖昧 7/49

Slide 8

Slide 8 text

これらの課題を解決するアプローチ → Result型 8/49

Slide 9

Slide 9 text

Result型とは? 9/49

Slide 10

Slide 10 text

質問:Result型を使ったことがありますか? 使っている 知っているけど使っていない 初めて聞いた 10/49

Slide 11

Slide 11 text

Result 型 try-catchとは別のエラーハンドリングのための型 戻り値として「 成功 」または「 失敗 」を持つ 成功 (Ok): 正常な値 を返す 失敗 (Err): エラー情報 を返す 成功した場合、失敗した場合など処理を分岐できる Haskell、Elm、Rust、Kotlin、Swiftなどで採用 PHPには標準では無いので、自前で実装する Result型を返す処理 if ($input === null) { // 失敗の場合 return Result::err('Input cannot be null'); } else { // 成功の場合 return Result::ok($input); } 結果の検証と値の取得 if ($result->isOk()) { $value = $result->unwrap(); } else { $error = $result->unwrapErr(); } 11/49

Slide 12

Slide 12 text

エラーの返し方の比較 try-catch throw /** * @throws UserNotFoundException * @throws OutOfStockException */ public function placeOrder(OrderInput $input): Order { $user = $this->userRepository->findById($input->userId); if ($user === null) { throw new UserNotFoundException('ユーザーが存在しません'); } if (! $this->productRepository->isInStock($input->productId, $input->quantity)) { throw new OutOfStockException('在庫が不足しています'); } $order = new Order(); return $order; } Result型 return /** * @return Result */ public function placeOrder(OrderInput $input): Result { $user = $this->userRepository->findById($input->userId); if ($user === null) { return Result::err(OrderError::UserNotFound); } if (! $this->productRepository->isInStock($input->productId, $input->quantity)) { return Result::err(OrderError::OutOfStock); } $order = new Order(); return Result::ok($order); } 12/49

Slide 13

Slide 13 text

エラーハンドリングの比較 try-catch catch try { $order = $this->orderService->placeOrder($input); } catch (UserNotFoundException $e) { // ユーザーが見つからない場合の処理 } catch (OutOfStockException $e) { // 在庫切れの処理 } catch (GuestUserCannotOrderException $e) { // ゲストユーザーの注文処理 } catch (RestrictedProductIncludedException $e) { // 制限商品が含まれている場合の処理 } catchしない書き方も出来てしまう 直接呼び出していない場所でもcatchできる Result型 unwrap() で成功した場合のみ値を取り出す $result = $this->orderService->placeOrder($input); if ($result->isErr()) { $error = $result->unwrapErr(); return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合は値を取り出す $order = $result->unwrap(); 13/49

Slide 14

Slide 14 text

まとめると... Result型とは 成功と失敗を戻り値として明示的に表現する型 try-catchとは異なるエラーハンドリングのアプローチ 関数の戻り値から失敗の可能性が読み取れる Result型のメリット 関数のシグネチャから「この関数は失敗する可能性がある」ことが明示的 呼び出し側は戻り値を取り出すためにチェックすることが強制される 処理の流れが直線的 14/49

Slide 15

Slide 15 text

PHPでのResult型の実装 15/49

Slide 16

Slide 16 text

とりあえず... bool値と結果を持つ実装 成功したかどうかをbool値で表現し、結果とエラーを持つ mixedで型安全性が不足 → ジェネリクスで解決 class Result { private function __construct( private bool $isSuccess, private mixed $value, private mixed $error ) {} public static function ok(mixed $value): self { return new self(true, $value, null); } public static function err(mixed $error): self { return new self(false, null, $error); } public function isOk(): bool { return $this->isSuccess; } public function isErr(): bool { return !$this->isSuccess; } } 16/49

Slide 17

Slide 17 text

PHPのジェネリクス PHPにはネイティブなジェネリクス機能がない PHPDocと静的解析ツールを組み合わせて実現 @template でテンプレート型を定義 @return でTemplate型を指定 /** * @template T * @param T $a * @return T */ function foo($a) { return $a; } 17/49

Slide 18

Slide 18 text

Ok(T) Err(E) Result 値: T エラー: E ジェネリクスを使ったResultの実装 基底クラスの定義 /** * @template T 成功時の値の型 * @template E 失敗時のエラーの型 */ abstract readonly class Result{} 18/49

Slide 19

Slide 19 text

成功と失敗それぞれの具象クラスを定義 成功: @template と @extends Result 失敗: @template と @extends Result /** * @template T * @extends Result */ final readonly class Ok extends Result { /** * @param T $value */ public function __construct( private mixed $value ) { } } /** * @template E * @extends Result */ final readonly class Err extends Result { /** * @param E $value */ public function __construct( private mixed $value ) { } } 19/49

Slide 20

Slide 20 text

Result型のメソッド実装 参考: RustのResult型 https://doc.rust-lang.org/std/result/ 基本操作 isOk() 成功かどうかを確認 isErr() 失敗かどうかを確認 unwrap() 成功時の値を取得 unwrapErr() 失敗時のエラーを取得 関数型プログラミング map(fn) 成功値に関数を適用 mapErr(fn) エラー値に関数を適用 andThen(fn) 成功時に別のResultを返す関数を適用 orElse(fn) 失敗時に別のResultを返す関数を適用 20/49

Slide 21

Slide 21 text

基本的なメソッドの実装 判定メソッドと値取得メソッドを実装 Ok public function isOk(): bool { return true; } public function isErr(): bool { return false; } /** * @return T */ public function unwrap(): mixed { return $this->value; } public function unwrapErr(): never { throw new LogicException('called Result::unwrapErr() on an Ok value'); } Err public function isOk(): bool { return false; } public function isErr(): bool { return true; } public function unwrap(): never { throw new LogicException('called Result::unwrap() on an Err value'); } /** * @return E */ public function unwrapErr(string $message = ''): mixed { return $this->value; } 21/49

Slide 22

Slide 22 text

基本的な使い方 isErr で失敗をチェックし、 unwrapErr でエラー値を取得 エラーに応じた処理を行う // 失敗した場合の処理 if ($result->isErr()) { $error = $result->unwrapErr(); return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合のみ値を取り出せる $order = $result->unwrap(); 22/49

Slide 23

Slide 23 text

必要に応じてメソッドを追加 Ok // 成功値を変換 public function map(callable $fn): Result { return new Ok($fn($this->value)); } // 成功時に別のResultを返す関数を適用 public function andThen(callable $fn): Result { return $fn($this->value); } // エラー値の変換(Okなので何もしない) public function mapErr(callable $fn): Result { return $this; } // 失敗時の代替(Okなので自身を返す) public function orElse(callable $fn): Result { return $this; } Err // 成功値の変換(Errなので何もしない) public function map(callable $fn): Result { return $this; } // 成功時の関数適用(Errなので何もしない) public function andThen(callable $fn): Result { return $this; } // エラー値を変換 public function mapErr(callable $fn): Result { return new Err($fn($this->value)); } // 失敗時に別のResultを返す public function orElse(callable $fn): Result { return $fn($this->value); } 23/49

Slide 24

Slide 24 text

応用的な使い方 関数型メソッドによる連鎖的な処理 map で成功値を変換 andThen で次の処理を適用 // 注文IDから注文詳細情報を取得する例 $orderDetails = $this->orderRepository ->findById($orderId) // Result ->map(fn($order) => $order->toArray()) // Result ->map(fn($data) => $this->enrichWithUserInfo($data)) // Result ->mapErr(fn($err) => new OrderNotFoundError($orderId)); // エラーを変換 // 複数のResult型を連鎖 $result = $this->validateInput($request) // Result ->andThen(fn($input) => $this->createOrder($input)) // Result ->andThen(fn($order) => $this->processPayment($order)) // Result ->map(fn($payment) => new OrderCompleted($payment)); // 成功時の値を変換 24/49

Slide 25

Slide 25 text

詳しい実装例はこちら https://github.com/valbeat/php-result 25/49

Slide 26

Slide 26 text

まとめると... PHPでのResult型実装 ジェネリクスで型安全に実装可能 静的解析ツール PHPStan との相性が良い map、andThenなどの関数型メソッドで処理を連鎖 実装のポイント isOk()、isErr()で状態をチェック unwrap()、unwrapErr()で値を取得 関数合成で成功パスと失敗パスを分離 RustのResult型を参考にしたが、メソッド名などはチームのスタイルに合わせて調整 (Success、Failure、Eitherなどの表現も一般的) 26/49

Slide 27

Slide 27 text

例外とResult型の使い分け 27/49

Slide 28

Slide 28 text

とはいえ、PHPでは try-catchによる例外処理が一般的 一切例外を使わなくてもライブラリが投げてくるし... フレームワークの例外ハンドリングも積極的に使いたい トランザクションのロールバック HTTPレスポンスの生成 Sentryへのエラーレポート ログ 28/49

Slide 29

Slide 29 text

オススメのアプローチ エラーを2種類に分けて、例外とResult型を使い分ける 29/49

Slide 30

Slide 30 text

ビジネスエラー 預金額を超える引き出し、在庫切れの注文など ビジネスロジックの一部として失敗が想定できるケース 技術的エラー システムの異常状態 ネットワーク障害、DBコネクション切断など 実装ミスやインフラ側で対処が必要なエラー 30/49

Slide 31

Slide 31 text

一方PHPでは... ビジネスエラーも技術的エラーも 例外で表現される Throwable Error Exception LogicException RuntimeException 31/49

Slide 32

Slide 32 text

現実のコードベースでよく見る光景 パターン1:全部catch try { // 複雑なビジネスロジック $result = $this->doEverything(); } catch (Exception $e) { Log::error($e); } パターン2:catch地獄 try { $order = $this->createOrder(); } catch (UserNotFoundException $e) { // 処理A } catch (InsufficientFundsException $e) { // 処理B } catch (OutOfStockException $e) { // 処理C } catch (DatabaseException $e) { // 処理D } catch (Exception $e) { // その他全部... } 結果:ビジネスエラーと技術的エラーが混在してカオスに 32/49

Slide 33

Slide 33 text

「例外」という名前が示す通り、本来は「例外的な」状況に使う べきもの しかし、失敗が想定できる結果であるビジネスケースをなぜ「例 外」としてモデリングするのか? 処理の失敗は「例外」ではなく、成功と同 様に考慮すべき「結果」 33/49

Slide 34

Slide 34 text

例外を技術的なエラーに 使用して、 ビジネスエラーをResult型 で表現する 34/49

Slide 35

Slide 35 text

ビジネスエラーのモデリングからResult型へ 35/49

Slide 36

Slide 36 text

エラーもドメインの一部として モデル化する ドメインをモデル化する際には、文字列などのプリミティブ型を 使わず、ドメインの語彙(ユビキタス言語)を用いて、ドメイン に特化した型を作成しました。 さて、エラーも同じように扱われるべきです。ドメインに関する 議論で特定の種類のエラーが挙がった場合、ドメイン内の他の要 素と同様にモデル化するべきです。 → エラーを選択型(Result型)としてモデル化し、特別な対応 が必要なエラーの種類ごとに個別のケースを用意する 36/49

Slide 37

Slide 37 text

チーム内の会話で洗い出す 例: 注文作成の条件 開発者とドメインエキスパートとの会話 の中で想定できる → ビジネスエラー 37/49

Slide 38

Slide 38 text

チーム内の会話で洗い出す 例: データベースの接続中断 開発者の技術的な関心にのみ現れる → 技術的エラー 38/49

Slide 39

Slide 39 text

洗い出したビジネスエラーを モデリング図にまとめる 39/49

Slide 40

Slide 40 text

ロジックと同じように... エラーにも関心の分離と依存のルールを適応する 40/49

Slide 41

Slide 41 text

各レイヤーで投げる例外 ドメイン層で投げる例外はResult型で表現できそう 41/49

Slide 42

Slide 42 text

ドメインロジック ビジネスプロセスの結果表現 -> Result型 アプリケーション ドメイン層のResult型を受け取り、Presentationに適 した例外を投げる ここで投げた例外はフレームワークのハンドラまで キャッチしない 42/49

Slide 43

Slide 43 text

処理の流れの中で例外とResult型の使い分け Entityの作成などのドメインロジックではResult型で表現する 一方、永続化の失敗は技術的エラーとして例外を投げることで即座にロールバックできる 43/49

Slide 44

Slide 44 text

Entityの作成をResult型で表現 失敗の種類をEnumで定義 enum OrderError { case ValidationError; case InsufficientStock; case PaymentFailed; case ShippingNotAvailable; } ワークフローでResult型を活用 /** * @return Result */ public function crate(OrderRequest $request): Result { } 44/49

Slide 45

Slide 45 text

Entity作成に必要な処理の定義 Result型を返す小さな関数に分割 function validateOrderData(array $data): Result { if (empty($data['items'])) { return Result::err(OrderError::ValidationError); } return Result::ok(new Order($data)); } function checkInventory(Order $order): Result { foreach ($order->items as $item) { if (!hasStock($item)) { return Result::err(OrderError::InsufficientStock); } } return Result::ok($order); } function processPayment(Order $order): Result { if (!$paymentGateway->charge($order->total)) { return Result::err(OrderError::PaymentFailed); } return Result::ok($order); } 45/49

Slide 46

Slide 46 text

小さな関数を組み合わせてビジネスロジックを作成 andThen で成功した時だけ次の処理を実行する class OrderFactory { /** * @return Result */ public function create(PlaceOrderRequest $request): Result { return Result::ok($request->toArray()) ->andThen(fn($data) => $this->validateOrderData($data)) ->andThen(fn($order) => $this->checkInventory($order)) ->andThen(fn($order) => $this->processPayment($order)) ->andThen(fn($order) => $this->scheduleShipping($order)); } } 46/49

Slide 47

Slide 47 text

ユースケース側でResult型から例外への変換 unwrapErr() で失敗時のエラーを取得し、例外を投げる 例外を投げることで、フレームワークのエラーハンドラに任せる また、検証が終わった後の処理の技術的例外で表現する public function exec(OrderCreateRequest $request): OrderDto { $result = $this->orderFactory->create($request); if ($result->isErr()) { throw match ($result->unwrapErr()) { OrderError::ValidationError => new BadRequestException(), OrderError::InsufficientStock => new ConflictException(), OrderError::PaymentFailed => new PaymentRequiredException(), OrderError::ShippingNotAvailable => new ServiceUnavailableException(), }; } $order = $result->unwrap(); // 永続化のエラーは例外で表現することで、従来通りロールバックする DB::transaction(fn() => $this->orderRepository->save($order)); return OrderDto::from($order); } 47/49

Slide 48

Slide 48 text

例外とResult型の使い分け エラーの分類 ビジネスエラー: 業務上想定される失敗 → Result型 技術的エラー: システムの異常状態 → 例外 使い分けの指針 ドメイン層:ビジネスエラーをResult型で表現 インフラ層:技術的例外をcatchしてResult型に変換 アプリケーション層:Result型を適切なレスポンスに変換 実装例から学んだこと ビジネスルールの検証はResult型で明示的に インフラエラーは例外として上位に委譲 コントローラーでビジネスエラーを適切にハンドリング 48/49

Slide 49

Slide 49 text

まとめ 失敗を型として明示的に扱い、Result型と例外と使い分けることでそれぞれのメリットを活かす 実装のポイント PHPでは標準サポートはないが、静的解析によって柔軟かつ型安全に実装できる チームに合わせて関数型のアプローチを取り入れていくのがオススメ 導入ポイント ビジネスエラーをResult型で表現し、技術的エラーは例外で表現する。 ビジネスロジックの純粋性と整合性が保証される。 条件分岐にtry-catchを使わないことで、コードの可読性と保守性が向上する また、例外のメリットである大域脱出を活かして即座に処理を中断し、 フレームワークのエラーハンドラに任せることができる。 49/49