📝

【関数型ドメインモデリング】TypeScriptで書いてみた

2024/12/08に公開

https://qiita.com/advent-calendar/2024/puromoku

はじめに

今年の夏ごろに以下書籍が発売されました

https://asciidwango.jp/post/754242099814268928/関数型ドメインモデリング

DDDを学ぶために手に取った書籍だったのですが、F#で書かれているコードをTypeScriptに書き直しながら学習を進めたので記事にまとめようと思います

関数型ドメインモデリングってどんな書籍

関数型プログラミングの視点からDDDを解説した書籍です
想定読者は以下のようになっています(書籍から引用)

  • 型と関数しか使わずに、どうドメインをモデル化し実装できるかに興味がある方
  • ドメイン駆動設計を簡潔に把握し、オブジェクト指向設計やデータベースファースト設計とどう違うのかを学びたい方
  • ドメイン駆動設計の経験者で、関数型プログラミングがDDDにどう役立つのかを学びたい方
  • 関数型プログラミングについて学びたいが、理論や抽象化が多過ぎて敬遠している方
  • F#と関数型プログラミングが現実的なドメインにどう適用できるのかを見てみたい方

サンプルコードはF#で記述されており、小規模製造会社の「受注ワークフローの自動化」を例に戦略的DDDのアプローチで解説が進んでいきます

この記事のゴール

実際に書いたコード

では早速TypeScriptで実装したコードがこちらです(長いのでアコーディオンで)

サンプルコード
import {
  fromPromise,
  ok,
  okAsync,
  Result,
  ResultAsync,
  safeTry,
} from "neverthrow";

import {
  Address,
  CustomerInfo,
  EmailAddress,
  FirstName,
  HtmlString,
  LastName,
  OrderAcknowledgment,
  OrderId,
  OrderQuantity,
  PersonalName,
  Price,
  ProductCode,
  SendOrderAcknowledgment,
  UnvalidatedAddress,
  UnvalidatedAmountToBill,
  UnvalidatedBillingAddress,
  UnvalidatedCustomerInfo,
  UnvalidatedOrderLine,
  UnvalidatedShippingAddress,
  ValidatedOrderLine,
} from "./simpleTypes";

