はじめに
エラーの実装指針は難しく、特に近年は AI にコードを書かせる場面で一貫性のないエラー対処コードが散見されがちです。
AI には直接指示をしたり、CLAUDE.md などを通じて指針を示すことができます。しかし、指針を決めるのは結局人間であり、決めるためには自身がしっかり理解をしている必要があります。
本記事は AI 時代において、綺麗なコードを書く(AI に書かせる)ために、エラー設計についてあらためてまとめてみました。
問題
エラー監視ツールへの通知は、障害だけを対象にすべきです。通知は人に対応を促す仕組みであり、促された人が行うことは復旧作業だからです。
ところが現場では、次のような通知が実装されることがよくあります。
- 例外を
catchした箇所でその都度通知する。1 回の失敗が、伝播経路にあるcatchの数だけ重複して通知される - 入力不正(準正常系)で 400 を返すときに通知する。ユーザーが入力を直せば済み、復旧作業は発生しない
- リトライで回復した一時的な接続失敗を通知する。自力で回復しており、障害ではない
- 逆に、「よくある失敗だから」と例外を
catchして握りつぶす。そのcatchには想定していない例外も入り込むため、本来通知すべき障害が通知されない
はじめの 3 つは通知が過剰な例であり、最後の 1 つは通知が不足している例です。しかし、過剰と不足は別々の問題ではありません。
対応の不要な通知が積み重なると、通知の大半は確認しなくてよいものだという認識が生まれやすくなり、本来知らせるべき障害がその中に埋もれてしまいます。通知が多すぎる状態は、結果として通知が届かない状態と変わりません(通称:オオカミ少年アラート)。
このようなことが起きるのは、「例外が発生したかどうか」を通知の判断基準にしているためだと考えられます。しかし、例外は言語の機構であり、準正常系・異常系・バグのいずれによっても発生します。通知すべきかどうかを決めるのは復旧作業が必要かどうかですが、例外が発生したというだけでは、それが必要かどうか分かりません。
適切な基準を持つには、まず語を分けて整理する必要があります。本記事では、ソフトウェアで起きるエラーについて整理し、その対応と、障害として通知すべきものを明らかにします。
前提
- 具体例は TypeScript(Node.js)で示します。HTTP サーバの例では Express 5 を用いますが、主張は言語やフレームワークに依存しません
- エラー監視ツールは通知の設計を扱う箇所でのみ登場させ、Sentry を用います
異常系・バグ・障害・例外を区別する
エラー監視ツールに通知すべき対象は障害です。しかし「異常系」「バグ」「障害」「例外」の 4 つは、日常の会話では混ざって使われることがよくあります。
この 4 つはそれぞれ別の分野の用語であり、指しているものも異なります。そのため、分野の異なる語を同じものを指す語として使うと、混同が起きます。
そこでこの章では、各語がどの分野の用語かを示したうえで、語と語の関係を説明します。
分野をまたぐ用語を整理する
用語は世間一般の意味に従い、本稿で独自の意味を与えません。
| 分野 | 用語 | 定義 |
|---|---|---|
| 共通 | 仕様 | ソフトウェアに意図した機能と振る舞いです。文書化されているかは問いません。「外部サービスが一時的に落ちても処理を完了させる」のような暗黙の意図も含みます |
| 要件定義・テスト設計 | 正常系 | 仕様が意図した通りに、機能が提供される場合です |
| 要件定義・テスト設計 | 準正常系 | 利用者の入力不正、権限不足、業務ルール上の拒否のように、仕様が想定した理由で機能の提供を断る場合です |
| 要件定義・テスト設計 | 異常系 | DB 停止、ネットワーク断、外部サービスの失敗、ファイルが開けない、のように、コードの外の要因で機能を提供できない場合です |
| プログラミング言語(言語仕様) | 例外(Error/Exception) | 言語が提供する、通常の制御フローから脱出して失敗を呼び出し元へ伝播させる機構と、それが運ぶオブジェクトです。JS/TS の Error と throw、Java や Python の Exception を指します |
| ソフトウェアテスト(ISTQB) | バグ | コードの不備です。コードが仕様と異なる実装をしていることを指し、準正常系・異常系に対する処置を実装していないことも含みます |
| システム運用(ITIL) | 障害(インシデント) | システムが仕様で意図した機能を提供できず、かつ自力で回復しなかった事象です。復旧作業が必要となります |
| システム運用(ITIL) | 自力の回復 | ソフトウェアに実装された回復手段(リトライ、再接続、フェイルオーバー、キューによる再実行)です。ユーザーの再操作は自力の回復に含めません |
| システム運用(ITIL) | 復旧作業 | 障害の後に人が行う作業です。コードの復旧、データの復旧、インフラ・外部依存の復旧の 3 種類があり、どれか 1 つでも必要になれば障害です |
本稿で「例外」は、常に言語機構の意味で使います。仕様上の失敗は「準正常系」「異常系」と呼び、その実装方法(例外を throw する、戻り値で返す、HTTP ステータスで返す)は問いません。
準正常系・異常系とバグは、別のものとして扱う
準正常系・異常系とバグは、互いに独立しています。準正常系・異常系であってもバグがあることはあり、バグが無くても準正常系・異常系は起こります。
正常系・準正常系・異常系は、いずれも仕様が想定しているものです。利用者が不正な値を入れる、ネットワークが切れる、外部サービスが落ちる、ファイルが開けない、といった事象が起きたときに何をするかを、仕様があらかじめ決めています。
一方でバグは、コードが仕様と異なる実装になっていることを指します。準正常系や異常系に対する処置を実装していない場合も、仕様と異なる実装であるため、バグにあたります。
以下では、準正常系と異常系をまとめて扱います。どちらも仕様が想定しており、コードが処置する点で同じだからです。
「正常系か、それ以外(準正常系・異常系)か」と「コードは仕様通りか」を組み合わせると、事象は次の 4 つに分かれます。
| 系 | コードは仕様通りか | 何が起きるか | 例 |
|---|---|---|---|
| 正常系 | 仕様通り | 仕様通りに動作します | |
| 正常系 | 仕様通りでない | 仕様と異なる実装が実行されます。現れ方は 2 つあります。 (a) 実行時に例外として現れる (b) システムが仕様と異なる挙動をする |
(a) ・ TypeError(null や undefined のプロパティ参照)・ ReferenceError(未宣言の変数の参照)(b) ・例外は発生したが、コードが catch して握りつぶし、動作し続けた ・例外は発生せず、仕様と異なる結果を出し続けた |
| 準正常系・異常系 | 仕様通り | コードが仕様通りに処置します。異常系では、処置の手段が尽きて失敗に終わることもあります | ・DB 接続に失敗したが、再接続して保存処理を完了した ・DB 接続に失敗し、上限まで再接続しても復帰せず、保存処理を完了できなかったため、ユーザーにエラー画面(500)を通知した |
| 準正常系・異常系 | 仕様通りでない | コードに処置が無い、または処置が誤っています。準正常系・異常系の事象が引き金になってバグが表に出ます | ・DB 接続に失敗したが、再接続もエラー通知も実装されておらず、保存処理が途中で止まり、ユーザーには意図しない応答が返った ・コードが DB 接続の失敗を catch して握りつぶし、保存できていないのに成功を返した ・コードが DB 接続の失敗に対して上限なくリトライし、保存処理が終わらない |
2 行目の (b) のバグは、エントリーポイントに置いたエラーハンドラでは検知できません。エラーハンドラが受け取れるのは、そこまで伝播してきた例外だけです。(b) では例外が発生しないか、途中で捕捉されて止まるため、エラーハンドラには届きません。
(b) を検知する手段は、テストと、データの整合性チェックです。このほか、(b) の影響を受けた後続の処理が別の失敗として現れ、そこから気づく場合もあります。たとえば、保存されなかったデータを後の処理が読み出そうとして、そこで初めて例外が発生する場合です。
例外は、準正常系・異常系でもバグでも発生する
例外はプログラミング言語の用語であり、正常系・準正常系・異常系のどれかを指す語でも、コードが仕様通りかを指す語でもありません。先ほどの表の 4 つの事象すべてで発生します。
- 例外は、準正常系・異常系でも発生します。
fetchがネットワークの失敗で投げる例外や、データベースのドライバが接続の失敗で投げる例外がその例です。多くはライブラリが投げます - 例外は、バグでも発生します。undefined のプロパティを参照したときの
TypeErrorや、宣言していない変数を参照したときのReferenceErrorがその例です。こちらは言語が投げます
例外の型を見ても、どちらであるかは分かりません。fetch は、ネットワークエラーを TypeError として投げます。同じ TypeError が、外部環境の失敗を表す場合と、コードの誤りを表す場合があります。
したがって、throw された例外には、準正常系・異常系が原因のものと、コードのバグが原因のものがあります。catch (e: unknown) は、そのどちらも同じように捕捉します。この区別が、どこで catch するかの設計の前提になります。
障害かどうかは、復旧作業が必要かどうかで決まる
障害はシステム運用の用語です。機能を提供できたかどうかと、人による復旧作業が必要かどうかで判定します。原因が準正常系・異常系であるか、バグであるかは、それだけでは障害かどうかを決めません。
- バグは、その経路が実行されればほとんどの場合障害になります。実装は本来仕様と異なるが結果的に仕様通りに動いている潜在的なバグは、短期的には障害ではありません
- 準正常系・異常系は、仕様通りに処置され復旧作業が不要なら障害ではありません。異常系で処置の手段が尽き、復旧作業が必要になれば障害です
復旧作業は主に次の 3 種類です。3 種類は独立に発生します。コードを直しても失われたデータは戻らず、DB を復帰させても止まった処理は再実行しなければ完了しません。
| 復旧作業 | 内容 |
|---|---|
| コードの復旧 | 仕様と異なるコードを修正し、デプロイします |
| データの復旧 | 失われた、または不整合になったデータを、再実行や手作業で正しい状態に戻します |
| インフラ・外部依存の復旧 | 不具合が発生した DB やミドルウェアなどを復帰させます。外部サービスの場合は復帰を確認し、影響範囲を把握します |
本稿が扱う、ソフトウェアの実行中に起きる障害の原因は、次の 2 つです。必要になる復旧作業の種類が異なります。仕様そのものの誤りや漏れ、デプロイや設定などの運用操作の誤りも障害の原因になりますが、いずれもコードの実行中に例外として現れないため、本稿では扱いません。
| 原因 | 復旧作業 |
|---|---|
| バグ(処置の欠落を含む) | コードの復旧が必要です。必要に応じてデータの復旧も行います |
| 処置の手段が尽きた異常系 | インフラ・外部依存の復旧が必要です。必要に応じてデータの復旧も行います。コードの復旧は不要です |
ケーススタディ: 場面ごとの判断と実装
ここからは、実際に書くコードを場面ごとに見ていきます。よくある書き方と、その問題、書き直した形を示します。
判断は 2 つです。
- 失敗を何で返すか
- どこで通知するか
準正常系や、呼び出し元が処置できる異常系は、通常の制御フローです。例外は例外的な状況にのみ使い、通常の制御フローには使わない、というのは広く共有された指針です(Effective Java, Item 69、SEI CERT ERR50-J)。したがって、ほとんどのケースでは、失敗の結果は戻り値で返すべきです。
処置の手段が尽きた異常系と、バグは、例外として投げます。どこで通知するかは、例外を止めた箇所で決まります。その箇所で機能を提供できていなければ、そこで通知します。
予期しない例外をエントリーポイントかエラーハンドラで捕捉する
TypeError や ReferenceError のような、バグが原因で発生する例外を扱います。正常系の処理の中で、コードが仕様と異なる実装になっていた場合です。この例外が発生した時点で機能は提供できておらず、コードの復旧も必要になるため、障害にあたります。したがって、通知の対象になります。
バグは、発生する箇所を事前に予測できません。予測できていれば、その箇所を直しているからです。したがって、個々の箇所で捕捉する方針は成り立ちません。捕捉できるのは、すべての処理の外側にある 1 箇所だけです。
次のコードは、各所で捕捉して通知しています。
// 注文を保存します。ルートハンドラから呼ばれます
async function saveOrder(input: OrderInput): Promise<Order> {
try {
return await db.insertOrder(input);
} catch (e: unknown) {
Sentry.captureException(e); // 1 回目の通知
throw e;
}
}
// ルートハンドラ。saveOrder を呼びます
app.post('/orders', async (req: Request, res: Response) => {
try {
const order = await saveOrder(req.body);
res.json(order);
} catch (e: unknown) {
Sentry.captureException(e); // 2 回目の通知。saveOrder が投げ直した同じ失敗です
res.status(500).json({ message: 'Internal Server Error' });
}
});
このコードには 2 つの問題があります。1 つは、1 回の失敗が 2 回通知されることです。伝播の経路に捕捉する箇所が増えるほど、通知の数も増えます。もう 1 つは、どちらの catch も処置をしていないことです。通知と 500 の応答は処置ではなく、失敗したことを伝えているだけです。
処置ができないのであれば、捕捉する理由がありません。
捕捉をやめると、例外はエントリーポイントまで到達します。Express では、引数を 4 つ取るミドルウェアがエラーハンドラになります。すべてのルートの後に登録します。
// 注文を保存します。失敗した場合は例外がそのまま呼び出し元へ伝わります
async function saveOrder(input: OrderInput): Promise<Order> {
return await db.insertOrder(input);
}
// ルートハンドラ。saveOrder を呼びます
// 例外を捕捉しないため、失敗は Express がエラーハンドラへ渡します
app.post('/orders', async (req: Request, res: Response) => {
const order = await saveOrder(req.body);
res.json(order);
});
// エラーハンドラ。すべてのルートより後に登録します
// どのルートで発生したかによらず、捕捉されなかった例外はここへ届きます
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
Sentry.captureException(err);
res.status(500).json({ message: 'Internal Server Error' });
});
通知は 1 箇所だけになり、通知の数と失敗の数が一致します。
try/catch を書く場所を絞る
TypeScript の catch は、捕捉する例外の種類を選べません。捕捉した値の型は unknown であり、ある失敗を処置するつもりで書いた catch が、その場所を通るすべての例外を捕捉します。
在庫を引き当てる関数を例にします。在庫が足りない場合に例外を投げる仕様です。
// 在庫を引き当てます。足りない場合は OutOfStockError を投げます
async function reserve(itemId: string, count: number): Promise<number> {
const stock = await db.findStock(itemId);
if (stock.available < count) {
throw new OutOfStockError(itemId);
}
return await db.decrementStock(itemId, count);
}
次のコードは、この関数を呼び、在庫が足りない場合を処置するために捕捉しています。
// 在庫を引き当てます。ルートハンドラから呼ばれます
async function reserveStock(itemId: string, count: number): Promise<number> {
try {
return await reserve(itemId, count);
} catch (e: unknown) {
// 在庫不足のつもりですが、接続の失敗もコードのバグもここへ来ます
return 0;
}
}
在庫不足であれば、0 を返すことは仕様通りの処置です。しかしデータベースへの接続が失敗した場合も、stock が undefined で TypeError が発生した場合も、同じように 0 が返ります。
呼び出し元は在庫が無いものとして処理を続け、例外はエラーハンドラに到達しないため、通知もされません。失敗したことを誰も知らないまま、誤った結果が残ります。
処置できる失敗だけを処置し、それ以外はそのまま投げ直します。
// 在庫を引き当てます。ルートハンドラから呼ばれます
async function reserveStock(itemId: string, count: number): Promise<number> {
try {
return await reserve(itemId, count);
} catch (e: unknown) {
// 在庫不足は仕様が想定した失敗なので、ここで処置します
if (e instanceof OutOfStockError) {
return 0;
}
// それ以外は処置できないため、呼び出し元へ渡します
throw e;
}
}
判断の基準は、その場で処置できるかどうかです。処置できないのであれば、捕捉する理由がありません。
なお、この例の reserve は、在庫不足という仕様が想定した失敗を例外で表現しています。そのため呼び出し元は、処置できる失敗かどうかを instanceof で判定することになりました。失敗を戻り値で返す設計であれば、この判定も catch も不要になります。
エラーハンドラに届かない非同期処理を捕捉する
メールの送信が失敗することは異常系です。送信できなければその機能は提供できておらず、再送などの復旧作業も必要になるため、障害にあたります。
ただしこの節では、もう 1 つ別の問題が起きます。ここまでは、例外が呼び出し元へ順に伝わることを前提にしてきました。しかし、その経路が存在しない場合があります。
// 確認メールを送ります。失敗した場合は例外を投げます
async function sendConfirmationMail(order: Order): Promise<void> {
await mailer.send(order.email, '注文を承りました');
}
// ルートハンドラ
app.post('/orders', async (req: Request, res: Response) => {
const order = await saveOrder(req.body);
sendConfirmationMail(order); // 完了を待たずに次へ進みます
res.json(order);
});
このコードの問題は、sendConfirmationMail が失敗しても、その例外を受け取る経路が存在しないことです。この関数が返した Promise を誰も受け取っていないため、失敗は reject としてその Promise の中に留まります。ルートハンドラがまだ実行中であっても、await していない限り、例外がルートハンドラへ伝わることはありません。
同じことが、setTimeout や setInterval のコールバック、イベントハンドラに渡した async 関数でも起こります。
応答を待たせずに実行するのであれば、その箇所で捕捉して通知します。
// ルートハンドラ
app.post('/orders', async (req: Request, res: Response) => {
const order = await saveOrder(req.body);
// 切り離した時点でエラーハンドラには届かないため、この箇所で通知します
sendConfirmationMail(order).catch((e: unknown) => {
Sentry.captureException(e);
});
res.json(order);
});
失われて困る処理であれば、切り離すのではなく、キューのように完走を保証する経路へ載せます。
サーバーレスの環境では、切り離した処理そのものが動作しないことがあります。Vercel や AWS Lambda などの関数は、リクエストごとに起動して応答とともに停止する前提で動いており、応答を返した時点で実行の基盤が回収されることがあります。await していない処理は誰からも追跡されていないため、途中で中断されます。
外部依存の失敗に処置する
データベースや外部サービスの失敗は異常系です。仕様が想定しており、コードが処置します。
異常系への処置には、リトライや再接続、別の系統への切り替えなど、いくつかの手段があります。ここではリトライを例にします。
const MAX_ATTEMPTS = 3;
// 注文を取得します。ルートハンドラから呼ばれます
// 接続の一時的な失敗に備えて、最大 3 回まで試します
async function findOrder(orderId: string): Promise<Order> {
let attempt = 0;
while (true) {
attempt++;
try {
return await db.findOrder(orderId);
} catch (e: unknown) {
// 最後の試行も失敗した場合は、処置の手段が尽きています
if (attempt === MAX_ATTEMPTS) {
throw e;
}
await sleep(attempt * 1000);
}
}
}
途中の試行で成功した場合、機能は提供できています。自力で回復したため障害ではなく、通知もしません。
上限まで試しても失敗した場合は、処置の手段が尽きています。この箇所で通知したくなりますが、通知はしません。
例外を投げ直しているため、エラーハンドラが受け取って通知します。ここでも通知すると、1 回の失敗が 2 回通知されます。通知するのは、例外を止めた箇所だけです。
失敗した件だけを記録して続行する
ここでは、複数の件をまとめて処理する場合に、1 件が失敗しても続行すると決めた場合を扱います。1 件ごとの失敗は異常系であり、その件について機能を提供できていないため、障害にあたります。
次のコードは、失敗した件を記録して次へ進みます。これまでの節では、例外はエラーハンドラまで届き、そこで通知されました。しかしこの catch は例外を止めるため、エラーハンドラには届きません。そこで例外的に、その 1 件が送信できていないことが確定するこの箇所で通知します。
// 未送信の確認メールをまとめて送ります。1 件の失敗で全体を止めません
// 送信できなかった注文の ID を返します
async function sendPendingMails(orders: Order[]): Promise<string[]> {
const failedOrderIds: string[] = [];
for (const order of orders) {
try {
await sendConfirmationMail(order);
} catch (e: unknown) {
// この 1 件は送信できていないため、この箇所で通知します
// 投げ直さないため、エラーハンドラには届きません
Sentry.captureException(e);
failedOrderIds.push(order.id);
}
}
return failedOrderIds;
}
件数が多い場合は、1 件ごとに通知すると同じ内容の通知が大量に並びます。失敗した件を記録しておき、ループの後で 1 回だけ通知する方法もあります。
// 未送信の確認メールをまとめて送ります。1 件の失敗で全体を止めません
// 送信できなかった注文の ID を返します
async function sendPendingMails(orders: Order[]): Promise<string[]> {
const failedOrderIds: string[] = [];
for (const order of orders) {
try {
await sendConfirmationMail(order);
} catch (e: unknown) {
// ここでは記録するだけです
failedOrderIds.push(order.id);
}
}
// ループの後で 1 回だけ通知します
if (failedOrderIds.length > 0) {
Sentry.captureException(
new Error(`確認メールを ${failedOrderIds.length} 件送信できませんでした`),
{ extra: { failedOrderIds } },
);
}
return failedOrderIds;
}
続行するかどうかは設計上の判断です。バッチ処理では、1 件の不正なレコードでジョブ全体を止めるべきではないとされています。失敗を記録してその件を飛ばし、残りを処理して、後から分析します。
一方、上の例でメールの送信サービス自体が落ちている場合は、残りの件も同じように失敗します。続けても失敗を繰り返すだけなので、途中で中断した方が無駄がありません。実務では、連続して失敗した回数が一定を超えたら中断する仕組み(サーキットブレーカー)を併用することもあります。
準正常系・異常系を戻り値で表現する
準正常系や、呼び出し元が処置できる異常系は、通常の制御フローです。例外は例外的な状況にのみ使い、通常の制御フローには使わない、というのは広く共有された指針です(Effective Java, Item 69、SEI CERT ERR50-J)。したがって、ほとんどのケースでは、失敗の結果は戻り値で返すべきです。
在庫不足は、仕様が想定した失敗です。呼び出し元は、在庫が無い場合の処理を続けられます。したがって、戻り値で返すべき失敗にあたります。
次のコードは、try/catch を書く場所を絞るの章で扱ったものです。在庫が足りないことを例外で表現しており、実は悪いコードの例です。
// 在庫を引き当てます。足りない場合は OutOfStockError を投げます
async function reserve(itemId: string, count: number): Promise<number> {
const stock = await db.findStock(itemId);
if (stock.available < count) {
throw new OutOfStockError(itemId);
}
return await db.decrementStock(itemId, count);
}
// reserve を呼びます。在庫不足かどうかを判定する必要があります
async function reserveStock(itemId: string, count: number): Promise<number> {
try {
return await reserve(itemId, count);
} catch (e: unknown) {
if (e instanceof OutOfStockError) {
return 0;
}
throw e;
}
}
例外で表現したことで、3 つの問題が生まれています。
1 つは、失敗が型に現れないことです。Promise<number> という戻り値からは、この関数が失敗しうることも、その理由が在庫不足であることも読み取れません。呼び出し元は実装か文書を読まない限り気づけません。
もう 1 つは、呼び出し元が try/catch を強いられることです。しかも catch は種類を選べないため、在庫不足だけを処置したくても、接続の失敗やバグまで捕捉してしまいます。instanceof の判定は、そのために必要になったものです。
そして、その判定を忘れると、接続の失敗もバグも 0 として扱われます。前の章で見た、失敗が誰にも知られないまま残る形です。
失敗を戻り値で返すと、この 2 つが不要になります。
// 成功と失敗のどちらかであることが型に現れます
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
// 在庫を引き当てます。足りない場合は理由を戻り値で返します
async function reserve(
itemId: string,
count: number,
): Promise<Result<number, 'out_of_stock'>> {
const stock = await db.findStock(itemId);
if (stock.available < count) {
return { ok: false, error: 'out_of_stock' };
}
return { ok: true, value: await db.decrementStock(itemId, count) };
}
// reserve を呼びます。try/catch も種類の判定も不要です
async function reserveStock(itemId: string, count: number): Promise<number> {
const result = await reserve(itemId, count);
// 仕様が想定した失敗です。機能の提供を断っているため、通知しません
if (!result.ok) {
return 0;
}
return result.value;
}
戻り値にすると、失敗が関数の型に現れます。呼び出し元が result.ok を確認しなければ result.value を参照できないため、失敗の可能性を見落とすことがありません。
データベースのドライバのように、ライブラリが例外を投げる場合は、呼び出しの境界で戻り値に変換します。ただし変換するのは、呼び出し元が処置できる失敗だけです。接続の失敗のように処置の手段が尽きたものや、バグによる例外は、そのまま投げます。
失敗を値として返すこの形は、Rust の Result や Go の複数戻り値など、多くの言語で採用されています。TypeScript には言語としての Result がないため、上のように判別可能なユニオンで定義します。neverthrow のようなライブラリもありますが、言語の一部ではないぶん馴染みが薄く、書き方にも制約が出ます。まずはライブラリを使わない形で十分です。
回復不能と判断した箇所で止める
コードが不正な状態を検知した場合は、その場で止めます。到達しないはずの状態に到達したのであれば、それはバグであり、コードの復旧が必要になるため障害です。
次のコードは、注文の合計金額を計算します。空の注文はここへ到達しない仕様ですが、到達した場合に 0 を返しています。
// 注文の合計金額を計算します
function totalAmount(order: Order): number {
// 空の注文は 0 円として扱います
if (order.items.length === 0) {
return 0;
}
return order.items.reduce((sum, item) => sum + item.price, 0);
}
落ちないという意味では安全に見えます。しかし空の注文が到達すること自体がバグなので、0 円の注文がそのまま保存されます。誤ったデータが残り、失敗したことは誰も知りません。
問題が表に出るのは、後の処理や請求の段階です。原因の箇所から離れるほど、調査は難しくなります。
到達しない前提であれば、到達した時点で止めます。
// 注文の合計金額を計算します
function totalAmount(order: Order): number {
if (order.items.length === 0) {
// 空の注文はここへ到達しない前提です。到達した時点でコードが誤っています
throw new Error(`order ${order.id} has no items`);
}
return order.items.reduce((sum, item) => sum + item.price, 0);
}
例外はエラーハンドラまで到達し、そこで通知されます。原因の箇所と通知が同じ処理の中にあるため、調査もしやすくなります。復旧作業の対象も、この 1 件に限定できます。
おわりに
長い記事を読んでいただき、ありがとうございます。