【Node.js実務講座】第14回:Prisma Client による型安全な CRUD 操作と高度なリレーション・トランザクション処理

社員ブログ

1. Prisma Client の生成メカニズムと型補完(Autocomplete)の裏側

酒本先輩!

前回で PostgreSQL を起動して、schema.prisma からマイグレーションを実行できました。

でも、prisma.cartItem.findMany() みたいなメソッドって、いつの間に作られているんですか?

コード補完が爆速で効くので感動したんですけど、仕組みが気になります!

Prisma といったらまさにその 『スキーマ駆動の型自動生成』 にあるのよ。

 

npx prisma migrate dev や npx prisma generate を実行した瞬間、Prisma は schema.prisma を解析して、指定した出力先(src/generated/prisma/client)に 専用の TypeScript 型定義ファイルと API Client を直接書き出すの。

 

一般的な ORM のように汎用的な型を提供するのではなく、スキーマに定義したフィールド名や型(Int、String、DateTime など)がそのまま TypeScript の型として生成されるから、タイポはコンパイルエラーになるし、IDE の自動補完が完璧に効くのよ。

【Prisma Client の自動生成ステップ】

ステップ1: schema.prisma(モデル定義ファイル)を用意
  ↓
ステップ2: 「npx prisma generate」コマンドを実行
  ↓
ステップ3: ../src/generated/prisma/client 配下に成果物を自動生成
  ・models/ Cart.ts, CartItem.ts (モデルごとのTypeScript型定義)
  ・client.js  (クエリビルダ本体・エントリーポイント)
  ↓
ステップ4: TypeScript コードで「import { PrismaClient } from '../../generated/prisma/client/client.js'」
  → 完全な型補完・静的型チェックが有効化!

2. 実務で必須!Prisma の基本 CRUD 操作とネストされた書き込み(Nested Writes)

まずは、実務で頻出する CRUD(作成・取得・更新・削除)の具体的なパターンを押さえましょう。

特に強力なのが、関連するテーブルのデータを1つのクエリで同時に作成・操作できる Nested Writes(ネストされた書き込み) よ。

1. Create(作成)& Nested Create(関連データ同時作成)

親データ(Cart)を作成すると同時に、子データ(CartItem)もまとめて作成します。

普通なら『カート作成』と『商品追加』で2回DBにアクセスしなきゃいけないんだけど、Prismaの Nested Writes を使うと、たった1つのクエリでアトミック(安全)に一括登録できるよ!

// カートを作成し、同時にカート内商品も1つのクエリで追加する
const newCart = await prisma.cart.create({
  data: {
    items: {
      create: [
        { productName: 'エルゴノミクスマウス', price: 8800, quantity: 1 },
        { productName: '4Kモニター', price: 45000, quantity: 2 },
      ],
    },
  },
  include: {
    items: true, // レスポンスに関係する items も含めて取得する
  },
});

2. Read(取得): 絞り込み・ソート・ページネーション・リレーション取得

実務の検索 API で必ず使う機能です。

// 複数件取得(フィルタリング・ソート・ページネーション・リレーション取得)
const items = await prisma.cartItem.findMany({
  where: {
    price: { gte: 5000 }, // 価格が 5,000 円以上 (>= 5000)
    productName: { contains: 'キーボード' }, // あいまい検索 (LIKE '%キーボード%')
  },
  orderBy: {
    price: 'desc', // 価格降順
  },
  take: 10, // 取得件数 (LIMIT 10)
  skip: 0,  // スキップ件数 (OFFSET 0)
  include: {
    cart: true, // 紐づく Cart 情報も JOIN して取得
  },
});

3. Update(更新)& Upsert(更新または作成)

データが存在すれば更新し、存在しなければ新規作成する upsert は、カートや設定情報の保存で非常に便利です。

// 条件に合うレコードがあれば更新、無ければ新規作成
const cartItem = await prisma.cartItem.upsert({
  where: { id: targetItemId },
  update: {
    quantity: { increment: 1 }, // 現在の値に +1 加算
  },
  create: {
    productName: 'ゲーミングヘッドセット',
    price: 12800,
    quantity: 1,
    cartId: defaultCartId,
  },
});

4. Delete(削除)

// ID を指定して単一レコードを削除
await prisma.cartItem.delete({
  where: { id: targetItemId },
});