export const PlaceOrderWorkflow = () => {
  // ====================
  // パート1: 設計
  // ====================

  // ----- 注文の検証 -----

  type CheckProductCodeExists = (productCode: ProductCode) => boolean;
  type CheckedAddress = { type: "checkedAddress"; address: Address };

  type Uri = string;
  type ServiceInfo = {
    name: string;
    endpoint: Uri;
  };
  type Exception = string;
  type RemoteServerError = {
    service: ServiceInfo;
    exception: Exception;
  };
  type CheckAddressExists = (
    unvalidatedAddress: UnvalidatedAddress
  ) => ResultAsync<CheckedAddress, Error>;

  type UnvalidatedOrder = {
    orderId: string;
    customerInfo: UnvalidatedCustomerInfo;
    shippingAddress: UnvalidatedShippingAddress;
    billingAddress: UnvalidatedBillingAddress;
    orderLines: UnvalidatedOrderLine[];
    amountToBill: UnvalidatedAmountToBill;
  };

  type ValidatedOrder = {
    orderId: OrderId;
    customerInfo: CustomerInfo;
    shippingAddress: Address;
    billingAddress: Address;
    orderLines: ValidatedOrderLine[];
  };

  type ValidationError = { type: "error"; error: string };
  type ValidateOrder = (
    checkProductCodeExists: CheckProductCodeExists // 依存関係
  ) => (
    unvalidatedOrder: UnvalidatedOrder // 入力
  ) => ResultAsync<ValidatedOrder, ValidationError>; // 出力

  // ----- 注文の価格決定 -----

  type GetProductPrice = (productCode: ProductCode) => Price;
  type CreateOrderAcknowledgmentLetter = (
    pricedOrder: PricedOrder
  ) => HtmlString;

  type PricingError = { type: "error"; error: string };
  type PriceOrder = (
    getProductPrice: GetProductPrice
  ) => (validatedOrder: ValidatedOrder) => Result<PricedOrder, PricingError>;

  type PlaceOrderEvent =
    | { type: "orderPlaced"; orderPlaced: OrderPlaced }
    | { type: "billableOrderPlaced"; billableOrderPlaced: BillableOrderPlaced }
    | {
        type: "orderAcknowledgmentSent";
        acknowledgmentSent: OrderAcknowledgmentSent;
      };
  type Command<Data> = {
    data: Data;
    timestamp: Date;
    userId: string;
    // etc
  };
  type PlaceOrderCommand = Command<UnvalidatedOrder>;
  type PlaceOrderWorkflow = (
    placeOrderCommand: PlaceOrderCommand // 入力コマンド
  ) => ResultAsync<PlaceOrderEvent[], PlaceOrderError>; // 出力イベント

  type ValidatedCustomerInfo = CustomerInfo;
  type ValidatedShippingAddress = Address;
  type ValidatedBillingAddress = Address;
  type PricedOrderLine = {
    orderLineId: string;
    productCode: ProductCode;
    quantity: OrderQuantity;
    linePrice: Price;
  };
  type BillingAmount = Price;

  // 価格計算済みの注文の型
  type PricedOrder = {
    orderId: OrderId;
    customerInfo: ValidatedCustomerInfo;
    shippingAddress: ValidatedShippingAddress;
    billingAddress: ValidatedBillingAddress;
    // 検証済みの注文明細行とは異なり、OrderLine→PricedOrderLineに変更
    orderLines: PricedOrderLine[];
    amountToBill: BillingAmount;
  };

  /// 受注確定ワークフローの成功出力
  type OrderPlaced = PricedOrder;
  type BillableOrderPlaced = {
    orderId: OrderId;
    billingAddress: Address;
    amountToBill: BillingAmount;
  };
  type OrderAcknowledgmentSent = {
    orderId: OrderId;
    emailAddress: EmailAddress;
  };

  type AcknowledgeOrder = (
    createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter // 依存関係
  ) => (
    sendOrderAcknowledgment: SendOrderAcknowledgment // 依存関係
  ) => (
    pricedOrder: PricedOrder // 入力
  ) => OrderAcknowledgmentSent | undefined; // 出力

  // 受注確定ワークフローの失敗出力
  type PlaceOrderError =
    | { type: "validation"; error: ValidationError }
    | { type: "pricing"; error: PricingError }
    | { type: "remoteServiceError"; error: RemoteServerError }
    | { type: "createEventsError"; error: CreateEventsError };

  type CreateEventsError = { type: "error"; error: string };

  type CreateEvents = (
    pricedOrder: PricedOrder // 入力
  ) => (
    orderAcknowledgmentSent?: OrderAcknowledgmentSent // 入力(前のステップのイベント)
  ) => PlaceOrderEvent[]; // 出力

  // ====================
  // パート2: 実装
  // ====================

  // ====================
  // 注文の検証: 実装
  // ====================

  const toCustomerInfo = (
    unvalidatedCustomerInfo: UnvalidatedCustomerInfo
  ): CustomerInfo => {
    const firstName: FirstName = unvalidatedCustomerInfo.firstName;
    const lastName: LastName = unvalidatedCustomerInfo.lastName;
    const emailAddress: EmailAddress = unvalidatedCustomerInfo.emailAddress;
    const name: PersonalName = {
      firstName,
      lastName,
    };

    const customerInfo: CustomerInfo = {
      name,
      emailAddress,
    };

    return customerInfo;
  };

  const toAddress = (
    checkedAddress: CheckedAddress
  ): ResultAsync<Address, ValidationError> => {
    const result: Address = {
      addressLine1: checkedAddress.address.addressLine1,
      addressLine2: checkedAddress.address.addressLine2,
      addressLine3: checkedAddress.address.addressLine3,
      addressLine4: checkedAddress.address.addressLine4,
      city: checkedAddress.address.city,
      zipCode: checkedAddress.address.zipCode,
    };

    return okAsync(result);
  };

  const predicateToPassthru =
    (errorMsg: string) => (f: CheckProductCodeExists) => (x: ProductCode) => {
      if (f(x)) {
        return x;
      } else {
        throw new Error(errorMsg);
      }
    };

  const toProductCode =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (productCode: ProductCode) => {
      const checkProduct = (productCode: ProductCode): ProductCode => {
        const errorMsg = `Invalid: ${productCode}`;
        return predicateToPassthru(errorMsg)(checkProductCodeExists)(
          productCode
        );
      };
      return checkProduct(productCode);
    };

  const toOrderQuantity =
    (productCode: ProductCode) =>
    (quantity: OrderQuantity): OrderQuantity => {
      const createUnitQuantity = (unitQuantity: OrderQuantity): number => {
        if (unitQuantity.type === "unitQuantity") {
          return unitQuantity.unitQuantity;
        } else {
          throw new Error("Invalid unit quantity");
        }
      };

      const createKilogramQuantity = (kiloQuantity: OrderQuantity): number => {
        if (kiloQuantity.type === "kilogramQuantity") {
          return kiloQuantity.kilogramQuantity;
        } else {
          throw new Error("Invalid kilogram quantity");
        }
      };

      const createOrderQuantity = (
        quantity: number,
        type: string
      ): OrderQuantity => {
        return type === "widgetCode"
          ? { type: "unitQuantity", unitQuantity: quantity }
          : { type: "kilogramQuantity", kilogramQuantity: quantity };
      };

      switch (productCode.type) {
        case "widgetCode":
          const unitQuantity = createUnitQuantity(quantity);
          return createOrderQuantity(unitQuantity, "widgetCode");
        case "gizmoCode":
          const kilogramQuantity = createKilogramQuantity(quantity);
          return createOrderQuantity(kilogramQuantity, "gizmoCode");
        default:
          throw new Error("Unknown product code");
      }
    };

  const toValidatedOrderLine =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (unvalidatedOrderLine: UnvalidatedOrderLine): ValidatedOrderLine => {
      const orderLineId = unvalidatedOrderLine.orderLineId;
      const productCode = toProductCode(checkProductCodeExists)(
        unvalidatedOrderLine.productCode
      ); // ヘルパー関数(toProductCode)
      const quantity = toOrderQuantity(productCode)(
        unvalidatedOrderLine.quantity
      ); // ヘルパー関数(toOrderQuantity)

      const validatedOrderLine: ValidatedOrderLine = {
        orderLineId,
        productCode,
        quantity,
      };

      return validatedOrderLine;
    };

  /// 注文の検証ステップの実装

  const validateOrder: ValidateOrder =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (unvalidatedOrder: UnvalidatedOrder) => {
      return safeTry(async function* () {
        const create = (value: string): OrderId => ({ value });
        const orderId: OrderId = create(unvalidatedOrder.orderId);

        const customerInfo: CustomerInfo = toCustomerInfo(
          unvalidatedOrder.customerInfo
        );

        const checkedShippingAddress = yield* toCheckedAddress(
          checkAddressExistsR
        )(unvalidatedOrder.shippingAddress).safeUnwrap();

        // ResultAsync<T, E>のTだけを得たいため、yield* safeUnwrap()を使って取得する
        const shippingAddress = yield* toAddress(
          checkedShippingAddress
        ).safeUnwrap();

        const checkedBillingAddress = yield* toCheckedAddress(
          checkAddressExistsR
        )(unvalidatedOrder.billingAddress).safeUnwrap();

        // ResultAsync<T, E>のTだけを得たいため、yield* safeUnwrap()を使って取得する
        const billingAddress = yield* toAddress(
          checkedBillingAddress
        ).safeUnwrap();

        const orderLines = unvalidatedOrder.orderLines.map(
          toValidatedOrderLine(checkProductCodeExists)
        );

        const validatedOrder: ValidatedOrder = {
          orderId,
          customerInfo,
          shippingAddress,
          billingAddress,
          orderLines,
        };

        return ok(validatedOrder);
      });
    };

  // Priceに数量を掛け合わせられるヘルパー関数
  const multiply = (p: Price, qty: OrderQuantity): Price => {
    return qty.type === "unitQuantity"
      ? p * qty.unitQuantity
      : p * qty.kilogramQuantity;
  };

  // 検証済みの注文明細行を価格計算済みの注文明細行に変換する
  const toPricedOrderLine =
    (getProductPrice: GetProductPrice) =>
    (line: ValidatedOrderLine): PricedOrderLine => {
      const qty = line.quantity;
      const price = getProductPrice(line.productCode);
      const linePrice = multiply(price, qty);

      return {
        orderLineId: line.orderLineId,
        productCode: line.productCode,
        quantity: line.quantity,
        linePrice,
      };
    };

  // 価格リストを合計して請求総額にする
  // 合計が範囲外の場合は例外を発生させる
  const sumPrices = (prices: Price[]) => {
    const total = prices.reduce((total, price) => total + price, 0);
    return total;
  };

  // 価格計算ステップの実装
  const priceOrder: PriceOrder = (getProductPrice) => (validatedOrder) => {
    const lines = validatedOrder.orderLines.map(
      toPricedOrderLine(getProductPrice)
    );
    const amountToBill = sumPrices(lines.map((line) => line.linePrice));

    const pricedOrder: PricedOrder = {
      orderId: validatedOrder.orderId,
      customerInfo: validatedOrder.customerInfo,
      shippingAddress: validatedOrder.shippingAddress,
      billingAddress: validatedOrder.billingAddress,
      orderLines: lines,
      amountToBill,
    };

    return ok(pricedOrder);
  };

  // 確認ステップの実装
  const acknowledgeOrder: AcknowledgeOrder =
    (createAcknowledgmentLetter) =>
    (sendOrderAcknowledgment) =>
    (pricedOrder): OrderAcknowledgmentSent | undefined => {
      const letter = createAcknowledgmentLetter(pricedOrder);
      const acknowledgment: OrderAcknowledgment = {
        emailAddress: pricedOrder.customerInfo.emailAddress,
        letter,
      };

      // 送信が成功した場合はOrderAcknowledgmentSentを返す
      // 失敗した場合はNoneを返す
      const sendResult = sendOrderAcknowledgment(acknowledgment);
      switch (sendResult.type) {
        case "Sent":
          const event: OrderAcknowledgmentSent = {
            orderId: pricedOrder.orderId,
            emailAddress: pricedOrder.customerInfo.emailAddress,
          };
          return event;
        case "NotSent":
          return undefined;
      }
    };

  type Some<T> = { type: "Some"; value: T };
  type None = { type: "None" };
  type Option<T> = Some<T> | None;
  // オプション型をリスト型に変換するヘルパー関数
  const listOfOption = (opt?: Option<PlaceOrderEvent>): PlaceOrderEvent[] => {
    switch (opt?.type) {
      case "Some":
        return [opt.value];
      case "None":
        return [];
      default:
        return [];
    }
  };

  const createBillingEvent =
    (pricedOrder: PricedOrder) => (): Option<BillableOrderPlaced> => {
      const BillingAmount = pricedOrder.amountToBill;
      if (BillingAmount > 0) {
        const order = {
          orderId: pricedOrder.orderId,
          billingAddress: pricedOrder.billingAddress,
          amountToBill: pricedOrder.amountToBill,
        };
        return { type: "Some", value: order };
      }
      return { type: "None" };
    };

  const createEvents: CreateEvents =
    (pricedOrder) =>
    (acknowledgmentEventOpt?): PlaceOrderEvent[] => {
      const event: PlaceOrderEvent = {
        type: "orderPlaced",
        orderPlaced: pricedOrder,
      };
      const event1 = listOfOption({ type: "Some", value: event });

      const event20pt = acknowledgmentEventOpt
        ? {
            type: "orderAcknowledgmentSent" as const,
            acknowledgmentSent: acknowledgmentEventOpt,
          }
        : undefined;

      const event2 = event20pt
        ? listOfOption({ type: "Some", value: event20pt })
        : [];

      const event30pt = (() => {
        const billingEvent = createBillingEvent(pricedOrder)();
        if (billingEvent.type === "Some") {
          return {
            type: "billableOrderPlaced" as const,
            billableOrderPlaced: billingEvent.value,
          };
        }
        return undefined;
      })();

      const event3 = event30pt
        ? listOfOption({ type: "Some", value: event30pt })
        : [];

      return [...event1, ...event2, ...event3];
    };

  const serviceExceptionAdapter = <T, E, X>(
    serviceInfo: ServiceInfo,
    fService: (x: X) => ResultAsync<T, E>
  ): ((x: X) => ResultAsync<T, RemoteServerError>) => {
    return (x: X) => {
      const result = fService(x);
      const mappedResult = result.mapErr((error) => {
        const remoteServerError: RemoteServerError = {
          service: serviceInfo,
          exception: `${error}`,
        };
        return remoteServerError;
      });
      return mappedResult;
    };
  };

  // bind: Result<T>と(T) => Result<U>を使って、Result<U>を手にいれる

  const checkAddressExists: CheckAddressExists = (
    unvalidatedAddress: UnvalidatedAddress
  ) => {
    // Result<Response>
    const r1 = fromPromise(
      fetch("https://example.com/address-service/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(unvalidatedAddress),
      }),
      () => Error("Address Validation Error")
    );

    // Result<Response> to Result<Address>
    const r2 = r1.andThen((r1) => {
      const r2 = fromPromise(r1.json() as Promise<Address>, () =>
        Error("FooError")
      );

      return r2;
    });

    // Result<Address> to Result<CheckedAddress>
    const r3 = r2.map((address) => {
      const x: CheckedAddress = {
        type: "checkedAddress",
        address,
      };

      return x;
    });

    return r3;
  };

  const checkAddressExistsR = (
    unvalidatedAddress: UnvalidatedAddress
  ): ResultAsync<CheckedAddress, RemoteServerError> => {
    const serviceInfo: ServiceInfo = {
      name: "AddressService",
      endpoint: "https://example.com/address-service/",
    };

    const adaptedService = serviceExceptionAdapter(
      serviceInfo,
      checkAddressExists
    );

    return adaptedService(unvalidatedAddress);
  };

  const toCheckedAddress =
    (
      checkAddressExistsR: (
        unvalidatedAddress: UnvalidatedAddress
      ) => ResultAsync<CheckedAddress, RemoteServerError>
    ) =>
    (unvalidatedAddress: UnvalidatedAddress) => {
      const result = checkAddressExistsR(unvalidatedAddress);
      return result.mapErr((error) => {
        const validationError: ValidationError = {
          type: "error",
          error: `${error}`,
        };
        return validationError;
      });
    };

  // ====================
  // ワークフローの全体像
  // ====================

  const placeOrder =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (getProductPrice: GetProductPrice) =>
    (createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) =>
    (sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => {
      return (placeOrderCommand: PlaceOrderCommand) => {
        // DI
        const fValidateOrder = validateOrder(checkProductCodeExists);
        const fPricedOrder = priceOrder(getProductPrice);
        const fAcknowledgmentOption = acknowledgeOrder(
          createOrderAcknowledgmentLetter
        )(sendOrderAcknowledgment);
        const fCreateEvents = createEvents;

        const validateOrderAdapted = (unvalidatedOrder: UnvalidatedOrder) => {
          // 引数を受け取って、実行結果のErrorを別のエラーに変換した関数を得たい
          const fResult = fValidateOrder(unvalidatedOrder);
          const result = fResult.mapErr((error) => {
            const placeOrderError: PlaceOrderError = {
              type: "validation",
              error: error,
            };

            return placeOrderError;
          });

          return result;
        };
        const priceOrderAdapted = (
          aValidatedOrder: ValidatedOrder
        ): Result<PricedOrder, PlaceOrderError> => {
          const result = fPricedOrder(aValidatedOrder).mapErr((error) => {
            const placeOrderError: PlaceOrderError = {
              type: "pricing",
              error: error,
            };

            return placeOrderError;
          });

          return result;
        };

        // exec
        const aValidatedOrder = validateOrderAdapted(placeOrderCommand.data);
        const aPricedOrder = aValidatedOrder.andThen(priceOrderAdapted);
        const acknowledgedOption = aPricedOrder.map(fAcknowledgmentOption);
        return safeTry(async function* () {
          const aPricedOrderResult = yield* (await aPricedOrder).safeUnwrap();
          const acknowledgedOptionResult = yield* (
            await acknowledgedOption
          ).safeUnwrap();

          return ok(
            fCreateEvents(aPricedOrderResult)(acknowledgedOptionResult)
          );
        });
      };
    };
};

