DDDで例外をどう扱う?

失敗を判断した層から出す

DDDには、例外の使い方を一つに決めたルールはありません。どこで問題が分かったかによって、例外を出す場所を決めます。

業務ルールを守れない

ルールを確認したEntity、Value Object、Domain Serviceから、業務上の失敗を表す例外を出します。

対象が見つからない

Repositoryの取得結果を確認したApplication Serviceから、対象が見つからないことを表す例外を出します。

DBや外部APIで障害が起きた

Infrastructure層で発生した例外として扱います。DBやライブラリ固有の例外をDomain層から出しません。

HTTPのエラーレスポンスへ変換する

Presentation層が例外の種類を確認し、HTTPステータスとレスポンスへ変換します。

業務ルールを守れない場合

例えば、申込期限を過ぎた募集へ参加を申請した場合を考えます。申込期限を確認するのは「募集」を表すAggregate Rootなので、RecruitmentEntityから例外を出します。

// 申込期限を過ぎたことを表すDomain層の例外
export class ParticipationDeadlineExceededError extends Error {
  readonly code = "PARTICIPATION_DEADLINE_EXCEEDED";

  constructor() {
    super("申込期限を過ぎています");
    this.name = "ParticipationDeadlineExceededError";
  }
}

export class RecruitmentEntity {
  requestParticipation(input: {
    requesterId: ParticipantIdValueObject;
    now: Date;
  }): void {
    // 状態を変更する前に、業務ルールを確認する
    if (this.deadline.hasPassed(input.now)) {
      throw new ParticipationDeadlineExceededError();
    }

    this.participantIds.push(input.requesterId);
  }
}

例外を出す前に一部の状態を変更すると、失敗した後に不正な状態が残る可能性があります。必要なルールを先に確認し、すべて満たした場合だけ状態を変更します。

対象が見つからない場合

「募集が存在すること」は募集自身が判断する業務ルールではありません。Repositoryから取得できなかったことを確認したApplication Serviceから例外を出します。

const recruitment = await this.repository.findById(
  command.recruitmentId,
);

if (!recruitment) {
  throw new RecruitmentNotFoundError(command.recruitmentId);
}

recruitment.requestParticipation({
  requesterId: participantId,
  now: command.requestedAt,
});

APIでHTTPレスポンスへ変換する

Domain層の例外には、HTTPステータスを持たせません。APIが例外を受け取り、外部へ返す形式へ変換します。

try {
  await applicationService.execute(command);
  return Response.json({ success: true });
} catch (error) {
  if (error instanceof ParticipationDeadlineExceededError) {
    return Response.json(
      { code: error.code, message: "申込期限を過ぎています" },
      { status: 409 },
    );
  }

  // 想定していない障害は握りつぶさない
  throw error;
}

例外ではなくResult型を使う場合

期限切れや定員到達が頻繁に起こり、呼び出し側が結果によって処理を分ける場合は、例外ではなくResult型で返す方法もあります。

type RequestParticipationResult =
  | { success: true }
  | {
      success: false;
      reason: "deadlineExceeded" | "capacityReached";
    };

例外とResult型のどちらを使うかより、業務ルールをドメインモデルで確認し、失敗しても不正な状態を残さないことが重要です。入力項目をまとめて確認して複数のエラーを返したい場合も、Result型やNotificationを使う方が扱いやすくなります。

ディレクトリで表す

src/features/futsal/
├── domain/
│   └── recruitment/
│       ├── RecruitmentEntity.ts
│       └── errors/
│           └── ParticipationDeadlineExceededError.ts
├── application/
│   └── errors/
│       └── RecruitmentNotFoundError.ts
└── presentation/
    └── api/
        └── requestParticipationRoute.ts