😶‍🌫️

Drizzle ORM のトランザクションを Service 層で扱う

に公開

こんにちは、タンベルベンチプレスの角度は45度派[1]の桐澤です。
クリーンアーキテクチャ(オレオレ)において Drizzle のトランザクションをサービス層で扱いたくなった時に落ち着いた書き方を紹介します。

Drizzle のトランザクション

drizzle のトランザクションは以下のように書きます。DB インスタンスの transaction メソッドに渡すコールバック関数内部でトランザクションを扱います。このコーバック関数内部の処理が同一のトランザクションで実行されます。
コールバック関数の引数から tx インスタンスを参照でき drizzle でお馴染みの書き方で SQL をビルドします。

const db = drizzle(...)

// 通常
await db.update(accounts).set({ balance: sql`${accounts.balance} - 100.00` }).where(eq(users.name, 'Dan'));
await db.update(accounts).set({ balance: sql`${accounts.balance} + 100.00` }).where(eq(users.name, 'Andrew'));

// トランザクション
await db.transaction(async (tx) => {
  await tx.update(accounts).set({ balance: sql`${accounts.balance} - 100.00` }).where(eq(users.name, 'Dan'));
  await tx.update(accounts).set({ balance: sql`${accounts.balance} + 100.00` }).where(eq(users.name, 'Andrew'));
});

// 参考:https://orm.drizzle.team/docs/transactions

テーブル定義

最初に解説の前提となる状況を整理していきます。
データベーステーブルはスクラップとスクラップに紐ずく画像を用意します。イメージとしてはXのポストとポストに紐ずく画像です。

テーブル定義
const scrapTable = pgTable("scrap", {
  id: serial("id").primaryKey(),
  chapterId: integer("chapter_id")
    .notNull()
    .references(() => chapterTable.id),
  userId: text("user_id")
    .notNull()
    .references(() => appUserTable.id),
  note: text("note").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at")
    .notNull()
    .defaultNow()
    .$onUpdate(() => sql`now()`),
});

const scrapImageTable = pgTable("scrap_image", {
  id: serial("id").primaryKey(),
  scrapId: integer("scrap_id")
    .notNull()
    .references(() => scrapTable.id),
  url: text("url").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at")
    .notNull()
    .defaultNow()
    .$onUpdate(() => sql`now()`),
});

画像付きのスクラップを投稿したときに scrap テーブルにスクラップの内容を、scrap_image テーブルにスクラップに紐ずく画像のURLをトランザクションを使って Insert する場合を想定してみます。

レイヤー分けせずに愚直に書いたパターン

技術スタックは Reactフレームワークに tanstack-start、画像アップロード(別のエンドポイントでアップロードした画像をコピー)に AWS S3 を使う想定です。
tanstack-startcreateServerFn(RPC)を使うため、バリデーションやレスポンス整形など Action もしくは Controller層で行う処理の一部ををいい感じに tanstack-start がやってくれます。
今回は createServerFn.handler に書かれる Service層以下をメインに実装していきます。

まずは愚直に書いてみます。

import { authenticationMiddleware } from "./middlewares/authentication";
import { createServerFn } from "@tanstack/react-start";
import { createScrapInputSchema } from "./libs/inputs/scrap";
import { db } from "./database/index";
import { scrapTable, scrapImageTable, type ScrapImageInsert } from "./database/schema/index";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import { s3Client } from "../libs/aws/s3/index";

const PREVIEW_BUCKET_NAME = "preview-storage";
const STORED_BUCKET_NAME = "stored-storage";

export const createScrap = createServerFn({ method: "POST" })
  .middleware([authenticationMiddleware])
  .inputValidator(createScrapInputSchema)
  .handler(async ({ data, context }) => {
    const userId = context.user.id;
    const scrap = data;

    await db.transaction(async (tx) => {
      const [row] = await tx
        .insert(scrapTable)
        .values({
          chapterId: scrap.chapterId,
          userId: userId,
          note: scrap.note,
        })
        .returning({ scrapId: scrapTable.id });

      const scrapId = row.scrapId;

      if (scrap.images.length > 0) {
        const scrapImages: ScrapImageInsert[] = scrap.images.map((fileName) => ({
          scrapId,
          fileName,
        }));

        await tx.insert(scrapImageTable).values(scrapImages);
      }
    });

    await Promise.all(
      scrap.images.map(async (fileName) => {
        const command = new CopyObjectCommand({
          Bucket: STORED_BUCKET_NAME,
          CopySource: `${PREVIEW_BUCKET_NAME}/${scrap.chapterId}/${fileName}`,
          Key: `${scrap.chapterId}/${fileName}`,
        });

        await s3Client.send(command);
      }),
    );
  });

レイヤーを分けて愚直に書く