普段業務でTypeScriptを触っている方でも見慣れないコードになっているのではないでしょうか?
関数型プログラミングということで、関数のカリー化やResult型の導入、モナドの考え方によってコードが実装されて(いるらしい)います

ソースコード全体はこちら
https://github.com/mocchann/domain_modeling_functional/tree/main/src

関数型プログラミングをTypeScriptで実装するまでの戦いの遍歴

実は現在アコーディオンに添付しているものがソースコードの最終系で、それ以前に何度も書き直しを行っています
(ファイルの命名は適当ですが)以下ファイルの順番で実装を修正したり、ライブラリを導入したりして関数型プログラミングを表現しようとしています
※最終系は5のplaceOrderWorkflowFixToAddress.tsです

ここからファイル差分を順番に見ていく形で進めます

placeOrderWorkflowFirst.ts <-> placeOrderWorkflowSecond.ts

まず最初に実装したコードがplaceOrderWorkflowFirst.tsです
初期の実装ではカリー化された関数によって依存性注入と実行が混ざった状態でコードが書かれており、とても視認性が悪かったのでDIとExecuteにわけるリファクタリングを行いました

placeOrderWorkflow.ts
  // ====================
  // ワークフローの全体像
  // ====================

  const placeOrder =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (checkAddressExists: CheckAddressExists) =>
    (getProductPrice: GetProductPrice) =>
    (createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) =>
    (sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => {
      return async (placeOrderCommand: PlaceOrderCommand) => {
-       const validatedOrder = await validateOrder(checkProductCodeExists)(
-          checkAddressExists
-       )(placeOrderCommand.data);
-
-       const pricedOrder = priceOrder(getProductPrice)(validatedOrder);
-
-       const acknowledgmentOption = acknowledgeOrder(
-         createOrderAcknowledgmentLetter
-       )(sendOrderAcknowledgment)(pricedOrder);
+       // DI
+       const fValidatedOrder = validateOrder(checkProductCodeExists)(
+         checkAddressExists
+       );
+       const fPricedOrder = priceOrder(getProductPrice);
+       const fAcknowledgmentOption = acknowledgeOrder(
+         createOrderAcknowledgmentLetter
+       )(sendOrderAcknowledgment);
+
+       // exec
+       const validatedOrder = await fValidatedOrder(placeOrderCommand.data);
+       const pricedOrder = fPricedOrder(validatedOrder);
+       const acknowledgmentOption = fAcknowledgmentOption(pricedOrder);

        const events = createEvents(pricedOrder)(acknowledgmentOption);

        return { success: true, value: events };
      };
    };

FirstとSecondの差分はこれだけです

placeOrderWorkflowSecond.ts <-> placeOrderWorkflowErrorHandling

次にResult型を使ってエラーを明示するように実装修正・追加を行いました
※通常の関数を1本の線路だとすると、Result型を出力する関数は2つ(成功 or 失敗)に分かれる線路だというイメージと表現されています(以下書籍を参考に作成したイメージが以下)
著者はこれを「鉄道指向プログラミング」と表現しています

alt text

※このような関数のことを関数型プログラミングの世界では「モナディック関数」と呼ぶそうです

placeOrderWorkflow.ts
  // ====================
  // ワークフローの全体像
  // ====================

  const placeOrder =
    (checkProductCodeExists: CheckProductCodeExists) =>
-   (checkAddressExists: CheckAddressExists) =>
    (getProductPrice: GetProductPrice) =>
    (createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) =>
    (sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => {
      return async (placeOrderCommand: PlaceOrderCommand) => {
        // DI
-       const fValidatedOrder = validateOrder(checkProductCodeExists)(
-         checkAddressExists
-       );
+       const fValidateOrder = validateOrder(checkProductCodeExists);
        const fPricedOrder = priceOrder(getProductPrice);
        const fAcknowledgmentOption = acknowledgeOrder(
          createOrderAcknowledgmentLetter
        )(sendOrderAcknowledgment);
+       const fCreateEvents = createEvents;
+
+       const validateOrderAdapted = async (
+         unvalidatedOrder: UnvalidatedOrder
+       ) => {
+         return mapError(
+           (fValidateOrder: ValidationError) => ({
+             type: "validation" as const,
+             error: fValidateOrder,
+           }),
+           await fValidateOrder(unvalidatedOrder)
+         );
+       };
+       const priceOrderAdapted = (aValidatedOrder: ValidatedOrder) => {
+         return mapError(
+           (fPricedOrder: PricingError) => ({
+             type: "pricing" as const,
+             error: fPricedOrder,
+           }),
+           fPricedOrder(aValidatedOrder)
+         );
+       };

        // exec
-       const validatedOrder = await fValidatedOrder(placeOrderCommand.data);
-       const pricedOrder = fPricedOrder(validatedOrder);
-       const acknowledgmentOption = fAcknowledgmentOption(pricedOrder);
-
-       const events = createEvents(pricedOrder)(acknowledgmentOption);
-
-       return { success: true, value: events };
+       const aValidatedOrder = await validateOrderAdapted(
+         placeOrderCommand.data
+       );
+       const aPricedOrder = bind(aValidatedOrder, priceOrderAdapted);
+       const acknowledgedOption = map(aPricedOrder, fAcknowledgmentOption);
+
+       if (aPricedOrder.type === "error") {
+         return { type: "error", error: aPricedOrder.error };
+       }
+
+       const events = map(
+         acknowledgedOption,
+         fCreateEvents(aPricedOrder.value)
+       );
+
+       return events;
      };
    };

+   const mapError = <T, E, F>(
+     f: (error: E) => F,
+     aResult: Result<T, E>
+   ): Result<T, F> => {
+     switch (aResult.type) {
+       case "ok":
+         return { type: "ok", value: aResult.value };
+       case "error":
+         return { type: "error", error: f(aResult.error) };
+     }
+   };
+ 
+   const bind = <T, E1, E2, U>(
+     result: Result<T, E1>,
+     f: (value: T) => Result<U, E2>
+   ): Result<U, E1 | E2> => {
+     switch (result.type) {
+       case "ok":
+         return f(result.value);
+       case "error":
+         return result;
+     }
+   };
+ 
+   const map = <T, E, U>(
+     result: Result<T, E>,
+     f: (value: T) => U
+   ): Result<U, E> => {
+     switch (result.type) {
+       case "ok":
+         return { type: "ok", value: f(result.value) };
+       case "error":
+         return result;
+     }
+   };

差分が多いため、上記は変更したコードの一部を記載しています
全実装はGitHubの以下ファイルを閲覧ください

https://github.com/mocchann/domain_modeling_functional/blob/main/src/placeOrderWorkflowErrorHandling.ts

また、この実装ではResult型を用いているため、関数の出力は成功 or 失敗の2パターンが想定されます
このモナディック関数同士の入出力を1つのパイプラインに合成するために、「mapError/bind/map」という3つの関数を作成しています(書籍を参考に作成したイメージが以下)

alt text

書籍ではこのイメージ上部の矢印を「成功トラック」、イメージ下部の分岐した矢印を「失敗トラック」と呼んでいます

ステップごとに型が変わる可能性のある成功トラックとは異なり、エラートラックでは、トラックに沿ってずっと均一な型があります。つまり、パイプライン内のすべての関数は、同じエラー型を持たなければなりません。

この引用のエラー型を満たすための関数としてmapErrorを作成しています
これはResult<T, E>Result<T, F>に変換する関数です

const mapError = <T, E, F>(
    f: (error: E) => F,
    aResult: Result<T, E>
  ): Result<T, F> => {
    switch (aResult.type) {
      case "ok":
        return { type: "ok", value: aResult.value };
      case "error":
        return { type: "error", error: f(aResult.error) };
    }
  };

失敗する可能性がある関数にはbind使います
bindはResult<T, E1>と(T) => Result<U, E2>を使って、Result<U, E1 | E2>を手にいれます

const bind = <T, E1, E2, U>(
    result: Result<T, E1>,
    f: (value: T) => Result<U, E2>
  ): Result<U, E1 | E2> => {
    switch (result.type) {
      case "ok":
        return f(result.value);
      case "error":
        return result;
    }
  };

失敗する可能性がない関数にはmapを使って、Result<T, E>Result<U, E>に変換します

const map = <T, E, U>(
    result: Result<T, E>,
    f: (value: T) => U
  ): Result<U, E> => {
    switch (result.type) {
      case "ok":
        return { type: "ok", value: f(result.value) };
      case "error":
        return result;
    }
  };

このような実装をするときの思考手順は以下をイメージするとわかりやすかったです

この思考手順を元にもう一度実装を見てみます(NOTEで説明を追加)

  const placeOrder =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (getProductPrice: GetProductPrice) =>
    (createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) =>
    (sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => {
      return async (placeOrderCommand: PlaceOrderCommand) => {
        // DI
        const fValidateOrder = validateOrder(checkProductCodeExists);
        const fPricedOrder = priceOrder(getProductPrice);
        const fAcknowledgmentOption = acknowledgeOrder(
          createOrderAcknowledgmentLetter
        )(sendOrderAcknowledgment);
        const fCreateEvents = createEvents;

        // NOTE: fValidateOrderは失敗する可能性があるため、まずmapErrorでエラー型をパイプライン共通のエラー型に変換する
        const validateOrderAdapted = async (
          unvalidatedOrder: UnvalidatedOrder
        ) => {
          return mapError(
            (fValidateOrder: ValidationError) => ({
              type: "validation" as const,
              error: fValidateOrder,
            }),
            await fValidateOrder(unvalidatedOrder)
          );
        };
        // NOTE: fPriceOrderも失敗する可能性があるため、mapErrorに通してエラー型を共通のエラー型に変換する
        const priceOrderAdapted = (aValidatedOrder: ValidatedOrder) => {
          return mapError(
            (fPricedOrder: PricingError) => ({
              type: "pricing" as const,
              error: fPricedOrder,
            }),
            fPricedOrder(aValidatedOrder)
          );
        };

        // exec
        // NOTE: validateOrderAdaptedは失敗する可能性はあるが、パイプラインの最初の関数なのでbindは通さずそのまま実行して結果を得る
        const aValidatedOrder = await validateOrderAdapted(
          placeOrderCommand.data
        );
        // NOTE: priceOrderAdaptedは失敗する可能性があり、かつ入力がResult<成功, 失敗>の2パターンのためbindを使う
        const aPricedOrder = bind(aValidatedOrder, priceOrderAdapted);
        // NOTE: 失敗する可能性がないためmapを使用
        const acknowledgedOption = map(aPricedOrder, fAcknowledgmentOption);

        if (aPricedOrder.type === "error") {
          return { type: "error", error: aPricedOrder.error };
        }

        // NOTE: 失敗する可能性がないためmapを使用
        const events = map(
          acknowledgedOption,
          fCreateEvents(aPricedOrder.value)
        );

        return events;
      };
    };

この思考手順で考えると実装の進め方が少しイメージしやすくなるかと思います

このようにbindとmapを用いることで、placeOrderの処理に特別な条件分岐やtry/catchブロックを記述することなくクリーンな状態を保つことができると書籍では説明されています
(一部条件分岐が入り込んでいますがこれは後続の処理で修正します)

placeOrderWorkflowErrorHandling <-> placeOrderWorkflowNeverThrow

次は実装が大きく変わっていきます
さきほど実装したResult型, bind, map, mapErrorなどTypeScriptでは言語標準として準備されていないため、関数型プログラミングをしたいときに毎回自前で実装する必要があります
これを良い感じに提供してくれるライブラリにneverthrowがあるのでこちらを使ってリファクタリングを行いました

https://github.com/supermacro/neverthrow

placeOrderWorkflow.ts
import { fromPromise, ok, Result, ResultAsync, safeTry } from "neverthrow";

  // ====================
  // ワークフローの全体像
  // ====================

  const placeOrder =
    (checkProductCodeExists: CheckProductCodeExists) =>
    (getProductPrice: GetProductPrice) =>
    (createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) =>
    (sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => {
-     return async (placeOrderCommand: PlaceOrderCommand) => {
+     return (placeOrderCommand: PlaceOrderCommand) => {
        // DI
        const fValidateOrder = validateOrder(checkProductCodeExists);
        const fPricedOrder = priceOrder(getProductPrice);
        const fAcknowledgmentOption = acknowledgeOrder(
          createOrderAcknowledgmentLetter
        )(sendOrderAcknowledgment);
        const fCreateEvents = createEvents;

-       const validateOrderAdapted = async (
-         unvalidatedOrder: UnvalidatedOrder
-       ) => {
-         return mapError(
-           (fValidateOrder: ValidationError) => ({
-             type: "validation" as const,
-             error: fValidateOrder,
-           }),
-           await fValidateOrder(unvalidatedOrder)
-         );
+       const validateOrderAdapted = (unvalidatedOrder: UnvalidatedOrder) => {
+         const fResult = fValidateOrder(unvalidatedOrder);
+         const result = fResult.mapErr((error) => {
+           const placeOrderError: PlaceOrderError = {
+             type: "validation",
+             error: error,
+           };
+
+           return placeOrderError;
+         });
+
+         return result;
        };

        // exec
-       const aValidatedOrder = await validateOrderAdapted(
-         placeOrderCommand.data
-       );
-       const aPricedOrder = bind(aValidatedOrder, priceOrderAdapted);
-       const acknowledgedOption = map(aPricedOrder, fAcknowledgmentOption);
-
-       if (aPricedOrder.type === "error") {
-         return { type: "error", error: aPricedOrder.error };
-       }
-
-       const events = map(
-         acknowledgedOption,
-         fCreateEvents(aPricedOrder.value)
-       );
-
-       return events;
+       const aValidatedOrder = validateOrderAdapted(placeOrderCommand.data);
+       const aPricedOrder = aValidatedOrder.andThen(priceOrderAdapted);
+       const acknowledgedOption = aPricedOrder.map(fAcknowledgmentOption);
+       return safeTry(async function* () {
+         const aPricedOrderResult = yield* (await aPricedOrder).safeUnwrap();
+         const acknowledgedOptionResult = yield* (
+           await acknowledgedOption
+         ).safeUnwrap();
+
+         return ok(
+           fCreateEvents(aPricedOrderResult)(acknowledgedOptionResult)
+         );
+       });
      };
    };
  };

- const mapError = <T, E, F>(
-   f: (error: E) => F,
-   aResult: Result<T, E>
- ): Result<T, F> => {
-   switch (aResult.type) {
-     case "ok":
-       return { type: "ok", value: aResult.value };
-     case "error":
-       return { type: "error", error: f(aResult.error) };
-   }
- };
-
- const bind = <T, E1, E2, U>(
-   result: Result<T, E1>,
-   f: (value: T) => Result<U, E2>
- ): Result<U, E1 | E2> => {
-   switch (result.type) {
-     case "ok":
-       return f(result.value);
-     case "error":
-       return result;
-   }
- };
-
- const map = <T, E, U>(
-   result: Result<T, E>,
-   f: (value: T) => U
- ): Result<U, E> => {
-   switch (result.type) {
-     case "ok":
-       return { type: "ok", value: f(result.value) };
-     case "error":
-       return result;
-   }
- };

上記コードのimportを見ればわかるように自前で実装していたResult型、AsyncResult型がそれぞれneverthrowのResult, ResultAsyncに置き換えられています
この型はそれぞれandThen(bind), map(map), mapErr(mapError)メソッドを持っているため、自前で用意した関数は必要なくなりました

※上記コードも差分が多いため、全コード確認したい方は以下ファイルを閲覧ください
https://github.com/mocchann/domain_modeling_functional/blob/main/src/placeOrderWorkflowNeverThrow.ts

また他にもsafeTryメソッドにより、パイプライン内に存在していた条件分岐も記述する必要がなくなっています

safeTrySample.ts
-       if (aPricedOrder.type === "error") {
-         return { type: "error", error: aPricedOrder.error };
-       }
-
-       const events = map(
-         acknowledgedOption,
-         fCreateEvents(aPricedOrder.value)
-       );
-
-       return events;
+       return safeTry(async function* () {
          // NOTE: ResultAsync<T, E>のTだけを得たいため、yield* safeUnwrap()を使ってResultAsync<T>だけを取得する
+         const aPricedOrderResult = yield* (await aPricedOrder).safeUnwrap();
+         const acknowledgedOptionResult = yield* (
+           await acknowledgedOption
+         ).safeUnwrap();
+
+         return ok(
+           fCreateEvents(aPricedOrderResult)(acknowledgedOptionResult)
+         );
+       });

aPricedOrderの型は以下のようになっており、条件分岐を絞り込みを行う等をしないと関数の合成ができませんでした

const aPricedOrder: Result<PricedOrder, {
    type: "validation";
    error: ValidationError;
} | {
    type: "pricing";
    error: PricingError;
}>

そこでまずandThenを用いて以下の型の結果を得たあと、safeTryの中でyield* safeUnwrap()を使うことにより、成功トラックのみを取得することで関数の合成を可能にしています

const aValidatedOrder: ResultAsync<ValidatedOrder, {
    type: "validation";
    error: ValidationError;
}>

まさにTypeScriptで関数型プログラミングを行うためのライブラリです

placeOrderWorkflowNeverThrow <-> placeOrderWorkflowFixToAddress

最後は一部書籍の内容を誤って実装しており、無理やり型を一致させて型エラーを無くしていた箇所を書籍に沿ったコードに修正しました

placeOrderWorkflow.ts
  const toAddress = (
-    unvalidatedAddress: UnvalidatedAddress
+    checkedAddress: CheckedAddress
  ): ResultAsync<Address, ValidationError> => {
-   const checkedAddress = checkAddressExistsR(unvalidatedAddress);
-
-   const result = checkedAddress.map((checkedAddress) => {
-     const mappedAddress: Address = {
-       addressLine1: checkedAddress.address.addressLine1,
-       addressLine2: checkedAddress.address.addressLine2,
-       addressLine3: checkedAddress.address.addressLine3,
-       addressLine4: checkedAddress.address.addressLine4,
-       city: checkedAddress.address.city,
-       zipCode: checkedAddress.address.zipCode,
-     };
-     return mappedAddress;
-   });
-
-   // 本来はここでreturnで良いが、DomainModelingのリポジトリのサンプルコードをみるとtoAddressの引数にはcheckedAddressを渡している
-   // そのため、本来返すべきValidationErrorに無理やり変換している
-   // return result;
-
-   const lierResult = result.mapErr((error) => {
-     const validationError: ValidationError = {
-       type: "error",
-       error: `${error}`,
-     };
-     return validationError;
-   });
-
-   return lierResult;
+   const result: Address = {
+     addressLine1: checkedAddress.address.addressLine1,
+     addressLine2: checkedAddress.address.addressLine2,
+     addressLine3: checkedAddress.address.addressLine3,
+     addressLine4: checkedAddress.address.addressLine4,
+     city: checkedAddress.address.city,
+     zipCode: checkedAddress.address.zipCode,
+   };
+
+   return okAsync(result);
  };

toAddress以外にも関連箇所を少し修正しています
冒頭にサンプルコードとしてアコーディオンに貼った最終系のコードがこれになります

https://github.com/mocchann/domain_modeling_functional/blob/main/src/placeOrderWorkflowFixToAddress.ts

これでようやくサンプルコードは一通り書き終わりました(ここまでやるのにちょうど3ヶ月かかりました)

まとめ

以下に書籍を読んでコードも書いてみた個人の感想を書いていきます

良かった点

  • シンプルにTypeScriptの勉強になった
    • bind, map, mapErrorの自前実装だったり、それらを使った関数型プログラミングでのパイプライン実装はなかなかに歯応えがありました
  • 書籍に登場するサンプルコードがGitHubで閲覧可能
    • 実装に詰まったときや、書籍にすべてのコードが記載されているわけではないのでリポジトリにサンプルコードがあるのはありがたかったです
    • 本家のリポジトリはこちら
  • モナドの考え方に触れられた
    • 今までオブジェクト指向ばかりに触れてきたので、bind, map等を駆使して連続した一連の処理を表現する(?)といったモナドの考え方の一端に触れることができたのは良かったと思います
  • 自前実装したあとにneverthrowを使ってリファクタリングをするのが最高に気持ち良い
    • 何か当たり前になっていた、ライブラリの"便利さ"と"ありがたさ"が身体中に染み渡りました
    • リファクタしている最中、気分良すぎて脳汁ドバドバ出てました
  • 戦略的DDDのアプローチを知ることができた
    • 今回の記事は技術に全振りした内容になっていますが、正味こっちの内容が本質だと思います
    • 今後、業務を行う中でフルフルDDDで開発するのはなかなか難しいかもしれませんが、ビジネスサイドとの仕様調整のやりとりだったり、設計の場面だったりで書籍でキャッチアップした知識を実践していきたい

課題な点

  • モナドの概念が抽象的すぎて正直何もわかっていない
    • これは普段関数型プログラミングに触れていないことも大きいかもしれませんが、全体的にふわっと触れてコード書いてみたになっています(業務で扱わないのが大きい)
    • また時間を作ってHaskellとか入門したい
  • 自前実装の型パズルに四苦八苦する
    • 改めてTSをもっと学習しないとなと思いtype-challenges触っています
  • TypeScriptで関数型プログラミングをするのはかなりハードルが高い
    • TSで関数型プログラミングをしようとすると、Result型やsafeTry, bind, mapなどTypeScriptに標準で用意されていないメソッドをneverthrowなどのライブラリで補うか自前で実装する必要がありますが、なんとも見慣れない感がすごい
      • ライブラリは便利で良いものですが、標準のTypeScriptの書き方からちょっと逸れた実装を行う必要があるので、関数型プログラミングをやるなら書籍で紹介されたF#など、関数型言語を選定したほうが良いのではと思ったりもします
      • ちなみにこの意見はネットでみかけたり、書籍を読んだ人と話したりもしました

おわりに

このサンプルコードの実装(特にneverthrowの部分)は自分一人ではなく、元同僚のNさんとペアプロで行いました
(この記事を見かけることはないかもしれませんがその説は本当に楽しく勉強になりました、ありがとうございました)

というわけでプロもくチャットAdvent Calendar 2024 8日目はmocchannがお届けしました

次回、9日目@babu-chさんの記事です!お楽しみに!

GitHubで編集を提案

Discussion