Repository — GoF の外にある実務で頻出するパターン
GoF 外である点を明示し、型による表現に焦点を絞ります。
このパターンが解こうとした問題
Fowler はこう定義しています。
Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.2
要点は collection-like interface です。呼び出し側からは、データベースではなくメモリ上のコレクションを触っているように見えることを目指します。
A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction.2
ここから、Repository の良し悪しを測る基準が出てきます。呼び出し側のコードに永続化の都合が漏れていないかです。SQL やテーブル名がそのまま現れていないか、が中心になります。
引用の後半は、問い合わせを宣言的な検索条件のオブジェクトとして組み立てて渡す形を指しています。**本章はこの形を採りません。**理由と、その判断が覆る条件は 本連載の判断 に書きます。
クラスで素直に書く
汎用の CRUD を持つ形が、まず思い付く書き方です。
type Bookmark = {
id: string;
url: string;
title: string;
tags: string[];
createdAt: Date;
};
interface CrudRepository<T> {
findAll(): Promise<T[]>;
findById(id: string): Promise<T | undefined>;
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
}
type BookmarkRepository = CrudRepository<Bookmark>;
再利用できそうに見えますが、呼び出し側から見ると穴があります。
findById(id: string)はどんな文字列でも受け取ります。利用者の ID を誤って渡してもコンパイルは通ります。save(entity: Bookmark)はidとcreatedAtを含む完全な形を要求します。まだ保存していないブックマークには、その 2 つがありません。 呼び出し側が仮の値を埋めるか、asで誤魔化すことになります。- 「タグで探す」のようなそのドメインに固有の問い合わせを表す場所がありません。
findAll()してから絞り込むか、検索条件オブジェクトを別に足すことになります。
はじめの 2 つは「コレクションを触っているように見える」から遠ざかる漏れです。3 つ目は漏れというより、汎用の型では表せる範囲が狭いという話になります。
言語機能で置き換える
TypeScript の型で、この 3 つを塞げます。
まず ID です。string のままだと取り違えを検出できないので、その型にはその型しか代入できない印を付けます。
declare const brand: unique symbol;
// string だが、BookmarkId としてしか使えない型
type BookmarkId = string & {readonly [brand]: 'BookmarkId'};
type UserId = string & {readonly [brand]: 'UserId'};
function toBookmarkId(raw: string): BookmarkId {
return raw as BookmarkId; // 変換はこの関数の中だけで行う
}
declare function findById(id: BookmarkId): Promise<unknown>;
const ok = findById(toBookmarkId('bm_1'));
BookmarkId と UserId はどちらも実体は文字列ですが、互いに代入できません。取り違えがコンパイル時に止まります。
// error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'BookmarkId'.
declare const userId: UserId;
findById(userId);
次に、保存前と保存後の形の違いです。Omit で表せます。
Constructs a type by picking all properties from
Typeand then removingKeys(string literal or union of string literals).3
次の例が brand を宣言し直しているのは、フェンス単体で型検査を通すためです。**実際のコードでは brand の宣言を 1 か所に置いて共有してください。**別々に宣言すると unique symbol は別の型になり、同じ名前の BookmarkId どうしが代入できなくなります。
declare const brand: unique symbol;
type BookmarkId = string & {readonly [brand]: 'BookmarkId'};
type Bookmark = {
id: BookmarkId;
url: string;
title: string;
tags: string[];
createdAt: Date;
};
// id と createdAt は保管側が決める。呼び出し側は書く必要がない
type NewBookmark = Omit<Bookmark, 'id' | 'createdAt'>;
interface BookmarkRepository {
findById(id: BookmarkId): Promise<Bookmark | undefined>;
findByTag(tag: string): Promise<Bookmark[]>;
add(draft: NewBookmark): Promise<Bookmark>;
remove(id: BookmarkId): Promise<void>;
}
async function bookmarkArticle(repository: BookmarkRepository): Promise<Bookmark> {
// id も createdAt も書かない。ここに書き足すと型エラーになる
return repository.add({
url: 'https://example.com/article',
title: 'デザインパターン',
tags: ['typescript'],
});
}
3 つの穴がすべて塞がりました。
- ID は取り違えられません
addは保存前の形を受け取り、保存後の形を返しますfindByTagというそのドメインの言葉で問い合わせが表現されています
ただし add が余剰のプロパティを弾くのは、引数に直接オブジェクトリテラルを書いたときに限られます。いったん変数に入れてから渡すと、id や createdAt を持ったオブジェクトでも通ります。Omit が表しているのは「書く必要がない」ことで、「書けない」ことではありません。
CrudRepository<T> という汎用の型は消えました。集約ごとに名前付きの interface にすると、ID の型・保存前後の形・問い合わせの語彙が、それぞれ型の上に現れます。
見つからない場合を Bookmark | undefined で表しているのも意図的です。「無い」を例外ではなく戻り値の型に置けば、呼び出し側は絞り込みを強制されます。
本連載の判断
判定: 条件付き。Repository を置くかどうかと、置くならどう型を付けるかは別の問題です。
置くかどうかの判断は次のとおりです。
- ドメインの言葉で問い合わせを表したい、または永続化の実装を差し替えたいなら置く — テストでメモリ実装に差し替える用途を含みます。
- ORM が既にコレクションのような入り口を提供していて、それ以上抽象化する動機がないなら置かない — 委譲するだけの層を挟むと、読む場所が 1 つ増えるだけになります。
置くと決めたなら、型については判定が片側に寄ります。汎用の Repository<T> ではなく、集約ごとの名前付き interface にします。 汎用にすると上で見た 3 つの穴がそのまま残り、Repository を置いた意味が薄れます。
出典が挙げる宣言的な検索条件オブジェクトを採らないのも同じ理由です。検索条件を汎用のオブジェクトにすると、どの組み合わせが有効かが型に現れません。findByTag(tag: string) のように名前を付ければ、扱える問い合わせが型で列挙されます。この点は出典と異なる立場です。
もう 1 つ、本章が「保存前と保存後で同じ型を使わされる」ことを漏れに数えているのも本ガイドの立場です。上に引用したカタログページには、この区別についての記述がありません。
この判定が覆る条件
印を付けた ID には変換の境界が生じます。データベースから読んだ文字列を BookmarkId にするには、どこかで as を書くことになります。上の toBookmarkId のように変換関数を 1 つ置いて、そこ以外では書かないという規律が要ります。この規律が守れない規模のチームでは、印を付けても取り違えは防げません。
もう 1 つ、集約ごとに interface を書くと、集約の数だけ interface が増えます。集約が 20 個あって、どれも本当に同じ CRUD しか持たないなら、汎用の型のほうが総量は少なくなります。ただしその状況は、Repository ではなくデータアクセス層が欲しかったという兆候でもあります。
名前付きメソッドを選ぶ判断も、絞り込みの条件が組み合わせで増えるときに崩れます。並び順・期間・複数タグ・ページングを任意に組み合わせる検索では、メソッド名の数が組み合わせの数だけ要ります。そこまで来ると、出典が挙げる検索条件オブジェクトのほうが素直です。
既存記事との関係
本章と重なる記事が 2 つあります。扱う面が異なるので、目的に応じて使い分けてください。
- リポジトリパターン — PHP と Laravel で、DDD の文脈から扱っています。PoEAA が出典であること、インターフェースをドメイン層に置く理由、N+1 問題と Eager Loading まで含みます。パターンとしての説明はそちらが正本です。
- 継承とインターフェース — TypeScript で汎用の
Repository<T>とメモリ実装を書く例があります。implementsの実践例という位置づけです。本章は、その汎用形を出発点に「型で表せることを増やすとどうなるか」を扱っているので、続きとして読めます。 - 命名規則 ② 関数とクラス は
~Repositoryを「データアクセス層」を表すサフィックスとして挙げ、findById/saveを持つクラスを例にしています。本章の区分では、その形は汎用の CRUD にあたります。名前が同じでも指しているものが違います。
本章で使った Omit は ユーティリティ型 が扱っています。印を付けた型 (ブランド型) は Adapter の「この判定が覆る条件」でも動機の側から触れていますが、書き方を示した記事は本サイトにまだありませんので、本章の例を参照してください。