🧩

TypeScript ORMのトランザクションをUsecaseで綺麗に扱う

2024/12/26に公開

ねらい

この記事のねらいは、Usecase層から複数のリポジトリを呼び出す際、ORM標準のトランザクション機能をそのまま利用することなく、型安全かつレイヤーの分離を崩さない方法でトランザクションを扱えるようにすることです。

具体的には、以下のようなコードでトランザクションを実行出来るようにしていきます。

Usecase サンプル
function OrderProduct(uRepo:UserRepository, pRepo: ProductRepository, oRepo:OrderRepository, tx: TransactionRunner) {
  return async function(userId:string, productId:string){
    const typedUserId = createUserId(userId);
    const typedProductId = createProductId(productId);

    return tx.run([uRepo,pRepo,oRepo], async(txRepos) => {
      const user = await txRepos.getUserById(userId);
      const product = await txRepos.getProductById(productId);

      await txRepos.purchaseProductByUserIdAndProductId(userId, productId);

      const order = Order.create(user, product);
      return await txRepos.createOrder(order);
    });
  };
}

はじめに

Controller / Usecase / Repositoryのようなレイヤードアーキテクチャででコードを書いていると、ORMの機能を用いたトランザクションの実装に悩まされると思います。PrismaやDrizzleではdb.transactionメソッドが用意されており、引数で渡されるトランザクションのメソッドを使う形でクエリを組み立てていく必要があります。

ORM標準のトランザクション
db.transaction(async(tx) => {
  await tx.insert(users).values(userParams);
  await tx.insert(order).values(orderParams);
});

しかし、Controller / Usecase / Repository のようにレイヤーを分割している場合、以下のような問題が発生しがちです:

  • Usecase は Repository の実装を知らないため、db.transactionを直接呼びにくい
  • db.transactionを抽象化しても、Usecase からは「トランザクション付きのRepository」をどのように呼び出せばよいか分からない
  • Usecase内で直接Repositoryを生成すると、レイヤーの分離が崩れてしまう

これらの問題を解決するためには、トランザクション内で利用できるRepositoryを型安全に生成し利用できる仕組みが必要です。いくつか解決方法はありますが、今回は利用したいRepositoryにトランザクションが注入されたRepositoryをUsecaseから実行出来るようにして解決します。

実装

型定義

まずはtx.runを実行出来るようにするための型定義を行います。

TransactionRunnerの型定義
// Repositoryは必ずwithTxが生えていることにする
type Repository = {
  withTx: (tx: any) => Repository;
  [key: string]: any;
};

// Union型をIntersection型に変換する
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

export type TransactionRunner = {
  run<T, R extends Repository[]>(
    repositories: R,
    operation: (txRepos: UnionToIntersection<R[number]>) => Promise<T>
  ): Promise<T>;
};

Repositoryの配列を受け取ってひとまとめにし、Usecaseのビジネスロジックをトランザクション下で実行出来るような型を作ります。この辺は好みの問題ですが、受け取った配列をそのままコールバックに流して利用側ではtx.run([repo1,repo2],async((repo1,repo2)=>{...}としても良いと思います。 その場合、withTxを再利用できるのでトランザクション内でのトランザクションも可能になります。

withTxメソッドの実装

各RepositoryにwithTxメソッドをつけて回ります。

function newOrderRepository(db: dbClient) {
  return {
    createOrder:createOrder(db),
    withTx:(tx)=>newOrderRepository(tx), // 自身を再生成する
  };
}

クラスベースで記述しているならば、BaseRepositoryのような基底クラスにwithTxを実装をしておけば各Repositoryで実装する手間を省くことが出来ます。

具体的なトランザクションの実装

TransactionRunnerは、具体的なDBクライアントと紐づく実装を用意する必要があります。今回はDrizzleのdb.transactionを利用するサンプルを紹介します。

Drizzleでの実装サンプル
export function newDrizzleTransactionRunner(
  db: QueryExecutor
): TransactionRunner {
  return {
    async run(repositories, operation) {
      return db.transaction(async (txDb) => {
        const txRepoArray = repositories.map((repo) => repo.withTx(txDb));
        const mergedTxRepos = Object.assign({}, ...txRepoArray);

        return await operation(mergedTxRepos);
      });
    },
  };
}

DI

最後に、先ほど実装したTransactionRunnerをUsecaseにDIすれば準備完了です。

DI
import { db } from "./lib";

const uRepo = newUserRepository(db);
const pRepo = newProductRepository(db);
const oRepo = newOrderRepository(db);
const tx = newDrizzleTransactionRunner(db);
const uc = newOrderUsecase(uRepo, pRepo, oRepo, tx);

...

このようにすることで、UsecaseはRepositoryの実装を一切知ることなくトランザクションを実行できるようになりました。DBやORMを変えても問題なく動くようになり、適切な責務を保てるようになりました。また、RepositoryもwithTxさえ実装していれば他のメソッドは自身がトランザクション内外なのかを気にせずに実装を行えるようになりました。

補足: テスト戦略について

UsecaseのテストはRepositoryのモックを作って行うことが多いと思いますが、TransactionRunnerもモックを作って注入すると良いでしょう。シンプルに作るならば、トランザクションを張らずにMockRepositoryをそのまま返すだけのようなモックになります。

MockTransactionRunner
export function newMockTransactionRunner(): TransactionRunner {
  return {
    async run(repositories, operation) {
      const mergedRepos = Object.assign({}, ...repositories);
      return operation(mergedRepos);
    },
  };
}

補足: withTxは消せないのか?

いくつかのアプローチはあります。

export async function newTransactionRunner<T>(
  db: dbClient,
  operation: (txDb: dbClient) => Promise<T>
): Promise<T> {
  return {
    run: db.transaction(async (txDb) => {
      return operation(txDb);
    });
  }
}

純粋にトランザクションに包んで返すだけの実装を行い、以下のように利用します。

トランザクション内でリポジトリを生成する

...
    return tx.run(async(txDb) => {
      const txUserRepo = newUserRepository(txDb);
      const txProductRepo = newUserRepository(txDb);
      const txOrderRepo = repositoryFactory.createOrderRepository(txDb); // 或いはリポジトリ生成を抽象化する

      const user = await txUserRepo.getUserById(userId);
      const product = await txProductRepo.getProductById(productId);
...

デメリットとしては冒頭にも挙げた通り、UsecaseでRepositoryの具象を生成することになるのでレイヤーが崩れてしまう点です。

リポジトリのメソッドでdb/txを受け取るようにする

function newUserRepository(db:dbClient) {
  return {
    getUserById(db:dbClient, userId:UserID) { ... },
    ...
  };
}

デメリットとしてはRepositoryをクラス/オブジェクトとして扱いにくくなる点と、逆に記述量が増えることになる点です。トランザクション内で実行出来るメソッドを限定するならば良いかもしれません。

まとめ

上記のような実装を行うことで、リポジトリの詳細を知る必要なくTypeScript ORMが提供しているトランザクション機能をクリーンアーキテクチャで有効に扱うことが出来るようになります。トランザクションの実装に悩んでいる方への参考になれば幸いです。

また、もっと良いパターンを知っている方は是非教えてください!

Discussion