レイヤーを分けて DBインスタンスをメソッド引数として伝搬させる愚直な書き方にしてみます。
この記事のタイトルはこの書き方で回収しています。ポイントは Repository 層に渡す DB インスタンスの型が PostgresJsDatabase となっている点です。この型は DB インスタンスとトランザクションのインスタンスを両方受け入れるため、Repository ではDB なのかトランザクションなのか気にすることなく実装できます。
Service 層でトランザクションを扱い、ロジックも寄せています。

import { authenticationMiddleware } from "@/libs/middlewares/authentication";
import { createServerFn } from "@tanstack/react-start";
import { createScrapInputSchema } from "./libs/inputs/scrap";
import { db, DB } from "./database/index";
import { scrapTable, scrapImageTable, type ScrapImageInsert } from "./database/schema/index";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import { s3Client } from "./libs/aws/s3/index";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";

const PREVIEW_BUCKET_NAME = "preview-storage";
const STORED_BUCKET_NAME = "stored-storage";

export const createScrap = createServerFn({ method: "POST" })
  .middleware([authenticationMiddleware])
  .inputValidator(createScrapInputSchema)
  .handler(async ({ data, context }) => {
    const userId = context.user.id;
    const scrap = data;

    await ScrapService.createScrap(db, userId, scrap);
    await S3Repository.copy(scrap);
  });

class ScrapService {
  static async createScrap(
    db: DB,
    userId: string,
    scrap: {
      chapterId: number;
      note: string;
      images: string[];
    },
  ) {
    await db.transaction(async (tx) => {
      const scrapId = await ScrapRepository.create(tx, userId, scrap);

      if (scrap.images.length > 0) {
        const scrapImages: ScrapImageInsert[] = scrap.images.map((fileName) => ({
          scrapId,
          fileName,
        }));

        await ScrapImageRepository.create(tx, scrapImages);
      }
    });
  }
}

class ScrapRepository {
  static async create(
    db: PostgresJsDatabase,
    userId: string,
    scrap: {
      chapterId: number;
      note: string;
      images: string[];
    },
  ) {
    const [row] = await db
      .insert(scrapTable)
      .values({
        chapterId: scrap.chapterId,
        userId: userId,
        note: scrap.note,
      })
      .returning({ scrapId: scrapTable.id });

    return row.scrapId;
  }
}

class ScrapImageRepository {
  static async create(db: PostgresJsDatabase, scrapImages: ScrapImageInsert[]) {
    await db.insert(scrapImageTable).values(scrapImages);
  }
}

class S3Repository {
  static async copy(scrap: { chapterId: number; note: string; images: string[] }) {
    await Promise.all(
      scrap.images.map(async (fileName) => {
        const command = new CopyObjectCommand({
          Bucket: STORED_BUCKET_NAME,
          CopySource: `${PREVIEW_BUCKET_NAME}/${scrap.chapterId}/${fileName}`,
          Key: `${scrap.chapterId}/${fileName}`,
        });

        await s3Client.send(command);
      }),
    );
  }
}

Interface を使って書く

この記事の趣旨は前項までの内容で完結していますが、サンプルコードをさらにリファクタリングしてみます。

一般的に?Repository 層は Interface に依存させてシステム境界を疎結合にし、改修しやすくしたり、テストしやすくしたりします(愚直に DB インスタンスを引数で渡すのも実装がわかりやすいし、一定テストしやすかったりはするんですけどね)。

他大体以下のようなことを考えて、

  • RepositoryInterface はドメインとして切り出す部分であるため Interface で依存するのはプリミティブな型か、同じくドメインとして切り出されるバリューオブジェクト(今回はないんですけどね)のみにしています
  • そのため、DB インスタンスを Constructor で注入するようにしていますが、こうするとどこで new するか?というポイントが出てきます
  • Service で new するという方法も思いつきますが、それだと ServiceRepository の具象に依存することになるため、疎結合にするために Factory で new するようにします
  • S3 Repository に関してはトランザクションインスタンスを注入する必要がないため Action で new しています。DBの場合もトランザクションが必要ない場合は同じように Action で new できます。

以下のような実装に落ち着きました。Service では Repository に関する Interface を参照するようになります。

import { authenticationMiddleware } from "@/libs/middlewares/authentication";
import { createServerFn } from "@tanstack/react-start";
import { createScrapInputSchema } from "./libs/inputs/scrap";
import { db, DB } from "./database/index";
import { scrapTable, scrapImageTable, type ScrapImageInsert } from "./database/schema/index";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import { s3Client } from "./libs/aws/s3/index";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";

const PREVIEW_BUCKET_NAME = "preview-storage";
const STORED_BUCKET_NAME = "stored-storage";

