🏃‍♂️

TypeScriptの型で設計を表現する、宣言的バックエンド実装具体例

2024/09/01に公開

こんにちは。
株式会社 CHILLNN という京都のスタートアップにて CTO を担っております永田と申します。
弊社では、バックエンドを typescript で実装しており、「宣言的プログラミング」と「関数型ドメインモデリング」のパラダイムを導入しています。

この構成は、一休 CTO である伊藤さんによる宣言的バックエンド開発の発信に大きく影響を受けており、よく言及されている「関数型ドメインモデリング」も拝読しました。

どれも非常に興味深く拝見させていただいたのですが、どうしても概念として理解することと、実装に落とし込むことの間には大きなギャップがありました。多くの試行錯誤を繰り返す中で、かなりこれらのパラダイムのメリットを実際に享受できるようになってきました。本記事では、我々の現時点での具体的実装上の tips を紹介します。

これらのパラダイムの導入を検討している方々にとって、少しでも理解と実装の橋渡しのお役に立てれば幸いです。

はじめに

本記事で紹介する開発手法では、
設計をコードで表現し、コンパイラレベルで解釈可能なビジネスロジックを表現すること を目指しています。

宣言的プログラミングとは

プログラミングの世界で、宣言的と対をなすのは命令的という概念です。

両者の違いに対する本記事のスタンスおよび、我々の理解をざっくり説明すると、前者はサービスで扱うデータの状態や結果を中心としたパラダイム、後者は処理を行うコンピュータの振る舞いを中心としたパラダイムであり、両者の具体的な実装手段には明確な境界はなく、プログラムを記述するエンジニアのメンタルモデルの違いです。

前者は、扱うデータを再利用する必要があるようなアプリケーションを開発するときに有効なメンタルモデルで、後者は、データからインサイトを抽出するような、統計的処理をするときに有効なメンタルモデルであると理解しています。

両者の違いについて説明する記事では、よく関数の書き方による説明がされています。しかし、実際には扱っている対象の粒度によって柔軟に解釈することが可能な概念です。宣言的なパラダイムを採用した実装においても、ミクロに見れば手続き的・命令的な記述は現れます。

2 つのパラダイムを導入した理由と享受できたメリット

弊社で二つのパラダイムを導入するに至った動機は以下のようなものでした。

  1. 機能追加によって処理が複雑になり、コードを理解するコストが上がってきた
  2. 暗黙的なドメイン知識が増え、実装上の考慮漏れが頻繁に起こるようになってきた
  3. 法律の変化により、複数箇所でアトミックに実装をしていたコードに副作用を発生させる必要が生じてきた

これらの課題に対し、それぞれ以下のようなメリットを得ることができています。

① 宣言的プログラミング:

  • コンパイラレベルで解釈可能な形でビジネスロジックを表現することで、ユニットテストに頼らなくても実装上の考慮漏れが起こりにくくなった(ユニットテストが不要とは言ってない)
  • 設計をコードで詳細に表現することが可能になり、ドメイン知識の習熟度に応じて設計と実装を分業でき、チーム開発が行いやすくなった

② 関数型ドメインモデリング:

  • 関数をアトミックな処理に分割しやすくすることで、後から必要な箇所にコードを複雑にせずに副作用を注入できるようになった
  • メソッドチェーンを追うだけで処理の概要を素早く掴むことができるようになった

具体的な開発フロー

ここからは、具体的に我々の開発フローを紹介していきます。
まず、型でドメインが取りうる状態をモデリングすることからスタートします。

例として、EC サイトでユーザーが登録済みのクレジットカードで決済を行う架空の処理について考えてみましょう。

この処理では以下のようなイベントが起こると考えます。

  1. クライアントサイドでユーザーが決済イベントを発行する.
  2. バックエンドで決済イベントを受け取り、登録済みの決済情報を DB から取得し、 Charge を作成する.
  3. クライアントサイドで Charge の必要情報を受け取り、3D セキュアでの検証を実施する.
  4. バックエンドで 3D セキュア検証を実施したイベントを受け取り、処理を再開する.
  5. バックエンドで 3D セキュアの検証結果を判断し、検証結果が問題なければ作成した Charge をキャプチャする. 失敗した場合は返金する.

