PrismaにIdentity Mapがない理由

Martin Fowlerが『PoEAA』で紹介したIdentity Mapパターンは、同一トランザクション内で同じレコードを複数回読み込んだとき、同一のオブジェクトを返すことを保証します。しかしPrismaにはこの仕組みがありません。これは問題なのでしょうか。

Identity Mapとは何か

Fowlerの定義を確認しましょう。

An Identity Map keeps a record of all objects that have been read from the database in a single business transaction. Whenever you want an object, you check the Identity Map first to see if you already have it.

Identity Mapは、1回のビジネストランザクション内でデータベースから読み込んだすべてのオブジェクトを記録します。オブジェクトが必要になったとき、まずIdentity Mapを確認し、すでに持っているかどうかをチェックします。

このパターンには2つの目的があります。

  1. パフォーマンス向上 - 同じレコードを複数回フェッチしない
  2. 整合性の保証 - 同じレコードへの参照が同一オブジェクトを指す

ActiveRecordはIdentity Mapを持っているか

Ruby on RailsのActiveRecordは、実は完全なIdentity Mapを持っていません。ただし、部分的に似た挙動があります。

# Railsでの挙動
user1 = User.find(1)
user2 = User.find(1)

user1.object_id == user2.object_id  # => false(別オブジェクト)

# しかし、associationのキャッシュはある
post = Post.includes(:user).find(1)
post.user.object_id == post.user.object_id  # => true(同一オブジェクト)

Railsはリクエストスコープでの完全なIdentity Mapを実装していません。一度は実装されましたが、複雑さとの兼ね合いで削除された経緯があります。

Prismaの挙動

Prismaは明確にIdentity Mapを持っていません。

const user1 = await prisma.user.findUnique({ where: { id: 1 } });
const user2 = await prisma.user.findUnique({ where: { id: 1 } });

user1 === user2;  // => false(別オブジェクト)

同じIDで2回クエリを発行すると、2回データベースにアクセスし、別々のオブジェクトが返されます。

これは問題なのか

Identity Mapがないことで生じる問題を考えてみましょう。

問題1. パフォーマンス

同じレコードを何度もフェッチするとパフォーマンスが劣化します。

// N+1的な問題
for (const order of orders) {
  const user = await prisma.user.findUnique({
    where: { id: order.userId }
  });
  // 同じuserIdが複数回出現すると、同じクエリが複数回走る
}

ただし、これはIdentity Mapがなくても解決可能です。

// 事前にまとめてフェッチする
const userIds = [...new Set(orders.map(o => o.userId))];
const users = await prisma.user.findMany({
  where: { id: { in: userIds } }
});
const userMap = new Map(users.map(u => [u.id, u]));

for (const order of orders) {
  const user = userMap.get(order.userId);
}

問題2. オブジェクトの同一性

より深刻なのは、同じレコードが別オブジェクトとして存在することによる不整合です。

async function processOrder(orderId: string) {
  const order = await prisma.order.findUnique({ where: { id: orderId } });

  // 別の場所で同じorderを取得
  const sameOrder = await prisma.order.findUnique({ where: { id: orderId } });

  order.status = 'PROCESSING';
  // sameOrderのstatusは変わらない

  await prisma.order.update({
    where: { id: orderId },
    data: { status: order.status }
  });

  // この時点でsameOrderは古いデータを持っている
  console.log(sameOrder.status);  // まだ古い値
}

Prismaの設計思想

PrismaがIdentity Mapを実装しない理由は、設計思想の違いにあると考えられます。

ステートレスなクエリビルダー

Prismaは「ステートレスなクエリビルダー」として設計されています。各クエリは独立しており、前のクエリの結果を覚えていません。これにより、動作が予測しやすくなります。

イミュータブルなデータ

Prismaが返すオブジェクトは、事実上イミュータブルとして扱うことが想定されています。取得したデータを変更するのではなく、updatecreateで新しい状態をデータベースに書き込むスタイルです。

// Prismaの想定する使い方
const user = await prisma.user.findUnique({ where: { id: 1 } });

// オブジェクトを変更するのではなく、updateで新しい状態を書き込む
await prisma.user.update({
  where: { id: 1 },
  data: { name: 'New Name' }
});

Identity Mapが必要なケースへの対処

それでもIdentity Mapが欲しい場面はあります。その場合、自前で実装するか、設計で回避するかの選択になります。

自前でIdentity Mapを実装する

class IdentityMap<T extends { id: string }> {
  private map = new Map<string, T>();

  get(id: string): T | undefined {
    return this.map.get(id);
  }

  set(entity: T): void {
    this.map.set(entity.id, entity);
  }

  getOrFetch(id: string, fetcher: () => Promise<T | null>): Promise<T | null> {
    const cached = this.get(id);
    if (cached) return Promise.resolve(cached);

    return fetcher().then(entity => {
      if (entity) this.set(entity);
      return entity;
    });
  }
}

// 使用例
const orderMap = new IdentityMap<Order>();

const order1 = await orderMap.getOrFetch(orderId, () =>
  orderRepository.findById(orderId)
);
const order2 = await orderMap.getOrFetch(orderId, () =>
  orderRepository.findById(orderId)
);

order1 === order2;  // => true

設計で回避する

多くの場合、Identity Mapがなくても問題にならないように設計できます。

  1. 1つのUseCaseで同じエンティティを複数回取得しない - 最初に取得して使い回す
  2. 取得したオブジェクトを変更しない - 変更は常にDBへの書き込みで行う
  3. 短いトランザクションを保つ - 長時間オブジェクトを保持しない
// 良い例:一度だけ取得して使い回す
async function approveOrder(orderId: string, approverId: string) {
  const order = await orderRepository.findById(orderId);
  const approver = await userRepository.findById(approverId);

  // orderとapproverを使って処理
  const result = order.approve(approver);

  await orderRepository.save(order);
  return result;
}

まとめ

FowlerがIdentity Mapで解決しようとした問題は今でも存在します。しかし、現代のORMはそれぞれ異なるアプローチを取っています。

Prismaの場合、Identity Mapがないことは設計上の欠陥ではなく、ステートレスでシンプルなモデルを選択した結果です。この制約を理解した上で、必要なら自前で実装するか、設計で回避するかを選べばよいでしょう。

重要なのは、使っているツールがどのような設計思想を持っているかを理解し、その制約の中で適切な設計を選ぶことです。

参考文献