KINTO Tech Blog
Development

Rust で Railway Oriented Programming を実践してみた

Cover Image for Rust で Railway Oriented Programming を実践してみた

この記事は KINTOテクノロジーズアドベントカレンダー2025 の 13 日目の記事です🎄

はじめに

KINTO開発部 KINTOバックエンド開発G マスターメンテナンスツール開発チーム・Osaka Tech Lab 所属の yuki.n(@yukidotnbysh)です。

わたしたちのチームでは各サービスと連携する管理システムを開発しています。これらは管理システムであると同時に、業務課題を解決するためのものでもあります。

そのため管理システムと言えども単純な CRUD システムというわけにはいかず、開発するシステムや業務によって様々複雑な課題が発生します。これらをビジネスロジックに落とし込むにあたって Railway Oriented Programming が効果的ではないかと考え、実際のプロジェクトで導入しました。その事例をもとにどのようなメリット・見えてきた課題があったのかをご紹介したいと思います。

Railway Oriented Programming について

Railway Oriented Programming とは F# for Fun and Profit の運営や「Domain Modeling Made Functional(関数型ドメインモデリング)」の作者である Scott Wlaschin 氏が提唱した、関数型プログラミングにおけるエラーハンドリングの手法です。日本語では「鉄道指向プログラミング」と呼ばれています。F# for Fun and Profit に投稿された同じタイトルの記事を見ると、少なくとも 2013 年には公開されていたようです。

Railway Oriented Programming では、関数を「線路(Railway)」に例え、正常系の処理と異常系の処理を 2 本の線路として表現します。

上段の緑の線路と下段の赤い線路に分岐する線路

この「線路」を複数つなぎ合わせたのが以下のイメージ図です。

3つの二分岐の線路が繋がっている。各分岐点を覆うように半透明の四角い枠が3つ並んでいる。それぞれの四角の枠内には左から「Validate function」「Update function」「Send function」と書かれている。

各処理ステップは「スイッチ」として機能し、成功すれば正常系の処理(線路)を進み、失敗すれば異常系の処理(線路)に切り替わります。一度異常系に入ると、以降の処理では成功することはなく、エラーとして最後まで流れていきます。

具体的には Result 型を返す関数をパイプラインで次々とつなぎ合わせていくイメージです。

Railway Oriented Programming を取り入れた理由

もともとわたしたちのチームでは Rust の開発実績があり、このプロジェクトでもバックエンドサーバーの開発言語として Rust を採用しています。

アーキテクチャとしては「The Clean Architecture」の図を参考にしています(以後、便宜的にあえて「Clean Architecture」と書きます)。

The Clean Architectureの図。外側から中心に向かって青、緑、赤、黄色と4つの円になっている。青は「Devices」や「DB」など、緑は「Controller」や「Presenter」など、赤は「Use Cases」、黄色は「Entities」と書かれている

過去のプロジェクトで Clean Architecture を導入した時、上の図で描かれている「Use Cases」層の処理が複雑かつ肥大化していく傾向にあり、一部処理をドメインサービスとして「Entities」層へ切り出したとしても、やはり Use Cases 層の可読性が落ちてしまうという課題がありました。

そんな中で Railway Oriented Programming の存在を知り、導入することになりました。

Rust での Railway Oriented Programming

全体の構造

わたしたちが実装した Use Cases 層は概ね以下のような構造になっています。

#[derive(Debug, thiserror::Error)]
pub enum CreateUserUseCaseError {
    // エラー型の定義
}

pub trait UsesCreateUserUseCase {
    /// Workflow
    fn handle(
        &self,
        input: CreateUserInputData,
    ) -> impl Future<
        Output = Result<CreateUserOutputData, CreateUserUseCaseError>,
    > + Send;
}

pub trait CreateUserUseCase:
    // 依存関係
    ProvidesUserFactory
    + ProvidesUserRepository
{
}

impl<T: CreateUserUseCase + Sync> UsesCreateUserUseCase for T {
    async fn handle(
        &self,
        input: CreateUserInputData,
    ) -> Result<CreateUserOutputData, CreateUserUseCaseError> {
        // railway モジュールで定義した関数のチェーン
    }
}

mod railway {
    type RailwayResult<T> = Result<T, super::CreateUserUseCaseError>;

    pub(super) fn validate_input(/* ... */) -> RailwayResult<(Email, UserName)> { /* ... */ }
    pub(super) async fn check_email_not_exists(/* ... */) -> RailwayResult<(Email, UserName)> { /* ... */ }
    pub(super) fn build_user(/* ... */) -> RailwayResult<User> { /* ... */ }
    pub(super) async fn save_user(/* ... */) -> RailwayResult<User> { /* ... */ }
    pub(super) fn end(/* ... */) -> CreateUserOutputData { /* ... */ }
}