3. アプリケーション層と DB モデルの整合性を保つ「型マッピング」

ここで超重要なアーキテクチャの原則を教えるわ。

Prisma が生成した型(CartItem モデルの型)をそのまま Controller や API レスポンスとして外に露出させてはいけないの。

えっ!どうしてですか?

Prisma の型をそのまま使った方が手軽じゃないですか?

理由は 『ドメインモデル(アプリケーション層の型)と DB スキーマ(インフラ層の型)の分離』 よ。

DB のカラム名(例: cart_id や created_at)を変更しただけで API のレスポンス形式まで変わってしまったら、フロントエンドに影響が出てしまうでしょ?

だからリポジトリ層で、Prisma のデータ型からアプリ固有の型(Domain Type)への マッピング(変換) を明示的に行うのが綺麗なレイヤードアーキテクチャの作法よ。

// src/repositories/prisma/prisma-cart.repository.ts

// Prisma が返す内部型
type PrismaCartItem = {
  id: number;
  productName: string;
  price: number;
  quantity: number;
  cartId: number;
  createdAt: Date;
  updatedAt: Date;
};

// アプリケーション(ドメイン)側で必要な型にマッピングして返す
private mapToDomain(item: PrismaCartItem): CartItem {
  return {
    id: item.id,
    productName: item.productName,
    price: item.price,
    quantity: item.quantity,
    // DB固有のフィールド(cartId, createdAt等)を削り、ドメイン層へ伝播させない
  };
}

4. 接続処理の共通化と依存性の注入(True DI)へのリファクタリング

酒本先輩!

第13回では PrismaCartRepository のコンストラクタ内で直接 new pg.Pool() や new PrismaClient() を作っていましたよね。

そうなのよ。

リポジトリの中で直接インスタンス化(new)してしまうと、単体テストでモックに差し替えることができなくなってしまうの。

 

まずは接続情報を専用のモジュール src/config/database.ts に集約し、リポジトリには外部から PrismaClient を注入する 『True DI(真の依存性の注入)』 へリファクタリングしましょう!

① 設定モジュール(src/config/database.ts)

// src/config/database.ts
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { PrismaClient } from '../generated/prisma/client/client.js';

// 第13回で npx prisma init により自動生成された .env から環境変数を取得[cite: 1]
const connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error('DATABASE_URL が .env に設定されていません。');
}

const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);

// アプリ全体で共有・注入するための PrismaClient インスタンス
export const prisma = new PrismaClient({ adapter });

5. データ整合性を保証する「トランザクション処理」

酒本先輩!

ECサイトのカート操作で、例えば『カートを全消去して新しい商品を一括追加する』みたいな処理を行うとき、途中でエラーが起きたら一部のデータだけ残っちゃいますよね?

これってどう防ぐんですか?

いい疑問ね!そこで登場するのが 『トランザクション(Transaction)』 よ。

 

複数のデータベース操作(例: 削除と作成)を1つの不可分な単位(All or Nothing)としてまとめ、すべて成功したら確定(Commit)、1つでも失敗したら全自動で元の状態に戻す(Rollback) 仕組みのことね。

Prisma 7 では、型安全で直感的に記述できる Interactive Transactions(prisma.$transaction) を使用します。

// トランザクション専用型 Prisma.TransactionClient を明示的に受け取る
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
  // tx はトランザクション専用の PrismaClient インスタンス
  // このコールバック内で発生した例外(throw)は自動的にロールバックされる

  // 1. カート内の既存商品を全削除
  await tx.cartItem.deleteMany({});
});

6. レイヤードアーキテクチャへの組み込み(完全版コード)

それでは、実際のプロジェクト構成に沿った完全な実装コードを作成していきましょう。

① インターフェースの定義(ICartRepository)

全削除(clearCart)メソッドを含むリポジトリインターフェースを定義します。

// src/repositories/interfaces/cart.repository.interface.ts
import { CartItem } from '../../types/cart.type.js';

export interface ICartRepository {
  findAll(): Promise<CartItem[]>;
  findById(id: number): Promise<CartItem | undefined>;
  create(data: Omit<CartItem, 'id'>): Promise<CartItem>;
  updateQuantity(id: number, quantity: number): Promise<CartItem | undefined>;
  delete(id: number): Promise<boolean>;
  clearCart(): Promise<void>;
}

