リポジトリパターン

データアクセスロジックをカプセル化し、ビジネスロジックからデータストアの詳細を隠蔽するパターン

設計パターンDDD

リポジトリパターンとは

リポジトリパターンは、データアクセスロジックをカプセル化し、ビジネスロジックからデータストアの詳細を隠蔽するパターンである。カタログとしての初出は Martin Fowler の『Patterns of Enterprise Application Architecture』(2002) 第 13 章で、ドメイン層とデータマッピング層を仲介し、ドメインオブジェクトをコレクションのように扱えるインターフェースを提供するもの、と定義されている。翌年の Eric Evans『Domain-Driven Design』(2003) が、永続化された集約を取り出す口としてこれを採り入れ、DDD の構成要素として広く知られるようになった。GoF の『デザインパターン』(1994) にある 23 パターンの 1 つと誤解されることがあるが、系譜は別で、GoF には収録されていない。

ドメイン層から見えるのは findByIdsave といったコレクション風の操作だけで、その裏が RDB か DynamoDB か外部 API かは問わない。この裏を問わない性質が、テスト時の差し替えとデータストアの移行の両方を成立させる。

なぜ必要か

ビジネスロジックの中に DynamoDB の GetItemCommandmarshall といったデータストア固有のコードが混在すると、DB の変更やテストが困難になる。リポジトリパターンでデータアクセスを抽象化すれば、ビジネスロジックは findById のようなシンプルなインターフェースだけに依存し、DB の詳細を知らずに済む。

// ❌ ビジネスロジックに DynamoDB の詳細が混在
async function getOrder(id: string) {
  const result = await db.send(new GetItemCommand({
    TableName: 'orders',
    Key: { PK: { S: `ORDER#${id}` }, SK: { S: 'METADATA' } },
  }));
  return unmarshall(result.Item!);
}

// ✅ リポジトリで隠蔽
async function getOrder(id: string) {
  return orderRepository.findById(id);
}

集約単位で 1 つ持つ

リポジトリは集約ルートごとに 1 つ用意し、集約の内部にあるエンティティ用のリポジトリは作らない。注文明細を単独で読み書きする OrderItemRepository を置くと、注文全体で守るべき不変条件 (明細の合計が上限を超えないなど) を外から迂回できてしまう。

取得も保存も集約全体を単位にする。findById は明細まで揃った注文を返し、save は注文全体を書き戻す。この単位が決まっていれば、DynamoDB なら 1 集約を 1 パーティションに寄せて 1 回の Query で読み切る、RDB なら明細を結合してまとめて取る、といったストア側の最適化をリポジトリの内側だけで選べる。

TypeScript での実装

インターフェースをドメイン層に、実装をインフラ層に置く。ドメイン層は自分で宣言したインターフェースだけを見て、実装がそれを満たす形になる。結果としてドメイン層のファイルには AWS SDK の import が 1 行も現れない。この状態を保てているかは、ドメイン層のディレクトリを SDK のパッケージ名で検索すれば機械的に確認できる。

interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByUserId(userId: string): Promise<Order[]>;
}

class DynamoDBOrderRepository implements OrderRepository {
  constructor(private db: DynamoDBClient, private tableName: string) {}

  async findById(id: string): Promise<Order | null> {
    const result = await this.db.send(new GetItemCommand({
      TableName: this.tableName,
      Key: { PK: { S: `ORDER#${id}` }, SK: { S: 'META' } },
    }));
    return result.Item ? toOrder(unmarshall(result.Item)) : null;
  }

  async save(order: Order): Promise<void> {
    await this.db.send(new PutItemCommand({
      TableName: this.tableName,
      Item: marshall(toItem(order)),
    }));
  }

  async findByUserId(userId: string): Promise<Order[]> {
    const result = await this.db.send(new QueryCommand({
      TableName: this.tableName,
      IndexName: 'GSI1',
      KeyConditionExpression: 'GSI1PK = :pk',
      ExpressionAttributeValues: { ':pk': { S: `USER#${userId}` } },
    }));
    return (result.Items ?? []).map(i => toOrder(unmarshall(i)));
  }
}