export const createScrap = createServerFn({ method: "POST" })
  .middleware([authenticationMiddleware])
  .inputValidator(createScrapInputSchema)
  .handler(async ({ data, context }) => {
    const userId = context.user.id;
    const scrap = data;

    await ScrapService.createScrap(
      db,
      ScrapRepositoryFactoryImpl,
      ScrapImageRepositoryFactoryImpl,
      new S3Repository({
        previewBucketName: PREVIEW_BUCKET_NAME,
        storedBucketName: STORED_BUCKET_NAME,
      }),
      userId,
      scrap,
    );
  });

class ScrapService {
  static async createScrap(
    db: DB,
    ScrapRepoFactory: ScrapRepositoryFactory,
    ScrapImageRepoFactory: ScrapImageRepositoryFactory,
    s3Repo: StaticMediaStorage,
    userId: string,
    scrap: {
      chapterId: number;
      note: string;
      images: string[];
    },
  ) {
    await db.transaction(async (tx) => {
      const scrapRepo = ScrapRepoFactory.create(tx);
      const scrapImageRepo = ScrapImageRepoFactory.create(tx);

      const scrapId = await scrapRepo.create(userId, scrap);

      if (scrap.images.length > 0) {
        const scrapImages: ScrapImageInsert[] = scrap.images.map((fileName) => ({
          scrapId,
          fileName,
        }));

        await scrapImageRepo.create(scrapImages);
      }
    });

    await s3Repo.copy({ id: scrap.chapterId, fileNames: scrap.images });
  }
}

interface ScrapWriteModelRepository {
  create(
    userId: string,
    scrap: {
      chapterId: number;
      note: string;
      images: string[];
    },
  ): Promise<number>;
}

class ScrapRepository implements ScrapWriteModelRepository {
  constructor(private readonly db: PostgresJsDatabase) {}

  async create(
    userId: string,
    scrap: {
      chapterId: number;
      note: string;
      images: string[];
    },
  ) {
    const [row] = await this.db
      .insert(scrapTable)
      .values({
        chapterId: scrap.chapterId,
        userId: userId,
        note: scrap.note,
      })
      .returning({ scrapId: scrapTable.id });

    return row.scrapId;
  }
}

interface ScrapRepositoryFactory {
  create(db: PostgresJsDatabase): ScrapWriteModelRepository;
}

const ScrapRepositoryFactoryImpl: ScrapRepositoryFactory = class {
  static create(db: PostgresJsDatabase): ScrapWriteModelRepository {
    return new ScrapRepository(db);
  }
};

interface ScrapImageWriteModelRepository {
  create(
    scrapImages: Array<{
      scrapId: number;
      url: string;
      id?: number | undefined;
      createdAt?: Date | undefined;
      updatedAt?: Date | undefined;
    }>,
  ): Promise<void>;
}

class ScrapImageRepository implements ScrapImageWriteModelRepository {
  constructor(private readonly db: PostgresJsDatabase) {}

  async create(scrapImages: ScrapImageInsert[]) {
    await this.db.insert(scrapImageTable).values(scrapImages);
  }
}

interface ScrapImageRepositoryFactory {
  create(db: PostgresJsDatabase): ScrapImageWriteModelRepository;
}

const ScrapImageRepositoryFactoryImpl: ScrapImageRepositoryFactory = class {
  static create(db: PostgresJsDatabase): ScrapImageWriteModelRepository {
    return new ScrapImageRepository(db);
  }
};

interface StaticMediaStorage {
  copy(params: { id: number; fileNames: string[] }): Promise<void>;
}

type S3RepositoryConfig = {
  previewBucketName: string;
  storedBucketName: string;
};

class S3Repository implements StaticMediaStorage {
  constructor(private readonly config: S3RepositoryConfig) {}

  async copy({ id: chapterId, fileNames }: { id: number; fileNames: string[] }) {
    await Promise.all(
      fileNames.map(async (fileName) => {
        const command = new CopyObjectCommand({
          Bucket: this.config.storedBucketName,
          CopySource: `${this.config.previewBucketName}/${chapterId}/${fileName}`,
          Key: `${chapterId}/${fileName}`,
        });

        await s3Client.send(command);
      }),
    );
  }
}

致命的な欠点としては、トランザクションを実装する場合に、DB インスタンスを Service に渡す必要があり、DB インスタンスは Serivce から直接参照できてしまうので、Repository を通さずにクエリをビルドできてしまう点です。
Repository が持ってる DB インスタンスからトランザクションを発行できるようにするとか何かしらは他に方法があるような気はしているんですが、何かしら別の方法を取ったらとったでトレードオフするものがあって考慮点が増え、一生アプリケーション開発が進まない気がするのでとりあえず Service で DB インスタンスからクエリをビルドしないようにするリンターを作るのが手っ取り早そうです。

まとめ

銀の弾丸欲しいです。

脚注
  1. バーベルベンチプレスを中心にやりつつ、大胸筋上部と三角筋を狙うことを目的としています ↩︎

Discussion