🐔

ドメインモデルは常に最適?トランザクションスクリプトという選択肢

に公開

はじめに

これまで私が所属するチームでは、バックエンドのアーキテクチャにおけるビジネスロジックの設計パターンとしてドメインモデルを採用してきました。

ビジネスロジックをドメインロジックとアプリケーションロジックに二分化し、ドメインロジックはドメインモデルレイヤーに、アプリケーションロジックはユースケースレイヤーに切り出すことで、ドメインロジックの再利用性やテスト容易性が高まるというメリットがありました。

しかし、実装・運用を重ねるうちに、以下のような課題が顕在化してきました。

  • 比較的単純な仕様を満たしたい場合であっても、複数の class/type 宣言、レイヤー分離、テストが必要になり、仕様に対しての実装コストが見合わない
  • チーム内でのドメインモデルの設計思想や理解の差により実装方針がばらつく
    • アプリケーションロジックとドメインロジックの線引きにかなりブレがあった印象
  • 共通のドメインモデルやドメインモデルの IO を担う永続化レイヤーにコードの変更が集中しコンフリクトが頻発
  • 設計レビューに時間がかかり、実装と乖離した議論が増える
    • いかに厳密にアーキテクチャを守るべきかにフォーカスしてしまうこともあり「俺たちはいったい何と戦っているんだ?」状態に陥る

このような背景から、比較的ロジックの単純なメール送信アプリケーションの新規開発にあたり、トランザクションスクリプトというアプローチを実験的に採用してみることにしました。

トランザクションスクリプトとは?

トランザクションスクリプトはビジネスロジックの設計パターンのひとつです。

ロジックを一つの手続きとしてまとめ、その手続きにアプリケーション外部からのリクエストの処理を担わせます。
手続き間で共通している部分は分割することも良しとされています。

ビジネスロジックがそのまま処理の流れとして表現されるため、実装はかなり愚直なものになります。
ドメインモデルと比べて全体の構造が非常にシンプルになる設計パターンです。

// 例: ユーザーにメールを送信するトランザクションスクリプト

type SendEmailToUserInput = {
  userId: string
  subject: string
  body: string
}

export async function sendEmailToUser(input: SendEmailToUserInput): Promise<void> {
  const user = await prisma.user.findUnique({
    where: { id: input.userId },
  })

  if (!user) {
    throw new Error('User not found')
  }

  if (!user.emailNotification || !user.email) {
    return
  }

  // 24時間以内に同一メールが送られていないかチェック
  const recentLog = await prisma.emailLog.findFirst({
    where: {
      userId: input.userId,
      subject: input.subject,
      body: input.body,
      sentAt: {
        gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
      },
    },
  })
  if (recentLog) {
    return
  }

  // メール送信
  await emailClient.send({
    to: user.email,
    subject: input.subject,
    body: input.body,
  })

  // ログを保存
  await prisma.emailLog.create({
    data: {
      userId: input.userId,
      subject: input.subject,
      body: input.body,
      sentAt: new Date(),
    },
  })
}

マーティン・ファウラーの『Patterns of Enterprise Application Architecture』で詳しく解説されています。
もっと深く知りたい方はそちらを参照ください。
https://martinfowler.com/books/eaa.html

実装構成と設計の工夫

今回トランザクションスクリプトを導入したサービスでは、以下のようなディレクトリ構成を採用しました。

.
├── handlers # イベント駆動・バッチなどのエントリポイント
├── features # トランザクションスクリプト本体
└── libs # 共通処理(ユーティリティ・インフララッパー・ビジネスロジック)

それぞれのディレクトリの責務や設計方針について紹介します。

handlers

handlers には、アプリケーションのエントリポイントを配置しました。
今回は実行基盤として Lambda を採用したため、SQS、EventBridge Rule 等との接続部分に相当します。

このレイヤーの責務は、初期バリデーションやロギングなどの最低限の前後処理、適切なトランザクションスクリプト(features)へのルーティングのみです。
レイヤードアーキテクチャやオニオンアーキテクチャなどで採用されるような handler や controller と同様の責務を持つレイヤーであると言えます。

// 例: SQS レコードを処理するエントリポイント

export async function handler(
  event: SQSEvent,
  context: Context,
): Promise<SQSBatchResponse> {
  // 入力イベントのログ出力
  logInfo("SQS event received", { context, event });

  // レコードをパース
  const { failures: parseFailures, recordsMap } = parseRecordsMap(
    event.Records,
  );
  // 適切なトランザクションを呼び出す
  const executeFailures = await callTxScript(recordsMap);
  // すべての失敗を集約
  const allFailures = [...parseFailures, ...executeFailures];

  // 失敗したレコードをログ出力
  await logFailures(context, allFailures);

  return {
    batchItemFailures: allFailures.map(({ messageId }) => ({
      itemIdentifier: messageId,
    })),
  };
}

features

features ディレクトリには、トランザクションスクリプトを配置しました。
基本的には1ファイル1スクリプトの方針を取りました。

入力の型のバリデーションやロギングを handlers に任せていることで、ビジネスロジックの組み立てのみに専念することができるようになっています。

スクリプト間の依存を無くし、共通化は最小限に抑える方針を取り、愚直でストレートに読み下せる書き振りを目指しました。