テスト用のインメモリ実装

同じインターフェースを Map で実装すれば、DynamoDB Local やコンテナを立てずに単体テストが書ける。ただしインメモリ実装は本番の挙動を全部は再現しない。条件付き書き込みの衝突、GSI の結果整合性による読み取り遅れ、1 項目 400KB の上限といった制約はすり抜けるため、これらに関わるロジックは実際の DB を使ったテストで別に確かめる。インメモリ実装だけが緑になった状態をテスト済みと見なさないこと。

class InMemoryOrderRepository implements OrderRepository {
  private store = new Map<string, Order>();
  async findById(id: string) { return this.store.get(id) ?? null; }
  async save(order: Order) { this.store.set(order.id, order); }
  async findByUserId(userId: string) {
    return [...this.store.values()].filter(o => o.userId === userId);
  }
}

ORM とリポジトリはレイヤーが違う

両者は競合しない。ORM はテーブルの行とオブジェクトを対応づける汎用の機構で、関心は永続化の技術側にある。リポジトリはドメインの語彙で書かれた境界で、関心は業務側にある。ORM を採用している構成では、リポジトリはその上に薄く載る層になる。

境界を引かずに ORM のモデルをそのままビジネスロジックへ渡すと、永続化の事情が業務コードへ漏れる。関連を触った瞬間に追加のクエリが飛ぶ遅延ロード、セッションやトランザクションの生存期間に縛られたオブジェクトの寿命などが典型で、いずれもドメインの都合ではない。リポジトリの戻り値をドメインオブジェクトに固定しておけば、漏れる範囲がリポジトリの実装内に閉じる (ORM)。

ORM を使わない場合は、リポジトリが唯一のマッピング層になる。DynamoDB のように SDK を直接呼ぶ構成では、属性名とドメインの項目名の対応づけがリポジトリの中に集まる。

DynamoDB の単一テーブル設計との噛み合わせ

単一テーブル設計では、ORDER#<id> のようなキーの命名規則と、どのアクセスパターンをどの GSI に載せるかが設計の中心になる。この規則はリポジトリの中だけに置く。呼び出し側が PK の組み立て方を知っていると、キー設計を変えるたびに呼び出し側まで書き換えが波及し、単一テーブル設計の変更しやすさが失われる。

漏れやすいのはページネーションである。LastEvaluatedKey をそのまま返すと、キー構造がレスポンスに露出する。中身を解釈させない文字列 (カーソル) に包んで受け渡す形にすれば、キー設計の変更が呼び出し側に見えない。

メソッドを増やす前に、そのクエリを満たすインデックスがあるかを確認する。既定の上限は GSI がテーブルあたり 20 個、LSI が 5 個で (2026 年 8 月時点)、findByXxx を無計画に増やすとテーブル定義側で先に詰まる。リポジトリのインターフェースは、実際に必要なアクセスパターンの数だけに保つ。

メリットと落とし穴

テスト時にインメモリ実装へ差し替えるだけで DB なしにテストでき、DynamoDB から Aurora への移行もリポジトリの実装を入れ替えるだけで済む。ビジネスロジックが DB の詳細を知らない状態を保てるため、データアクセスの責務が 1 箇所に集まる。

落とし穴は 2 つある。1 つは万能メソッドで、findAll(condition) のように検索条件をそのまま通す口を作ると、呼び出し側が結局データストアのクエリを書くことになり、境界を設けた意味が消える。メソッド名は「未出荷の注文を利用者ごとに取る」のような業務上の問いで切る。もう 1 つは抽象の取りすぎで、あらゆるストアに対応する共通インターフェースを目指すと、トランザクションの粒度や条件付き更新といったストア固有の強みが使えなくなる。差し替える相手を具体的に想定できないなら、インターフェースは今使っているストアに素直に合わせた方が保守しやすい。

この記事は役に立ちましたか?

関連用語

関連する記事