Repositoryは何を返す?

基本はAggregate Rootを返す

取得したデータを使って業務上の変更を行う場合、Repositoryは基本的にAggregate Rootを返します。Aggregate RootはEntityなので、結果としてEntityを返すことになります。

フットサルNOWの募集用Repositoryは、「募集」のAggregate RootであるRecruitmentEntityを返します。募集が存在しない場合はnullを返します。

参加申請を受け付けるには、「申込期限を過ぎていないか」「同じ人が申し込んでいないか」「定員に達していないか」を確認する必要があります。これらのルールと現在の状態を持つ「募集」をEntityとして復元することで、requestParticipation()を呼び、ルールを守った状態だけを保存できます。

export interface IRecruitmentRepository {
  findById(
    recruitmentId: RecruitmentIdValueObject,
  ): Promise<RecruitmentEntity | null>;

  save(recruitment: RecruitmentEntity): Promise<void>;
}

DBの行やAggregate内部の値だけを返すと、Application Serviceが業務ルールを確認して状態を変更することになります。ルールをAggregate Rootに集めるため、更新ではAggregate Rootを取得します。

Entity以外を返す場合

取得したデータを一覧表示、検索、集計に使うだけで、業務上の変更を行わない場合は、Entityでなくても構いません。業務ルールを実行する振る舞いや、Aggregate全体の状態が必要ないためです。

この場合は、利用側に必要な項目だけを持つRead Modelを返します。読み取り処理は、更新用のRepositoryとは分けてQueryとして定義します。

例えば、近くで参加できる募集の一覧には、募集のすべての状態や振る舞いは必要ありません。「募集ID」「タイトル」「場所」「開催日時」「残り枠」だけを返せます。

// 募集一覧へ表示する値
export type RecruitmentListItem = {
  readonly recruitmentId: string;
  readonly title: string;
  readonly locationName: string;
  readonly startsAt: Date;
  readonly remainingCapacity: number;
};

// 募集一覧を取得するためのApplication層の窓口
export interface IRecruitmentQuery {
  findNearby(input: {
    latitude: number;
    longitude: number;
  }): Promise<readonly RecruitmentListItem[]>;
}

RecruitmentListItemはEntityではありません。募集一覧へ返す値を表すRead Modelです。

読み取りにもEntityを使ってはいけないわけではありません。単純な取得でRepositoryから返したEntityをそのまま使えるなら、無理にQueryとRead Modelを増やす必要はありません。必要な項目や検索条件がEntityの形と合わなくなったときに分けます。

DAOと型をどこに置くか

Application Serviceが使うRead ModelとQuery interfaceは、Application層に置きます。利用側が必要とする返却値だからです。

DBから読み取った行やORMの型は、DAOやQueryの実装と同じInfrastructure層に置きます。DBの型をそのままApplication層へ返さず、Read Modelへ変換します。

// Infrastructure層だけで使うDBの行
type RecruitmentListRow = {
  recruitment_id: string;
  title: string;
  location_name: string;
  starts_at: Date;
  remaining_capacity: number;
};

export class RecruitmentQuery implements IRecruitmentQuery {
  async findNearby(input: {
    latitude: number;
    longitude: number;
  }): Promise<readonly RecruitmentListItem[]> {
    const rows: RecruitmentListRow[] = await this.dao.findNearby(input);

    // DBの行を、Application層へ返すRead Modelに変換する
    return rows.map((row) => ({
      recruitmentId: row.recruitment_id,
      title: row.title,
      locationName: row.location_name,
      startsAt: row.starts_at,
      remainingCapacity: row.remaining_capacity,
    }));
  }
}

DAOという名前を使う場合も考え方は同じです。DAOはDBへのアクセスを担当し、DBの行を表す型はDAOの近くに置きます。Application層へ公開する型はDAOではなく、Application層に定義します。

返却値の決め方

取得後に業務ルールを使って変更する

RepositoryからAggregate Rootを返します。

一覧・検索・集計の結果を表示する

QueryからRead Modelを返します。DBアクセスをDAOへ分ける場合は、Queryの実装からDAOを呼びます。

存在するか、何件あるかだけを確認する

用途が明確なら、Repositoryからbooleannumberを返すメソッドも定義できます。

ディレクトリで表す

src/features/futsal/
├── application/
│   └── query/
│       ├── IRecruitmentQuery.ts
│       └── RecruitmentListItem.ts
├── domain/
│   └── recruitment/
│       ├── RecruitmentEntity.ts
│       └── IRecruitmentRepository.ts
└── infrastructure/
    ├── query/
    │   └── RecruitmentQuery.ts
    └── repository/
        └── RecruitmentRepository.ts

更新ではRepositoryとAggregate Rootを使い、読み取りではQueryとRead Modelを使います。DAOを分ける場合は、Infrastructure層のQuery実装から呼び出します。