上記のイベントは、例えば以下のようにモデリングできます。

model/state.ts
// タスク実行のInput
export type Input = {
    kind: "Input";
    userID: string;
    price: Price;
}

// DBから必要情報を取得した状態
export type InputCommand = {
    kind: "InputCommand";
    price: Price;
    paymentInfo: PaymentInfo;
}

// 決済情報
type Charge =
    | ThreeDSecureChallengedCharge // 3Dセキュア検証前
    | ThreeDSecureCheckedValidCharge // 検証した結果、有効だった決済情報
    | ThreeDSecureCheckedInvalidCharge // 検証した結果、無効だった決済情報
    | CapturedCharge // キャプチャ済みの決済情報
    | RefundedCharge // 返金済みの決済情報

// 3Dセキュアで検証するChargeを作成した状態
export type ChallengedThreeDSecure = {
    kind: "ChallengedThreeDSecure";
    charge: ThreeDSecureChallengedCharge;
}

// 3Dセキュアで検証した結果、有効な決済だと判断された状態
export type CheckedThreeDSecureValid = {
    kind: "CheckedThreeDSecureValid";
    charge: ThreeDSecureCheckedValidCharge;
}

// 3Dセキュアで検証した結果、無効な決済だと判断された状態
export type CheckedThreeDSecureInvalid = {
    kind: "CheckedThreeDSecureInvalid";
    charge: ThreeDSecureCheckedInvalidCharge;
}

// 3Dセキュアで検証した決済をキャプチャした状態
export type Captured = {
    kind: "Captured";
    charge: CapturedCharge;
}

// 返金済みの状態
export type Refunded = {
    kind: "Refunded":
    charge: RefundedCharge;
}

次にドメインの状態がどのように遷移しうるかをモデリングします。

model/state-machine.ts

// InputからInputCommandに遷移する
export type CreateInputCommand = (
    input: Input,
) => ResultAsync<InputCommand, Error>;

// InputCommandから3Dセキュアの検証状態に遷移する
export type ChallengeThreeDSecure = (
    input: InputCommand,
) => ResultAsync<ChallengedThreeDSecure, Error>;

// 3Dセキュアの検証を行い、有効な状態と無効な状態に遷移する
export type CheckThreeDSecureIsValid = (
    input: ChallengedThreeDSecure,
) => ResultAsync<CheckedThreeDSecureValid | CheckedThreeDSecureInvalid, Error>;

// 3Dセキュアが有効な場合、キャプチャを行う
export type Capture = (
    input: CheckedThreeDSecureValid,
) => ResultAsync<Captured, Error>;

// 3Dセキュアが無効な場合、確保した金額の返金を行う
export type Refund = (
    input: CheckedThreeDSecureInvalid,
) => ResultAsync<Refunded, Error>;

以上でモデリング作業は終了です。
後ほど StateMachine の関数を合成して使いたいので、neverthrow を使って Result 型を導入しています。

次は状態遷移を関数として実装します。
カリー化して副作用を注入しています。

service/create-input-command.ts

import { fromPromise } from 'neverthrow';

export const createInputCommand = (
    paymentInfoRepository: PaymentInfoRepository
): CreateInputCommand => (input) => {
    const { userID, price } = input;
    const promise = async (): Promise<InputCommand> => {
        const paymentInfo = await paymentInfoRepository.get(userID);

        return {
            kind: 'InputCommand',
            price,
            paymentInfo
        }
    }

    return fromPromise(promise(), (err) => err as Error);
}

service/challenge-three-d-secure.ts

import { fromPromise } from 'neverthrow';

export const challengeThreeDSecure = (
    // 必要なRepositoryやserviceをDI
): ChallengeThreeDSecure => (input) => {
    const { price, paymentInfo } = input;
    const promise = async (): Promise<ChallengedThreeDSecure> => {
        const charge: ThreeDSecureChallengedCharge = /* Chargeを作成する処理 */

        return {
            kind: 'ChallengedThreeDSecure',
            charge,
        }
    }

    return fromPromise(promise(), (err) => err as Error);
}

最後に作成した関数を合成し、一連のワークフローを作成します。

transaction/challenge-three-d-secure.ts