Rust の DI を考える –– Part 2: Rust における DI の手法の整理」で紹介されている Cake Pattern を用いています(本記事の本筋とは逸れるため詳細は割愛します)。

railway モジュールに関数を定義し、それらを UsesCreateUserUseCasehandle メソッド内で結合する、という形です。

(なお、「関数型ドメインモデリング」では handle メソッドにあたる部分を「ワークフロー」、引数は「コマンド」と表現されています。本記事でもこれに倣います)

これらの要素をひとつずつ分解していきます。

エラー型の定義

#[derive(Debug, thiserror::Error)]
pub enum CreateUserUseCaseError {
    // エラー型の定義
    #[error("メールアドレスが既に存在します。")]
    AlreadyExistsEmail,
    #[error("無効なメールアドレスです。")]
    InvalidEmail,
    #[error("無効なユーザー名です。")]
    InvalidUserName,
    #[error("UserFactoryError")]
    UserFactoryError(#[from] UserFactoryError),
    #[error("UserRepositoryError")]
    UserRepositoryError(#[from] UserRepositoryError),
}

Use Case ひとつに対し、エラー型を必ずひとつ定義する形で運用しています。

Rust ではエラー型を Enum で定義することができます。標準のままだと std::error::Error トレイトを実装する必要があるのですが、thiserror クレートを使うことでエラー型の定義を簡略化できます。

また thiserror クレートで定義した場合、#[from] アトリビュートを設定することで From トレイトが実装されるので、該当エラーが発生した時に明示的に変換をせずとも、自動的に目的のエラー型へ変換できるようになります。

RailwayResult

mod railway {
    // このユースケース専用のResult型
    type RailwayResult<T> = Result<T, CreateUserUseCaseError>;
}

Use Case 専用のエラーをすべての関数に定義するのは大変なので、RailwayResult 型という型エイリアスを定義して、戻り値だけ設定するようにしています。

railway モジュール

mod railway {
    /// 入力値を検証し、値オブジェクトに変換します。
    pub(super) fn validate_input(
        input: CreateUserInputData,
    ) -> RailwayResult<(Email, UserName)> {
        let email = Email::try_from(input.email)
            .map_err(|_| CreateUserUseCaseError::InvalidEmail)?;
        let name = UserName::try_from(input.name)
            .map_err(|_| CreateUserUseCaseError::InvalidUserName)?;
        Ok((email, name))
    }

    /// メールアドレスが存在していないことを確認します。
    pub(super) async fn check_email_not_exists(
        (email, name): (Email, UserName),
        impl_repository: &impl UsesUserRepository,
    ) -> RailwayResult<(Email, UserName)> {
        impl_repository
            .find_by_email(&email)
            .await
            .map_err(CreateUserUseCaseError::UserRepositoryError)?
            .map_or(Ok((email, name)), |_| Err(CreateUserUseCaseError::AlreadyExistsEmail))
    }

    /// ユーザーを新規作成します。
    pub(super) fn build_user(
        (email, name): (Email, UserName),
        impl_factory: &impl UsesUserFactory,
    ) -> RailwayResult<User> {
        impl_factory
            .build(UserFactoryParams { email, name })
            .map_err(CreateUserUseCaseError::UserFactoryError)
    }

    /// ユーザーを保存します。
    pub(super) async fn save_user(
        output: User,
        impl_repository: &impl UsesUserRepository,
    ) -> RailwayResult<User> {
        impl_repository
            .save(output)
            .await
            .map_err(CreateUserUseCaseError::UserRepositoryError)
    }

    /// 戻り値を返し、処理を終了します。
    pub(super) fn end(
        output: User,
    ) -> CreateUserOutputData {
        CreateUserOutputData {
            user: output.into(),
        }
    }
}

railway というモジュールを作り、その中に「線路」となる関数群を定義していきます。
これは Railway Oriented Programming の流儀ではなく、単純にわたしたちが確認しやすいように目印として設けています。

この記事のコードの場合では以下の流れを想定しています。

  1. 入力値(メールアドレス・ユーザー名)の検証
  2. メールアドレスの存在チェック
  3. User エンティティの生成
  4. User エンティティの保存
  5. 保存した User エンティティを上位層に渡すための DTO に変換し、終了

前回の関数の戻り値が次の関数の入力値になるため、第一引数は output と命名しています。
ただし output がタプルだった場合は始めから展開しています。これはタプルのままだと変数の所有権の問題で取り扱いが面倒なためです。

基本的には前回の値だけがそのまま次の関数の入力値になることが望ましいとは思うのですが、バケツリレーのように不要な値まで渡し続ける必要が発生するなどデメリットの方が多いと判断し、各関数で新たな入力値を渡しても良いというルールにしています。

ワークフロー全体

原典では F# が使われていますが、Result 型や Either 型の概念があり、かつ関数を合成する機能がある言語(またはそれを補完するライブラリなど)であれば Railway Oriented Programming の導入は可能です。

Rust では幸い、Result 型以外にも Railway Oriented Programming を実現するのに欠かせない以下の機能が標準で備わっています。

  • ? 演算子:エラー発生時の処理停止を表現
  • mapand_then 関数:関数の合成

これらを組み合わせることで以下のようにパイプラインを構築することができます。

impl<T: CreateUserUseCase + Sync> UsesCreateUserUseCase for T {
    async fn handle(
        &self,
        input: CreateUserInputData,
    ) -> Result<CreateUserOutputData, CreateUserUseCaseError> {
        railway::validate_input(input)
            .map(|output| {
                railway::check_email_not_exists(output, self.user_repository())
            })?
            .await
            .and_then(|output| railway::build_user(output, self.user_factory()))
            .map(|output| railway::save_user(output, self.user_repository()))?
            .await
            .map(railway::end)
    }
}

実践して感じたメリット

以下、Railway Oriented Programming を実践して感じたメリットです。

処理の流れが明確になり、機能追加がしやすくなった

ワークフローの中身がパイプラインで繋がっているので、どういう処理が行われているのかは一目である程度わかるようになりました。

もちろん複雑な仕様であればワークフローが長くなることは避けられませんが、それでも関数の流れを追えば、どこで何が行われているのかはだいたいの当たりをつけられるようになりました。

ワークフロー内の各処理も関数に切り出されていることで、それぞれの処理・変数のスコープも明確になりました。

このおかげで機能追加の場合は新たに関数を差し込むだけでよく、変更の場合は該当の関数のみ修正すれば良くなり、保守性も向上したように感じます。

処理の入出力を型で表現できるようになった

これは Railway Oriented Programming と直接結びつく効果ではないと思いますが、各関数の引数・戻り値が何のデータであるかを型レベルでチェックできるようになりました。

コンパイルエラーで型の誤りを防げるようになり、処理途中で誤った値が渡ってしまうといった問題を回避できるようになりました。

単体テストが書きやすくなった

ワークフローに対し正常系・異常系すべてのテストを実装するのは非常に大変だったため、各 railway 関数それぞれをしっかりテストして、ワークフローのテストではハッピーパスを通すというやり方を取っています。

Private な関数のテストコードが必要かどうかの是非はあると思いますが、それでも railway モジュールの各関数をテストできるのは個人的にメリットと感じました。いまのところは大きな問題も感じていません。

また、副次的効果として、機能追加があった場合でもその関数分のテストを追加し、既存のテストがパスすれば問題ないことが確認できるので、この点は良かったと思います。

実践して感じた課題

実践してメリットが得られた反面、やはりいくつか課題もありました。

Railway Oriented Programming の慣れが必要

普段から関数型言語に慣れている方であればそれほど違和感ない手法と思うのですが、もちろんわたし含めてそうでないメンバーもいます。そのため、この書き方に慣れるまではどうしても実装が難しくなりますし、実際、わたしも Rust で Railway Oriented Programming が実装できるか調べていた時はかなり苦戦しました。

現在は AI のおかげでかなり難易度は下がりましたが、できあがったものが適切な内容かどうかはやはりある程度の理解が必要です。そのため、メンバーに対してのフォローがどうしても必要になります。

実際のところ、このプロジェクトでも開発初期はどうしてもレビュー負荷が大きくなりました。そのため、開発規模や納期など、プロジェクトの状況・条件によっては Railway Oriented Programming の採用を見送ることも検討した方が良いかもしれません。

fatal runtime error: stack overflow が発生する場合がある

実装内容によっては実行時に Stack Overflow エラーが発生する場合があります。

コンパイルエラーとして検出されないのが非常に厄介で、かつ具体的にどの箇所が問題なのか、ソースコードを見るだけでは判断がつきません。

原因の探し方ですが、rust-lldb を使ってスタックトレースを確認することで、Stack Overflow エラーが発生したコードを特定することができます。

# LLDB でバイナリを起動する
rust-lldb target/debug/your-bin

# 実行
run

# Stack Overflow が発生したらバックトレースを確認
thread backtrace all

解決が難しい場合の別案として、もっとも安全かつかんたんな解決策は mapand_then によるメソッドチェーンをやめ、1 行ずつ処理を書いていく形です。

async fn handle(&self, input: InputData) -> Result<OutputData, UseCaseError> {
    railway::begin(self.uow()).await?;
    let output = railway::validate_email(&input.email)?;
    let output = railway::authenticate(output, input.password, self.authenticator()).await?;
    let output = railway::update_last_access(output, self.user_repository()).await?;
    let output = railway::commit(output, self.uow()).await?;
    railway::end(output)
}

これはこれで悪くありませんが、メソッドチェーンによるパイプラインがなくなり、どうにでも書けてしまうという問題が発生します。なので本当にどうしても解決が難しい場合のみこの形式に置き換えるというのが安全と思います。

なお、他の方法としては RUST_MIN_STACK 変数の値を追加することでスタック領域を拡張できます。しかしこれは問題を先送りにしているだけで、いつの日か Stack Overflow エラーが再発しかねません。そのため、この解決方法はあまりおすすめできません。

ちなみにわたしの事例では、デバッグビルドの時に railway モジュール内に定義した関数の中で、非同期処理を並列実行する場合に起こるケースがありました。
async/await は Future 型の糖衣構文ですが、rust-lang/rust#132050 を見ると実行時に Future 型の持つ状態がスタック領域へ展開されるため、多くの async 関数を実行する時に Stack Overflow エラーを引き起こしてしまうようです。
このケースでは、並列で処理したい Future 型のデータを Vec 型のデータへ格納することで対処できました(Vec 型の値はヒープ領域に格納されるためです)。

処理フローが明確になる代わりにコードは増える

Rust で Railway Oriented Programming を導入すると、各関数を mapand_then でつなぎ合わせていく形になります。それに加えて、それぞれの関数を定義していく必要があるので、ふつうに書くより全体のコード量は増えます。

たとえば Railway Oriented Programming を適用しなかった場合の handle メソッドは

impl<T: CreateUserUseCase + Sync> UsesCreateUserUseCase for T {
    async fn handle(
        &self,
        input: CreateUserInputData,
    ) -> Result<CreateUserOutputData, CreateUserUseCaseError> {
        // 入力値の検証
        let email = Email::try_from(input.email)
            .map_err(|_| CreateUserUseCaseError::InvalidEmail)?;
        let name = UserName::try_from(input.name)
            .map_err(|_| CreateUserUseCaseError::InvalidUserName)?;

        // メールアドレスの重複チェック
        let existing_user = self
            .user_repository()
            .find_by_email(&email)
            .await
            .map_err(CreateUserUseCaseError::UserRepositoryError)?;
        if existing_user.is_some() {
            return Err(CreateUserUseCaseError::AlreadyExistsEmail);
        }

        // ユーザーの作成
        let user = self
            .user_factory()
            .build(UserFactoryParams { email, name })
            .map_err(CreateUserUseCaseError::UserFactoryError)?;

        // ユーザーの保存
        let saved_user = self
            .user_repository()
            .save(user)
            .await
            .map_err(CreateUserUseCaseError::UserRepositoryError)?;

        // 結果の変換
        Ok(CreateUserOutputData {
            user: saved_user.into(),
        })
    }
}

となり、関数がなく代わりに handle メソッドの中(または部分的に関数を切り出すなど)で実装することになります。このため、場合によってはこの方がシンプルなこともあると思います。

なので、かんたんな処理・分岐がほとんどといったアプリケーションの場合、無理に Railway Oriented Programming を採用しない方が無難かもしれません。

余談:「リポジトリパターンはどこにある?」

この記事の本筋とは直接関係しませんが、「関数型ドメインモデリング」では「リポジトリパターンはどこにある?」という題でリポジトリパターンについて言及されていて、関数型のアプローチではリポジトリパターンを

すべてを関数としてモデル化し、永続化を端に追いやることで、リポジトリパターンは必要なくなります。

と書かれています。

しかしわたしがこのことについての意図・方法を読み切れなかったため、この記事のコードおよびわたしたちのプロジェクトではリポジトリパターンを採用しています。

おわりに

以上、Railway Oriented Programming を Rust で実践した内容についての紹介でした。

Rust は非常に表現力が豊かで様々な機能を持つ言語ですが、Railway Oriented Programming を採用することでより強化できるのではないかと感じています。

もし同じように Rust で Railway Oriented Programming の採用を検討している方がいらっしゃいましたら、この記事が少しでも参考になれば幸いです。

Facebook

関連記事 | Related Posts

We are hiring!

【クラウドエンジニア】Cloud Infrastructure G/東京・大阪・福岡

KINTO Tech BlogWantedlyストーリーCloud InfrastructureグループについてAWSを主としたクラウドインフラの設計、構築、運用を主に担当しています。

【フロントエンドエンジニア(リードクラス)】FACTORY EC開発G/東京・大阪

KINTO FACTORYについて自動車のソフトウェア、ハードウェア両面でのアップグレードを行う新サービスです。トヨタ/レクサス/GRの車をお持ちのお客様に、KINTO FACTORYを通してリフォーム、アップグレード、パーソナライズなどを提供し、購入後にも進化続ける自動車を提供するモビリティ業界における先端のサービスを提供します。

イベント情報