「見つからない」のエラーはリポジトリとユースケース層のどちらが出すか
もくじ
バックエンドの API を書いていると、探したデータがデータベースに無い、という場面に出会う。たとえば、課題を管理するアプリで、取得しようとした課題がもう削除されている。無いことは、誤りのときもあれば、正常のときもある。たとえば、新しい課題を作成する前に、同じ題名の課題が無いかを調べたのなら、無いのが正常だ。どちらなのかを決める場所を間違えると、新しい課題を作成しようとしただけで「見つかりません」と断る API ができあがる。
バックエンドは、サーバーの側で動いて、求められたデータを用意するプログラムだ。API は、バックエンドのうち、アプリやブラウザーからの求めを受け取って、データを返す部分だ。データベースは、大量の情報を整理して保存するためのソフトウェアだ。
だから、データを取り出す部分は「無い」と答えるだけにする。それが誤りかどうかは、操作の手順を書く部分が決める。たとえば、課題を取得する操作なら誤りにし、課題を作成する操作なら正常にする。
前提: コードは役目ごとに分けて書く
プログラムは、1つのファイルに全部書いても動く。ただ、アプリやブラウザーからの求めを受け取るコードも、業務ルール(「題名は空にできない」のように、そのアプリがそう決めたこと)も、データベースとやり取りするコードも同じ場所にあると、1か所を変えたいだけなのに全部を読むことになる。たとえば、題名の文字数の上限を変えたいだけなのに、データベースとやり取りするコードの間から、その1行を探すことになる。
だから、コードは役目ごとに分けて書く。分けたまとまりの1つずつを、層と呼ぶ。たとえば、業務ルールだけを集めたフォルダーが、1つの層だ。どう分けて、どこに何を書くかの決め方を、アーキテクチャと呼ぶ。
よく知られたアーキテクチャの1つに、クリーンアーキテクチャがある。この考え方を示した記事は、業務ルールを内側の層に置き、データベースや Web のような外部とやり取りする部分を外側の層に置く。たとえば、「題名は空にできない」は内側に書き、データベースに保存するコードは外側に書く。層は4つ挙げてあるが、4つでなければならないという決まりは無い、とも書いてある。
ここでは、バックエンドの API を次の4つの層に分ける。
- API の入口は、アプリやブラウザーが API に送る求め(リクエスト)を受け取り、結果を返す部分だ。たとえば、「課題の番号が 1 の課題を取得したい」というリクエストを受け取る
- ユースケース層は、操作1つずつの手順を書く部分だ。ユースケースは、英語で「使われ方の1場面」という意味である。たとえば、「課題を取得する」「課題を作成する」が、それぞれ1つの操作になる
- ドメイン層は、業務ルールを書く部分だ。ドメインは、そのアプリが扱う仕事の分野という意味である。たとえば、「題名は空にできない」をここに書く
- リポジトリ(Repository)は、データベースからデータを取り出し、データベースに保存する部分だ。英語で「保管場所」という意味である。たとえば、課題の番号が 1 の課題をデータベースから取り出す
矢印は、呼び出す向きだ。
ここで扱うのは、ユースケース層とリポジトリの間だ。ユースケース層がリポジトリに、たとえば「課題の番号が 99 の課題はあるか」と尋ね、リポジトリが「無い」と答える。その「無い」を誤りにするのは、どちらなのか。
課題を管理するアプリの API を例にする。課題は「ログインできない」のような題名を持ち、1、2、3 と課題の番号が付く。
src/
├── routes/
│ └── issues.ts API の入口。HTTP の番号を決める
├── usecase/
│ ├── getIssue.ts ユースケース層。課題を取得する操作(「見つからない」のエラーは本来ここで出す)
│ └── createIssue.ts ユースケース層。課題を作成する操作
└── repository/
└── issueRepository.ts リポジトリ(「見つからない」のエラーを出せてしまう)
例を短くするため、HTTP の通信は省き、API の入口は結果を値として返す関数にした。データベースも使わず、リポジトリは配列に入れた課題を探す。ドメイン層は出てこない。
困りごと: 無いのが正常な操作までエラーになる
この API には、操作が2つある。
| 操作 | リポジトリに尋ねること | 答えが「無い」のとき |
|---|---|---|
| 課題を取得する | この課題の番号の課題はあるか | 誤り。無い課題の番号が、リクエストで届いている |
| 課題を作成する | 同じ題名の課題はもうあるか | 正常。同じ題名の課題が無いので、作成してよい |
どちらの操作も、リポジトリ(データベースからデータを取り出す部分)に「あるか」を尋ねている。同じ「無い」という答えが、取得では誤りになり、作成では正常になる。
ここで、リポジトリが「無ければエラーを出す」と決めてしまうとする。取得はうまくいく。ところが作成では、同じ題名の課題が無いという正常な答えまで、エラーになる。たとえば「画面が白くなる」という新しい課題を作成しようとしただけなのに、「課題が見つかりません」と断られる。まだ作成していないのだから、見つからなくて当然である。
結論: リポジトリは null を返すだけにする
課題の番号で探した課題がデータベースに無いとき、リポジトリは null を返すだけにする。「見つからない」のエラーにするのは、ユースケース層(操作1つずつの手順を書く部分)だ。
null は、「何も無い」ことを表す値である。たとえば、課題の番号が 99 の課題を探して無ければ、リポジトリは null を返す。それを受け取ったユースケース層が、課題を取得する操作なら「見つからない」のエラーにする。
図書館に置き換えると、こうなる。書庫の係は、頼まれた本を書庫から出してくる。本が無ければ「その本はありません」と答える。それが困ったことかどうかは、頼んだ人の用事で決まる。本を借りに来た人にとっては、困ったことだ。同じ本を二重に買わないよう、買う前に確かめに来た職員にとっては、無いのが望んだ答えである。書庫の係がリポジトリで、頼んだ人の用事を知っているのがユースケース層だ。
悪い例: リポジトリが「見つからない」のエラーを出す
リポジトリを、無ければエラーを出すように書く。
// src/repository/issueRepository.ts
export type Issue = { id: number; title: string };
export class IssueNotFoundError extends Error {}
const issues: Issue[] = [{ id: 1, title: "ログインできない" }];
export function findById(id: number): Issue {
const issue = issues.find((issue) => issue.id === id);
if (!issue) throw new IssueNotFoundError("課題が見つかりません");
return issue;
}
export function findByTitle(title: string): Issue {
const issue = issues.find((issue) => issue.title === title);
if (!issue) throw new IssueNotFoundError("課題が見つかりません");
return issue;
}
export function save(title: string): Issue {
const issue = { id: issues.length + 1, title };
issues.push(issue);
return issue;
}
Issue は、課題が課題の番号(id)と題名(title)を持つことを表す型だ。issues は、データベースの代わりの配列で、課題が1つ入っている。findById は課題の番号で、findByTitle は題名で課題を探す。どちらも、無ければ throw の行で IssueNotFoundError というエラーを出して、そこで止まる。これが「見つからない」のエラーだ。save は、新しい課題を配列に足す。
課題を取得する操作は、findById を呼ぶだけで済む。
// src/usecase/getIssue.ts
import { type Issue, findById } from "../repository/issueRepository.ts";
export function getIssue(id: number): Issue {
return findById(id);
}
API の入口は、ユースケース層の関数を呼び、エラーが出たら HTTP の番号に置き換える。
// src/routes/issues.ts
import { IssueNotFoundError } from "../repository/issueRepository.ts";
import { DuplicateIssueTitleError, createIssue } from "../usecase/createIssue.ts";
import { getIssue } from "../usecase/getIssue.ts";
export function handleGetIssue(id: number) {
try {
return { status: 200, issue: getIssue(id) };
} catch (error) {
return toErrorResponse(error);
}
}
export function handleCreateIssue(title: string) {
try {
return { status: 201, issue: createIssue(title) };
} catch (error) {
return toErrorResponse(error);
}
}
function toErrorResponse(error: unknown) {
if (error instanceof IssueNotFoundError) {
return { status: 404, error: error.message };
}
if (error instanceof DuplicateIssueTitleError) {
return { status: 409, error: error.message };
}
throw error;
}
try の中でエラーが出ると、続きは catch に移る。toErrorResponse は、「見つからない」のエラーなら 404 を返し、同じ題名の課題がもうあるというエラー(DuplicateIssueTitleError)なら 409 を返す。404 は、求められたものが見つからないことを表す HTTP の番号だ。409 は、リクエストが今のデータの状態とぶつかって完了できないことを表す。200 は取得できたこと、201 は作成できたことを表す。
取得は、思ったとおりに動く。課題の番号が 1 の課題はあり、99 の課題は無い。
handleGetIssue(1)
{ status: 200, issue: { id: 1, title: 'ログインできない' } }
handleGetIssue(99)
{ status: 404, error: '課題が見つかりません' }
次に、課題を作成する操作を書く。同じ題名の課題がもうあれば断り、無ければ作成する。
// src/usecase/createIssue.ts
import { type Issue, findByTitle, save } from "../repository/issueRepository.ts";
export class DuplicateIssueTitleError extends Error {}
export function createIssue(title: string): Issue {
const existing = findByTitle(title);
if (existing) {
throw new DuplicateIssueTitleError("同じ題名の課題がもうあります");
}
return save(title);
}
まだ無い題名で呼ぶと、こうなる。
handleCreateIssue("画面が白くなる")
{ status: 404, error: '課題が見つかりません' }
作成したいのに、404 が返る。findByTitle が、同じ題名の課題が無いという正常な答えをエラーにして出すので、if の行に届く前に止まる。API の入口は、そのエラーを 404 に置き換えている。
直すには、課題を作成する操作の側でエラーを受け止めて、正常と読み替える。
// src/usecase/createIssue.ts
import {
type Issue,
IssueNotFoundError,
findByTitle,
save,
} from "../repository/issueRepository.ts";
export class DuplicateIssueTitleError extends Error {}
export function createIssue(title: string): Issue {
try {
findByTitle(title);
} catch (error) {
if (error instanceof IssueNotFoundError) {
// 同じ題名の課題が無い。これは正常なので、作成に進む
return save(title);
}
throw error;
}
throw new DuplicateIssueTitleError("同じ題名の課題がもうあります");
}
handleCreateIssue("画面が白くなる")
{ status: 201, issue: { id: 2, title: '画面が白くなる' } }
handleCreateIssue("ログインできない")
{ status: 409, error: '同じ題名の課題がもうあります' }
これで動くようにはなる。ただし、作成に進むという正常な道が catch の中にあり、断る行が関数のいちばん下にある。リポジトリが出したエラーを、ユースケース層が打ち消している。書庫の係が「大変です、ありません」と駆け込んできて、職員が「いえ、それでいいんです」となだめているようなものだ。
良い例: リポジトリは null を返すだけにし、ユースケース層が「見つからない」のエラーにする
リポジトリは、無ければ null を返す。
// src/repository/issueRepository.ts
export type Issue = { id: number; title: string };
const issues: Issue[] = [{ id: 1, title: "ログインできない" }];
export function findById(id: number): Issue | null {
return issues.find((issue) => issue.id === id) ?? null;
}
export function findByTitle(title: string): Issue | null {
return issues.find((issue) => issue.title === title) ?? null;
}
export function save(title: string): Issue {
const issue = { id: issues.length + 1, title };
issues.push(issue);
return issue;
}
Issue | null は、課題か null のどちらかを返すという意味だ。?? null は、配列に見つからなかったときに null を返す。「見つからない」のエラーは、リポジトリから消えた。
課題を取得する操作は、null を誤りとして扱う。
// src/usecase/getIssue.ts
import { type Issue, findById } from "../repository/issueRepository.ts";
export class IssueNotFoundError extends Error {}
export function getIssue(id: number): Issue {
const issue = findById(id);
if (issue === null) {
throw new IssueNotFoundError("課題が見つかりません");
}
return issue;
}
「見つからない」のエラー IssueNotFoundError は、ユースケース層に移した。
課題を作成する操作は、null を正常として扱う。
// src/usecase/createIssue.ts
import { type Issue, findByTitle, save } from "../repository/issueRepository.ts";
export class DuplicateIssueTitleError extends Error {}
export function createIssue(title: string): Issue {
const existing = findByTitle(title);
if (existing !== null) {
throw new DuplicateIssueTitleError("同じ題名の課題がもうあります");
}
return save(title);
}
null でなければ、同じ題名の課題がもうあるので断る。null なら、そのまま作成に進む。try も catch も要らない。
API の入口で変わったのは、IssueNotFoundError を読み込む行だけだ。リポジトリからではなく、ユースケース層から読み込む。
// src/routes/issues.ts
import { DuplicateIssueTitleError, createIssue } from "../usecase/createIssue.ts";
import { IssueNotFoundError, getIssue } from "../usecase/getIssue.ts";
悪い例と同じ4つを呼んだ結果がこれだ。
handleGetIssue(1)
{ status: 200, issue: { id: 1, title: 'ログインできない' } }
handleGetIssue(99)
{ status: 404, error: '課題が見つかりません' }
handleCreateIssue("画面が白くなる")
{ status: 201, issue: { id: 2, title: '画面が白くなる' } }
handleCreateIssue("ログインできない")
{ status: 409, error: '同じ題名の課題がもうあります' }
なぜそれでよいのか
無いことが誤りかどうかは、操作ごとに違う。そしてリポジトリは、自分がどの操作から呼ばれたのかを知らない。たとえば findByTitle が受け取るのは題名だけで、課題を作成する前の確かめなのかどうかは渡されない。知らないことは決められない。だからリポジトリは「無い」という事実だけを null で返し、誤りかどうかは、操作の手順を知っているユースケース層が決める。
null を返すと、確かめ忘れを TypeScript が止めてくれる。たとえば、null かどうかを確かめずに題名を読むコードを書いてみる。
// src/usecase/forgetNullCheck.ts
import { findById } from "../repository/issueRepository.ts";
export function getIssueTitle(id: number): string {
const issue = findById(id);
return issue.title;
}
src/usecase/forgetNullCheck.ts(6,9): error TS18047: 'issue' is possibly 'null'.
「issue は null かもしれない」というエラーだ。戻り値の型が Issue | null なので、TypeScript は、null かどうかを確かめる前に題名を読むコードを通さない(strict という設定を有効にしている場合)。
悪い例は逆だ。課題を作成する操作でエラーを受け止め忘れたコードを、TypeScript はエラーにしない。関数がエラーを出すかどうかは、型に書かれないからだ。たとえば、悪い例の findByTitle の型は「題名を受け取って、課題を返す」で、エラーを出すとはどこにも書いていない。間違いに気づくのは、動かして 404 が返ってきたときになる。
リポジトリに、null を返す関数とエラーを出す関数の両方を持たせる案もある。たとえば、null を返す findById の隣に、無ければエラーを出す findByIdOrThrow を並べる。比べるとこうなる。
| リポジトリの書き方 | 無いのが正常な操作では | 間違えたとき |
|---|---|---|
| 無ければエラーを出す(悪い例) | エラーを受け止めて、正常と読み替える | 受け止め忘れても、TypeScript は止めない |
| null を返す関数と、エラーを出す関数の両方を持つ | null を返す関数を選んで呼ぶ | エラーを出す関数を間違えて呼んでも、TypeScript は止めない |
| 無ければ null を返すだけ(良い例) | null なら先に進む | null を確かめ忘れると、TypeScript が止める |
リポジトリはエラーを一切出さない、という話ではない。たとえば、データベースにつながらないのは、どの操作にとっても誤りなので、リポジトリがエラーを出してよい。null で返すのは、操作によって意味が変わる「無い」だけだ。
この分け方で諦めるもの
諦めるものは、リポジトリを呼ぶ1行で済む短さだ。悪い例の課題を取得する操作は、findById を呼ぶだけで済む。良い例では、null かどうかを確かめる行が増える。課題を更新する操作、課題を削除する操作と、操作が増えるたびに同じ行を書く。
同じ行が気になるほど増えたら、「無ければ『見つからない』のエラーを出す」関数を、ユースケース層に1つ作ればよい。リポジトリに置かなければ、誤りかどうかを決めるのはユースケース層のままだ。
それでも僕は、リポジトリは null を返すだけにするほうを選ぶ。行が増えるのは目に見える。正常な答えがエラーになっていることは、動かすまで見えない。
「無い」と答えることと、「誤りだ」と決めることは別だ
課題の番号で探した課題がデータベースに無いとき、リポジトリは null を返すだけにする。「見つからない」のエラーにするのは、操作の手順を知っているユースケース層だ。無いと答える層と、誤りだと決める層を分けておけば、新しい課題を作成しようとして「見つかりません」と断られることはない。
おわり😊