export const challengeThreeDSecure = (
    paymentInfoRepository: PaymentInfoRepository,
    // その他、必要なRepositoryやServiceなど
) => (input: Input): ResultAsync<ChallengedThreeDSecure, Error> => {
    return okAsync(input)
        .andThen(createInputCommand(paymentInfoRepository))
        .andThen(challengeThreeDSecure(/* 必要なRepositoryやServiceなど */))
}
transaction/check-three-d-secure-valid.ts
export const checkThreeDSecureValid = (
    // 必要なRepositoryやServiceなど
) => (input: ChallengedThreeDSecure): ResultAsync<CheckedThreeDSecureValid | CheckedThreeDSecureInvalid, Error> => {
    return okAsync(input)
        .andThen(checkThreeDSecureIsValid(/* 必要なRepositoryやServiceなど */))
}
transaction/capture-valid-payment.ts
export const captureValidPayment = (
    // 必要なRepositoryやServiceなど
) => (input: CheckedThreeDSecureValid): ResultAsync<Capture, Error> => {
    return okAsync(input)
        .andThen(capture(/* 必要なRepositoryやServiceなど */))
}

これらのワークフローをさらに合成して特定ユースケースの単一ワークフローを作成することもできます。

実装上の Tips

上記の実装フローで意識しておくべきことをいくつか挙げておきます。

1. State の命名は、処理の完了状態を表現する

  • State をモデリングする際、次のアクションを意識した命名と、行った処理の完了状態を意識した命名方法が考えられます。
  • 前者の場合は、ここから後に続く処理に暗黙的な依存を生むことになり、関数の原子性を保つことができない要因になり得ます。
  • 処理の完了状態がイメージできるような命名にしましょう

2. State の分割は、コンパイラによってビジネスロジックに制約をかけられる単位で行う

  • 今回紹介した開発手法の大きな目的の一つは、設計のコード化です。
  • 状態遷移を粗く設計しすぎると、処理の途中で発生する副作用が暗黙的になってしまいコンパイラの恩恵を受けることができません。
  • コンパイラがビジネスロジックを理解できるような粒度で State を分割しましょう。

3. State では副作用の結果も表現する

  • それ以降の処理で値を使わなかったとしても、何らかのデータの変更を行う必要があるならば、その変更結果を State で表現しましょう。
  • このように実装を行うことで、副作用の実装漏れを防ぐことができます。

4. State は、そのまま DB に保存することができるように、実データのみで定義する

  • 今回の実装例では、ワークフローの途中で一度クライアントサイドに処理を戻しています。
  • 処理を一時中断する際には、処理の途中経過をスナップショットとして DB に保存し、ユーザーから再度のイベントの通知があった際に簡単に再開できるようにします。
  • このように実装しておくことで、後から複雑な副作用を起こさせる必要が生じた際でも、処理を中断して別の処理を差し込むことができます。

5. 処理が分岐し続ける場合は、別のワークフローとして合成する

  • 参考にさせていただいた本や記事では、処理を一本のワークフローとして表現することを重視していました。
  • 自分たちは最初、この TIPS を誤解して受け取っており、条件分岐があったとしても関数内で条件分岐をして無理やり全体を一本のワークフローで表現しようとしていました。
  • 確かにこのようなアプローチでも、最後に関数を合成するタイミングではシングルワークフローには見えるのですが、関数の詳細を見に行くと内部で複数のワークフローを同時に処理していることになります。
  • 一度だけ条件分岐をして、すぐにシングルワークフローに戻る場合などは問題ないのですが、連続して異なる処理を行う必要がある場合は、別のワークフローに分解しまししょう

まとめ

いかがでしたでしょうか?
書いている感覚では「設計をコード化する」という目的をある程度達成できているかなと感じています。利用しているライブラリである neverthrow への理解がまだ浅かったり、エラーハンドリングに関してまだ甘い部分があったりするので、継続して改善を行なっていくつもりです。

最後になりますが、弊社ではエンジニアの採用を積極的に行なっています!
ぜひ一緒に堅牢なサービスを作っていけたらと思っているので、もし少しでも興味が湧いたらカジュアル面談させてください!実装の壁打ちなんかも大歓迎です。ぜひ意見交換させてください 🙇
ご連絡お待ちしています。

参考

株式会社CHILLNN

Discussion