Repositoryパターンは現代でも有効か

Eric Evansは『Domain-Driven Design』で、Repositoryについて「コレクションのように振る舞うべき」と述べています。しかし、PrismaやActiveRecordを使う現代の開発では、この原則をどこまで守るべきでしょうか。

Evansが語ったRepositoryの本質

DDDの原典でEvansはRepositoryについて次のように述べています。

A REPOSITORY represents all objects of a certain type as a conceptual set (usually emulated). It acts like a collection, except with more elaborate querying capability.

Repositoryは特定の型のオブジェクト全体を概念的な集合として表現し、コレクションのように振る舞いつつ、より精緻なクエリ機能を持つものです。

さらに重要な記述があります。

Provide repositories only for AGGREGATE roots that actually need direct access. Keep the client focused on the model, delegating all object storage and access to the REPOSITORIES.

Repositoryは集約ルートに対してのみ提供し、クライアントがモデルに集中できるようにする、と述べています。

「コレクションのように」とは具体的に何を意味するか

Evansの意図を汲み取ると、以下のようなインターフェースが想定されています。

interface OrderRepository {
  add(order: Order): void;
  remove(order: Order): void;
  findById(id: OrderId): Order | null;
  findByCustomer(customerId: CustomerId): Order[];
}

これはList<Order>Set<Order>のように振る舞います。addでオブジェクトを追加し、removeで削除し、条件で検索できる。クライアントコードはこのRepositoryがデータベースと通信していることを意識する必要がありません。

Prisma Clientが「すでに提供しているもの」との重複

ここで疑問が生じます。Prisma Clientは以下のようなインターフェースをすでに提供しています。

// Prismaが自動生成するClient
prisma.order.create({ data: orderData });
prisma.order.delete({ where: { id } });
prisma.order.findUnique({ where: { id } });
prisma.order.findMany({ where: { customerId } });

これも「コレクションのように振る舞う」インターフェースです。では、この上にさらにRepositoryを被せる意味はあるのでしょうか。

Repositoryを作る3つの理由

私の経験では、Prisma Clientの上にRepositoryを作る理由は3つあります。

1. ドメインオブジェクトへの変換

Prismaが返すのはPrismaが定義した型です。これをドメインオブジェクトに変換する責務をどこかに持たせる必要があります。

class OrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id: id.value },
      include: { lineItems: true }
    });

    if (!record) return null;

    return Order.reconstitute({
      id: new OrderId(record.id),
      customerId: new CustomerId(record.customerId),
      lineItems: record.lineItems.map(li => LineItem.reconstitute(li)),
      status: OrderStatus.fromString(record.status),
    });
  }
}

この変換ロジックがUseCase層に散らばると、同じ変換コードが複数箇所に重複します。Repositoryに凝集させることで、ドメインモデルの構築ロジックを一箇所に集約できます。

2. テスト時の差し替え

Repositoryをインターフェースとして定義しておくと、テスト時にインメモリ実装に差し替えられます。

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// 本番用
class PrismaOrderRepository implements OrderRepository { ... }

// テスト用
class InMemoryOrderRepository implements OrderRepository {
  private orders: Map<string, Order> = new Map();

  async findById(id: OrderId): Promise<Order | null> {
    return this.orders.get(id.value) ?? null;
  }

  async save(order: Order): Promise<void> {
    this.orders.set(order.id.value, order);
  }
}

ただし、これには注意が必要です。LocalStackやTestcontainersを使えば、本物のデータベースでテストすることも可能です。インメモリ実装でテストを速くする代わりに、本番との差異を受け入れるトレードオフがあります。

3. クエリの抽象化

特定のクエリパターンをドメインの言葉で表現できます。

class OrderRepository {
  async findPendingOrdersOlderThan(days: number): Promise<Order[]> {
    const cutoffDate = subDays(new Date(), days);
    const records = await this.prisma.order.findMany({
      where: {
        status: 'PENDING',
        createdAt: { lt: cutoffDate }
      }
    });
    return records.map(this.toDomainObject);
  }
}

findPendingOrdersOlderThanというメソッド名は、ビジネスの意図を明確に表現しています。

Repositoryを作らなくてよいケース

一方で、Repositoryを作らなくてよいケースもあります。

シンプルなCRUD操作のみの場合

アプリケーションがシンプルなCRUD操作のみで、複雑なドメインロジックがない場合、Prisma Clientを直接UseCase層で使っても問題ありません。

// Repositoryなしで直接Prismaを使う
async function createUser(input: CreateUserInput): Promise<User> {
  return prisma.user.create({
    data: {
      email: input.email,
      name: input.name,
    }
  });
}

この場合、Repositoryは余計な抽象化レイヤーになります。

ドメインオブジェクトとDBスキーマがほぼ一致する場合

Prismaの生成する型がそのままドメインの表現として適切なら、変換の必要がありません。変換が不要なら、Repositoryの主要な責務の一つがなくなります。

findメソッドが20個に増えたとき

Repositoryに様々な検索条件のメソッドが増えていくことがあります。

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;
  findByStatus(status: OrderStatus): Promise<Order[]>;
  findByDateRange(start: Date, end: Date): Promise<Order[]>;
  findByCustomerIdAndStatus(customerId: CustomerId, status: OrderStatus): Promise<Order[]>;
  // ... さらに続く
}

これは設計が間違っている兆候かもしれません。いくつかの対処法があります。

Specification パターンの導入

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findAll(spec: OrderSpecification): Promise<Order[]>;
}

// 使用側
const pendingOrders = await orderRepository.findAll(
  OrderSpecification.byStatus(OrderStatus.PENDING)
    .and(OrderSpecification.byCustomer(customerId))
);

Query Serviceの分離

複雑な検索はRepositoryではなく、専用のQuery Serviceに分離する方法もあります。

// 書き込み用
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// 読み取り用(CQRSの読み取り側)
interface OrderQueryService {
  search(criteria: OrderSearchCriteria): Promise<OrderSummaryDto[]>;
}

結論

「Repositoryはコレクションのように振る舞う」というEvansの原則は、今でも有効な指針です。ただし、現代のORM(特にPrisma)はすでにコレクションライクなインターフェースを提供しているため、Repositoryを導入すべきかどうかは状況によります。

Repositoryを導入する判断基準としては、以下が挙げられます。

  • ドメインオブジェクトへの変換が必要か
  • テスト時にデータアクセス層を差し替えたいか
  • クエリをドメインの言葉で表現したいか

これらの答えがすべて「いいえ」なら、Prisma Clientを直接使っても問題ないでしょう。Repositoryはパターンのための実装ではなく、具体的な課題を解決するための手段です。

参考文献

  • Eric Evans『Domain-Driven Design』(2003)
  • Martin Fowler『Patterns of Enterprise Application Architecture』(2002)
  • Prisma ORM Documentation