第14章:Infrastructure入門② 外部APIを“翻訳”する📡🈂️✨
この章のゴールはひとつだけ😊 外部APIのクセ(形式・失敗・遅い・たまに落ちる)を、アプリの中心(Domain/Application)に持ち込まないで、ちゃんと扱えるようになることだよ〜!💪🧁
0. まず結論:外部APIは“別の国の言葉”🗺️🗣️
外部APIって、だいたいこう👇
- 返ってくるJSONの形が自分のドメインと合わない(snake_case/謎のnull/文字列日付…)😵💫
- 失敗の種類が多すぎ(タイムアウト/429/5xx/ネットワーク断/JSON壊れてる)💥
- “たまに遅い”が一番やっかい(待ってるだけで詰む)🐢💤
- 仕様がいつのまにか変わる(フィールドが増える/減る)🔧🌀
だから、やるべきことはこれ👇
✅ Infrastructure層で「翻訳(Translation)」して ✅ Application/Domainには「いつも同じ形・いつも同じ失敗の表現」で渡す🎁
1. 本日時点の“最新前提”メモ🧭✨(設計判断に効くやつ)
- Node.js はリリースラインが進んでいて、v24 が Active LTS、v22 は Maintenance LTS という位置づけだよ(2026-01-12 更新)。(Node.js)
- Node には 組み込みの
fetch()があって、内部は undici ベース(Node v18 以降で組み込み、fetchは Node 21 で stable 扱いの告知)。(GitHub) fetch()は放っておくと待ち続けることがあるので、タイムアウトは自分で付ける設計が必須(AbortController /AbortSignal.timeout()が定番)。(Node.js)- TypeScript は npm 上の latest が **5.9.3(2025-09-30 公開)**で、6.0/7.0 は“橋渡し〜ネイティブ化”の大きい流れが進行中(早期2026をターゲットの話)。(npm)
この章の設計は、上の前提(特に 「fetch はある」「タイムアウトは自前」)に乗っかっていくよ〜☺️🧋
2. この章で作るもの(完成イメージ)🎁✨
例として「読書ログ」っぽい題材にするね📚 外部の書誌API(例:ISBN検索)から情報を取ってきて、アプリ内はこういう形で扱えるようにする!
- 外部:
{ title: "...", publish_date: "...", authors: [...] }みたいな雑多JSON - 内部:
BookMeta { title: BookTitle, authors: AuthorName[], publishedAt?: YYYYMMDD }みたいにアプリ都合で整えた形💎
そして失敗も揃える👇
- 外部:タイムアウト・429・5xx・ネット断・JSON壊れ…
- 内部:
ExternalServiceUnavailable/ExternalRateLimited/ExternalContractBrokenみたいに分類して返す🧩
3. 章の核🔥:翻訳レイヤ(ACLっぽい考え)🈂️🧱