データベースの呼び出しには Dao や Repository などのパターンは採用せずに ORM を直接使うようにしました。
これによりスクリプトのテストを行う際にデータベースへの接続が必要になりましたが、今回はスクリプト単体での自動テストは行わず handler の統合テストのみを行う方針を取ったこともあり、特段困ることはありませんでした。(テスト方針については後述)

export async function sendEmail(
  sqsRecords: SQSRecord[],
): Promise<SQSRecordWithError[]> {
  const failures: SQSRecordWithError[] = [];
  const userIds = sqsRecords.map((record) => {
      const parsedBody = parseBody(record.body);
      return parsedBody.userId;
  });

  const users = await prisma.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
    },
    where: {
      id: {
        in: userIds,
      },
    },
  });

  const sendBulkEmailInput = buildEmailInput(users);

  try {
    await sendBulkEmail(sendBulkEmailInput);
  } catch (error) {
    return sqsRecords.map((record) => ({
      ...record,
      error: new Error(
        `Failed to send email: ${error instanceof Error ? error.message : error}`,
      ),
    }));
  }

  return;
}

libs

libs には、以下のような共通処理を集約しました。

  • AWS SDK やロガーなどのインフラ寄りのモジュールのラッパー
  • ID 生成などのユーティリティ
  • 共通の計算ロジック

なんとなく共通っぽいものを libs に入れるような文化になることだけは避けたかったため、切り出しの判断は慎重に行いました。

具体的には、

  • スクリプトにベタで書くとビジネスロジックの組み立ての邪魔になるようなインフラの設定値関連の操作を含む処理
  • 複数箇所での実装に差分があった際にユーザーへの悪影響が大きい処理
    • 例えば、お金の計算💰など

を切り出しの対象としました。

function calculateCost(
  unitPrice: number, 
  quantity: number, 
  maxCost?: number
): number {
  const totalCost = new Big(unitPrice).times(quantity);
  if (maxCost !== undefined && totalCost.gt(maxCost)) {
    return maxCost;
  }
  return totalCost.toNumber();
}
export async function sendBulkEmail({
  toAddresses,
  subject,
  body,
}: {
  toAddresses: string[];
  subject: string;
  body: string;
  fromAddress?: string;
}): Promise<void> {
  const fromEmailAddressDomain = process.env.FROM_EMAIL_ADDRESS;

  if (!fromEmailAddressDomain) {
    throw new Error(
      "FROM_EMAIL_ADDRESS_DOMAIN environment variable is not set",
    );
  }

  const maxRetires = process.env.SES_MAX_RETRIES
    ? parseInt(process.env.SES_MAX_RETRIES, 10)
    : 3;


  const emailClient = new EmailClient();
  emailClient.setMaxRetries(maxRetires);
  await emailClient.sendBulk({
    fromAddress: `no-reply@${fromEmailAddressDomain}`,
    toAddresses,
    subject,
    body,
  });
};

自動テスト

以下のような自動テスト方針を取りました。

  • handlers: 統合テスト
  • features: テストなし
  • libs: 計算ロジックのみ単体テスト

handlers の統合テストでは、SQS イベントを模した入力を与え、実際にトランザクションスクリプトが想定通り挙動することテストするようにしました。

データベースの呼び出しには testcontainers を利用し、コンテナ上で動作するデータベースに対して実際にクエリするようにしました。
https://testcontainers.com/

メール送信については、モックを利用して実際のメール送信は行わない方針にしました。
コンテナ上で動作するエミュレーターを利用することも検討しましたが、想定通りのタイトルや本文がメール送信関数に渡されているかだけを確認できれば十分に質が担保できると判断し、モックでの実装に留めました。

features のトランザクションスクリプトは、handler の統合テスト経由で実行されるため、個別のテストは実装しませんでした。
ロジックが複雑になり統合テストでの網羅が厳しくなった場合には、個別にテストを追加することも検討する必要があると考えています。

libs の計算ロジックについては、重要なビジネスロジックであるため単体テストを実装しました。
インフラのラッパーについては、ほとんどが設定値の読み取りや引数をそのまま渡すだけの処理であり、バグが混入する可能性が低いと判断してテストは実装しませんでした。

気付き

トランザクションスクリプトを採用したことで、いくつかの気づきを得ることができたので紹介します。

機能ごとにスクリプトが独立しているため、並行開発時の Git コンフリクトの発生が減少しました。
また、レイヤー分割や共通化を最小限に抑えたことで、ビジネスロジックの正しさのみに集中できるようになり、設計レビューや議論にかける時間が削減されました。

また、ドメインモデルを採用しなかったことにより、その必要性についても再認識することができました。
今回のサービスで共通関数として切り出した計算関数のような、ビジネス的に重要で再利用性の高いロジックはドメインモデルとして独立して扱うことはサービスの安定性向上への影響が大きいと感じました。

まとめ

今回は、トランザクションスクリプトを採用してみて得られた知見や実装方針について紹介しました。
今後も、早く継続的にユーザーに価値を届けることに繋がるような設計方針を模索するために、様々なアーキテクチャや設計パターンを学び実践していきたいと考えています。

フクロウラボ エンジニアブログ

Discussion