② Prisma 用リポジトリの実装(PrismaCartRepository)

src/config/database.ts から生成された PrismaClient を注入(DI)され、ドメイン型への変換やトランザクション処理を行います。

// src/repositories/prisma/prisma-cart.repository.ts
import { PrismaClient, Prisma } from '../../generated/prisma/client/client.js';
import { ICartRepository } from '../interfaces/cart.repository.interface.js';
import { CartItem } from '../../types/cart.type.js';

export class PrismaCartRepository implements ICartRepository {
  // 💡 コンストラクタで PrismaClient を外部から受け取る(True DI)
  constructor(private readonly prisma: PrismaClient) {}

  // 内部ユーティリティ: Prismaのデータ構造からドメイン型へ変換
  private mapToDomain(raw: { id: number; productName: string; price: number; quantity: number }): CartItem {
    return {
      id: raw.id,
      productName: raw.productName,
      price: raw.price,
      quantity: raw.quantity,
    };
  }

  // 親カートの ID を取得または作成するヘルパーメソッド[cite: 1]
  private async getOrCreateDefaultCartId(): Promise<number> {
    let cart = await this.prisma.cart.findFirst();
    if (!cart) {
      cart = await this.prisma.cart.create({ data: {} });
    }
    return cart.id;
  }

  async findAll(): Promise<CartItem[]> {
    const items = await this.prisma.cartItem.findMany({
      orderBy: { id: 'asc' },
    });
    return items.map(this.mapToDomain);
  }

  async findById(id: number): Promise<CartItem | undefined> {
    const item = await this.prisma.cartItem.findUnique({
      where: { id },
    });
    return item ? this.mapToDomain(item) : undefined;
  }

  async create(data: Omit<CartItem, 'id'>): Promise<CartItem> {
    const cartId = await this.getOrCreateDefaultCartId();
    const created = await this.prisma.cartItem.create({
      data: {
        productName: data.productName,
        price: data.price,
        quantity: data.quantity,
        cartId: cartId,
      },
    });
    return this.mapToDomain(created);
  }

  async updateQuantity(id: number, quantity: number): Promise<CartItem | undefined> {
    try {
      const updated = await this.prisma.cartItem.update({
        where: { id },
        data: { quantity },
      });
      return this.mapToDomain(updated);
    } catch {
      return undefined;
    }
  }

  async delete(id: number): Promise<boolean> {
    try {
      await this.prisma.cartItem.delete({
        where: { id },
      });
      return true;
    } catch {
      return false;
    }
  }

  // TransactionClient 型を用いたアトミック全削除
  async clearCart(): Promise<void> {
    const cartId = await this.getOrCreateDefaultCartId();
    await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      await tx.cartItem.deleteMany({
        where: { cartId },
      });
    });
  }
}

③ サービス層の実装(CartService)

// src/services/cart.service.ts
import { ICartRepository } from '../repositories/interfaces/cart.repository.interface.js';
import { CartItem } from '../types/cart.type.js';

export class CartService {
  constructor(private readonly cartRepository: ICartRepository) {}

  getCartSummary = async () => {
    const items = await this.cartRepository.findAll();
    const totalCount = items.reduce((sum, item) => sum + item.quantity, 0);
    const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    return { items, totalCount, totalPrice };
  };

  addItem = async (data: Omit<CartItem, 'id'>) => {
    return await this.cartRepository.create(data);
  };

  updateItemQuantity = async (id: number, quantity: number) => {
    return await this.cartRepository.updateQuantity(id, quantity);
  };

  removeItem = async (id: number) => {
    return await this.cartRepository.delete(id);
  };

  clearAll = async (): Promise<void> => {
    await this.cartRepository.clearCart();
  };
}

④ コントローラー層の実装(CartController)

// src/controllers/cart.controller.ts
import { Request, Response } from 'express';
import { CartService } from '../services/cart.service.js';

export class CartController {
  constructor(private readonly cartService: CartService) {}

  getSummary = async (req: Request, res: Response): Promise<void> => {
    const summary = await this.cartService.getCartSummary();
    res.json(summary);
  };

  createItem = async (req: Request, res: Response): Promise<void> => {
    const { productName, price, quantity } = req.body;
    const item = await this.cartService.addItem({ productName, price, quantity });
    res.status(201).json(item);
  };