DDD でいう ACL(Anti-Corruption Layer) 的な発想ね😊 (名前は覚えなくてOK!やることが大事!)
やることは3つだけ🎀
- **外部DTO(外の形)**を受け取る📦
- 自分の形に変換する🧁
- 失敗も自分の失敗に変換する⚠️
4. 実装の“型”🧩(これ覚えたら勝ち✨)
外部API連携は毎回ほぼ同じ型になるよ〜!
4.1 まず Application に Port(interface)を置く🔌
Application は「外部にこういう機能が欲しい」を抽象(interface)で表現する(第12章の続きだね)😊
例:BookCatalogPort(外部の本情報が欲しい)
// src/application/ports/BookCatalogPort.ts
import { Isbn } from "../../domain/valueobjects/Isbn";
import { BookMeta } from "../dto/BookMeta";
export interface BookCatalogPort {
findByIsbn(isbn: Isbn): Promise<BookMeta | null>;
}
✅ Domain/Application は 外部URLもAPIキーもHTTPも知らない 🙈✨ ここが超重要!
4.2 次に Infrastructure に Adapter(実装)を書く🛠️
Infrastructure は「具体的に fetch して、翻訳して返す」をやる。
ざっくり構成はこう👇
HttpClient(fetchラッパ:timeout/retry/エラー整形)External DTO(外のJSON型)Mapper(外→内変換)Adapter(Port実装)
5. “事故らない”ための必須ルール5つ🚧✨
ルール①:タイムアウトは絶対つける⏱️
fetch() はタイムアウトを勝手にしてくれない前提で設計するよ〜😇
(だから AbortSignal/AbortController を使う)(Tasuke Hub)
Node では AbortSignal.timeout(ms) が使える(Node 16.14+ / 17.3+ で追加されてるよ)。(Node.js)
ルール②:リトライは“選ぶ”🔁
なんでもリトライすると、逆に迷惑&悪化するの🥲
✅ リトライしやすい例
- ネットワーク断っぽい
- 429(レート制限)
- 5xx(サーバ側の一時障害)
- 408/504(タイムアウト系)
⛔ リトライしない例
- 400(こっちの入力が悪い)
- 401/403(認証・権限ミス)
- 404(存在しない)※ただし仕様次第
429 は Retry-After があれば尊重するのが定番だよ〜📮(Akeneo API Documentation)
そして Retry-After が無いこともあるので、指数バックオフ+ジッターがよく勧められるよ〜🎲(Doceboヘルプセンター)
ルール③:外部JSONは信用しない(最低限チェック)🛡️
「型があるから安全」じゃないよ〜!🥺
外部は any の世界…!
最低限だけでも👇
- 必須フィールドがある?
- 型が想定通り?
- 文字列日付が壊れてない?
ルール④:外部都合のフィールド名を内側に持ち込まない🙅♀️
publish_date とか author_name とか、そのままDomainに入れたら負け😵💫
Infrastructure で変換して publishedAt とかに整えて渡す🎁
ルール⑤:秘密(APIキー等)はログにもコードにも残さない🔐
- 例:
Authorization/x-api-keyはログに出さない🙊 .envや環境変数に置く(そして.gitignore)🧹
6. 実装してみよう(サンプル)🧪✨
6.1 Application 側:DTO(内側の形)📦
// src/application/dto/BookMeta.ts
export type BookMeta = {
title: string; // 本当は BookTitle VO とかにしてもOK😊
authors: string[];
publishedAt?: string; // "YYYY-MM-DD" みたいな形に寄せる
source: "openlibrary"; // どこ由来か残すと便利✨
};
6.2 Infrastructure:HTTPクライアント(timeout + retry)🌊🛟
(A) エラー型を用意(内側に渡すための“分類”)🧩
// src/infrastructure/http/ExternalApiError.ts
export type ExternalApiErrorKind =
| "Timeout"
| "Network"
| "RateLimited"
| "UpstreamBadResponse"
| "ContractBroken"
| "Unauthorized"
| "Forbidden"
| "NotFound"
| "BadRequest"
| "Unknown";
export class ExternalApiError extends Error {
constructor(
public readonly kind: ExternalApiErrorKind,
message: string,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = "ExternalApiError";
}
}
(B) スリープ&バックオフ(ジッター付き)🎲
// src/infrastructure/http/retry.ts
export const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
export const computeBackoffMs = (attempt: number, baseMs = 300, capMs = 5_000) => {
// attempt: 1,2,3...
const raw = Math.min(capMs, baseMs * Math.pow(2, attempt - 1));
// jitter: 0.5x〜1.0x くらい(同期リトライ地獄を避ける)🎲
const jitter = 0.5 + Math.random() * 0.5;
return Math.floor(raw * jitter);
};
export const parseRetryAfterMs = (value: string | null): number | null => {
if (!value) return null;
// Retry-After は秒数 or HTTP-date のことがある📮
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const dateMs = Date.parse(value);
if (!Number.isNaN(dateMs)) {
const diff = dateMs - Date.now();
return diff > 0 ? diff : 0;
}
return null;
};
(C) fetch JSON:タイムアウト+リトライ付き✨
Node には組み込み fetch() があり(undici ベース)、安定運用のためにタイムアウトを設計として付けるよ〜。(GitHub)
// src/infrastructure/http/FetchJsonClient.ts
import { ExternalApiError } from "./ExternalApiError";
import { computeBackoffMs, parseRetryAfterMs, sleep } from "./retry";
type FetchJsonClientOptions = {
timeoutMs: number;
maxAttempts: number; // 例:3
userAgent?: string;
};
export class FetchJsonClient {
constructor(private readonly opts: FetchJsonClientOptions) {}
async getJson<T>(url: string, headers: Record<string, string> = {}): Promise<T> {
const { timeoutMs, maxAttempts, userAgent } = this.opts;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(url, {
method: "GET",
headers: {
...(userAgent ? { "user-agent": userAgent } : {}),
...headers,
},
// Node/ブラウザで広く使えるタイムアウトの付け方✨
signal: AbortSignal.timeout(timeoutMs),
});
if (res.status === 429) {
const retryAfter = parseRetryAfterMs(res.headers.get("retry-after"));
throw new ExternalApiError("RateLimited", "Rate limited (429)", {
retryAfterMs: retryAfter ?? undefined,
status: res.status,
});
}
if (res.status === 401) throw new ExternalApiError("Unauthorized", "Unauthorized (401)", { status: 401 });
if (res.status === 403) throw new ExternalApiError("Forbidden", "Forbidden (403)", { status: 403 });
if (res.status === 404) throw new ExternalApiError("NotFound", "Not Found (404)", { status: 404 });
if (res.status === 400) throw new ExternalApiError("BadRequest", "Bad Request (400)", { status: 400 });
if (!res.ok) {
// 5xx / 408 / 504 などは “一時的” の可能性がある
throw new ExternalApiError("UpstreamBadResponse", `Upstream error (${res.status})`, {
status: res.status,
});
}
// JSONが壊れてる可能性もある😇
try {
return (await res.json()) as T;
} catch {
throw new ExternalApiError("ContractBroken", "Response is not valid JSON", {
status: res.status,
});
}
} catch (e) {
const err = e as any;
// タイムアウト/中断系(環境で name が違うことがあるので両対応)
if (err?.name === "AbortError" || err?.name === "TimeoutError") {
if (attempt === maxAttempts) throw new ExternalApiError("Timeout", "Request timeout", { url });
await sleep(computeBackoffMs(attempt));
continue;
}
// 自分で投げた ExternalApiError
if (err instanceof ExternalApiError) {
if (err.kind === "RateLimited") {
// Retry-After を優先📮(無いならバックオフ)
const retryAfterMs = Number((err.details as any)?.retryAfterMs);
const waitMs = Number.isFinite(retryAfterMs) ? retryAfterMs : computeBackoffMs(attempt);
if (attempt === maxAttempts) throw err;
await sleep(waitMs);
continue;
}
if (err.kind === "UpstreamBadResponse") {
// 5xx等は数回だけ粘る
if (attempt === maxAttempts) throw err;
await sleep(computeBackoffMs(attempt));
continue;
}
// 401/403/400/404 は基本リトライしない
throw err;
}
// fetch のネットワーク系は TypeError になることが多い
if (attempt === maxAttempts) {
throw new ExternalApiError("Network", "Network error", { url });
}
await sleep(computeBackoffMs(attempt));
}
}
throw new ExternalApiError("Unknown", "Unexpected fallthrough");
}
}
✅ 429 の
Retry-Afterは尊重するのが一般的だよ〜📮(Akeneo API Documentation) ✅Retry-Afterが無い場合に備えて、指数バックオフ+ジッターがよく推奨されるよ〜🎲(Doceboヘルプセンター) ✅AbortSignal.timeout()は仕様として用意されてるやつだから、タイムアウト実装がスッキリするよ〜⏱️(Node.js)
6.3 Infrastructure:外部DTO → 内部DTOの翻訳(Mapper)🈂️✨
外部がこう返す(仮):
title: stringauthor_name?: string[]first_publish_year?: number
翻訳して BookMeta にするよ〜!
// src/infrastructure/book/OpenLibraryDtos.ts
export type OpenLibrarySearchResponse = {
docs?: Array<{
title?: unknown;
author_name?: unknown;
first_publish_year?: unknown;
}>;
};
最低限のガード(ざっくりでOK!):
// src/infrastructure/book/openLibraryMapper.ts
import { BookMeta } from "../../application/dto/BookMeta";
import { ExternalApiError } from "../http/ExternalApiError";
import { OpenLibrarySearchResponse } from "./OpenLibraryDtos";
const isString = (v: unknown): v is string => typeof v === "string";
const isStringArray = (v: unknown): v is string[] =>
Array.isArray(v) && v.every(isString);
export const mapOpenLibraryToBookMeta = (data: OpenLibrarySearchResponse): BookMeta | null => {
const first = data.docs?.[0];
if (!first) return null;
if (!isString(first.title)) {
throw new ExternalApiError("ContractBroken", "Missing or invalid title in OpenLibrary response");
}
const authors = isStringArray(first.author_name) ? first.author_name : [];
const publishedAt =
typeof first.first_publish_year === "number"
? `${first.first_publish_year}-01-01`
: undefined;
return {
title: first.title,
authors,
publishedAt,
source: "openlibrary",
};
};
💡 ここでのポイントは「外の
unknownを内側に入れない」だよ〜!🛡️ “雑な外” を “整った内” にするのが翻訳係の仕事っ✨
6.4 Adapter:Port を実装する🔌➡️🛠️
// src/infrastructure/book/OpenLibraryBookCatalogAdapter.ts
import { BookCatalogPort } from "../../application/ports/BookCatalogPort";
import { Isbn } from "../../domain/valueobjects/Isbn";
import { BookMeta } from "../../application/dto/BookMeta";
import { FetchJsonClient } from "../http/FetchJsonClient";
import { OpenLibrarySearchResponse } from "./OpenLibraryDtos";
import { mapOpenLibraryToBookMeta } from "./openLibraryMapper";
export class OpenLibraryBookCatalogAdapter implements BookCatalogPort {
constructor(private readonly client: FetchJsonClient) {}
async findByIsbn(isbn: Isbn): Promise<BookMeta | null> {
// APIのURL組み立ても Infrastructure の責務✨
const url = `https://openlibrary.org/search.json?isbn=${encodeURIComponent(isbn.value)}`;
const json = await this.client.getJson<OpenLibrarySearchResponse>(url);
return mapOpenLibraryToBookMeta(json);
}
}
7. 演習パート🧩🎮(手を動かすと定着するよ〜!)
演習1:失敗ケースを“内側の言葉”に分類しよう🗂️
次のケースが来たら、ExternalApiErrorKind をどれにする?☺️
- JSONパースに失敗した
- 429 で
Retry-After: 2がある - 503 が返ってきた
- 401 が返ってきた
- ネットワーク断っぽい(fetchが例外)
✅ 答えの目安
- JSONパース失敗 →
ContractBroken - 429 →
RateLimited(Retry-After尊重📮)(Akeneo API Documentation) - 503 →
UpstreamBadResponse(数回リトライ候補) - 401 →
Unauthorized(基本リトライしない) - ネット断 →
Network(数回だけリトライ候補)
演習2:リトライ回数と待ち時間を“体感”しよう⏱️🎲
maxAttempts=3 のとき、computeBackoffMs() がどんな値を出すかログで見てみてね😊
(ジッターで毎回変わるのが正常だよ〜)
8. AI活用コーナー🤖💡(この章はAIが超相性いい!)
8.1 外部APIの“失敗パターン洗い出し”🔎
プロンプト例👇
- 「この外部API連携で起きうる失敗を、ネットワーク/HTTP/データ形式/仕様変更で分類してリスト化して」
- 「429 のとき Retry-After が無いケースも含めて、推奨リトライ方針を3段階(弱/中/強)で提案して」(Doceboヘルプセンター)
8.2 Mapperの安全性レビュー🛡️
- 「この mapper は外部JSONの揺れに強い?危ない箇所と修正案を出して」
- 「unknown → 内部型の変換で、落とし穴を指摘して」
9. この章のチェック✅🌸(できたら勝ち!)
- 外部APIの JSON を Domain/Application にそのまま入れてない🙈
- timeout を必ず付けてる⏱️(
AbortSignal.timeoutなど)(Node.js) - リトライは“選んでる”(429/5xx などだけ)🔁
- 429 で
Retry-Afterを見て待てる📮(Akeneo API Documentation) - 外部の失敗を、内側の失敗に分類して返せる🧩
おまけ:よくある“やりがち事故”😭➡️😊
- ❌ Application/Domain で
fetch()しちゃう(境界崩壊💥) - ❌ 外部の
snake_caseをそのままDomainに持ち込む(あとで地獄)🔥 - ❌ 429 を見ても即リトライ連打(BANされがち🥲)
- ❌ タイムアウト無し(ハングして止まる🐢)(ScrapingBee)
次の章(第15章)では、この Adapter を どこで組み立てるか(Composition Root) を気持ちよく決めて、「new が散らばる地獄」から卒業するよ〜🏗️🎉