  updateQuantity = async (req: Request, res: Response): Promise<void> => {
    const idParam = req.params.id;
    if (typeof idParam !== 'string') {
      res.status(400).json({ message: '不正なID形式です' });
      return;
    }
    const id = parseInt(idParam, 10);
    const { quantity } = req.body;

    const updated = await this.cartService.updateItemQuantity(id, quantity);
    if (!updated) {
      res.status(404).json({ message: '該当のアイテムが見つかりません' });
      return;
    }
    res.json(updated);
  };

  deleteItem = async (req: Request, res: Response): Promise<void> => {
    const idParam = req.params.id;
    if (typeof idParam !== 'string') {
      res.status(400).json({ message: '不正なID形式です' });
      return;
    }
    const id = parseInt(idParam, 10);

    const success = await this.cartService.removeItem(id);
    if (!success) {
      res.status(404).json({ message: '該当のアイテムが見つかりません' });
      return;
    }
    res.status(204).send();
  };

  clearCart = async (req: Request, res: Response): Promise<void> => {
    await this.cartService.clearAll();
    res.status(204).send();
  };
}

⑤ ルーティングと依存性の注入(src/routes/cart.route.ts)

src/config/database.ts で初期化した共有の prisma インスタンスを読み込み、各層へ注入(DI)します。

// src/routes/cart.route.ts
import { Router } from 'express';
import { prisma } from '../config/database.js'; //  共有インスタンスをインポート
import { PrismaCartRepository } from '../repositories/prisma/prisma-cart.repository.js';
import { CartService } from '../services/cart.service.js';
import { CartController } from '../controllers/cart.controller.js';
import { validateRequest } from '../middlewares/validate.middleware.js'; 
import { createCartItemSchema, updateCartItemSchema } from '../schemas/cart.schema.js';

//  依存関係の組み立て(DI)
const cartRepository = new PrismaCartRepository(prisma);
const cartService = new CartService(cartRepository);
const cartController = new CartController(cartService);

const router = Router();

router.get('/items', cartController.getSummary);
router.post('/items', validateRequest({ body: createCartItemSchema }), cartController.createItem);
router.patch('/items/:id', validateRequest({ body: updateCartItemSchema }), cartController.updateQuantity);
router.delete('/items/:id', cartController.deleteItem);
router.delete('/items', cartController.clearCart);

export default router;

⑥ エントリーポイント(src/app.ts)

// src/app.ts
import express, { Application } from 'express';
import cartRouter from './routes/cart.route.js';

const app: Application = express();
app.use(express.json());

// ルーティングのバインド
app.use('/api/cart', cartRouter);

export default app;

7. API の動作確認(curl コマンドでのテスト)

サーバーを起動した状態で、別ターミナルから以下の curl コマンドを実行して動作確認を行います。

① カート情報の取得(GET)

curl -X GET http://localhost:3000/api/cart/items

② カートへの商品追加(POST)

curl -X POST http://localhost:3000/api/cart/items \
  -H "Content-Type: application/json" \
  -d '{"productName": "メカニカルキーボード", "price": 14800, "quantity": 1}'

③ 数量の更新(PATCH)

curl -X PATCH http://localhost:3000/api/cart/items/1 \
  -H "Content-Type: application/json" \
  -d '{"quantity": 3}'

④ 特定商品の削除(DELETE)

curl -X DELETE http://localhost:3000/api/cart/items/1

⑤ カートの全削除(DELETE / トランザクション動作確認)

curl -X DELETE http://localhost:3000/api/cart/items

本日のまとめ

  • Prisma Client の自動生成と型マッピング: schema.prisma から型を自動生成しつつ、Repository 層でドメイン型へ変換(マッピング)して上位層との疎結合を維持します。
  • src/config/database.ts と True DI: 接続初期化を一箇所に集約し、リポジトリへ外部から注入することでテスト容易性を向上させます。
  • インタラクティブ・トランザクション: prisma.$transaction を活用し、複数クエリをアトミック(不可分)に実行する安全なデータ処理を実装します。
  • ESM 環境でのインポート規則: TypeScript 5 / Prisma 7 環境では、インポートパスの末尾に .js 拡張子を付与します。

次回:第15回:データベースエラーのハンドリングと Prisma シード(初期データ投入)の仕組み へ続く

タイトルとURLをコピーしました