第28章 外部API境界(ACLの入門):外の歪みを中に入れない📡🧼🛡️

外部APIって、便利なんだけど……だいたい“クセ”が強いです😵💫
- 命名が
snake_caseだったり🐍 - 数字が文字列で来たり
"1200"とか😇 - 単位が違ったり(円?銭?秒?ミリ秒?)💸⏱️
- 仕様にない値が急に混ざったり(新しいステータス追加!)🚨
そこで登場するのが ACL(Anti-Corruption Layer) です✨ ざっくり言うと 「外部のクセを“翻訳”して、ドメイン(自分の中心)に入れない防波堤🌊🏰」 です!
この章でできるようになること🎯✨
- 外部APIのレスポンスを unknown扱い から安全に取り込める🙂🛡️
- スキーマ検証 → 正規化 → 翻訳(マッピング) の流れを作れる🔄✨
- 外部の変化(項目追加/仕様変更)を ACL内に閉じ込めて 影響範囲を最小化できる✂️🏰
- エラーを「外→中」で扱いやすい形にして返せる🧯🫶
ACLってどこに置くの?(超ざっくり地図)🗺️✨
イメージはこんな感じ👇
-
外部API 🌍 ↓(ネットワーク・不確実・壊れやすい)
-
ACL(翻訳所)🧑💼📘
- 受信: unknown → スキーマ検証
- 正規化: 単位/型/命名のゆれを整える
- 翻訳: ドメイン型へ変換(VO/Entity) ↓(ここから先は信じる)
-
ドメイン 🏰💎
この「翻訳所」に 外部の歪みを全部押し込める のが勝ち筋です😆🛡️
最新ツール小ネタ(2026年1月時点)🧰✨
- TypeScript は 5.9 系が安定版として提供され、5.9 のアナウンスも公式に出ています。(Microsoft for Developers)
- さらに TypeScript チームは ネイティブ版(
@typescript/native-preview) のプレビューを出していて、VS Code拡張も案内されています。(Microsoft for Developers) - Node.js は Active LTS / Maintenance LTS の考え方が明記されていて、運用はその範囲が推奨です。(nodejs.org)
- スキーマ検証ライブラリは、たとえば Zod 4 が stable として案内されています(Zod 4系は継続的に更新)。(Zod)
- ほかに Valibot みたいな軽量系もあります。(valibot.dev)
この章の例は Zod でいきます(人気・情報多めで学びやすいので)🙂✨
ACLの基本レシピ(まずこれだけ覚えよ🍳✨)
外部APIの取り込みは、毎回この順番にすると安定します👇
- 受け取った生データは unknown 扱い🕵️♀️❓
- スキーマ検証(形・型・必須/任意)📐✅
- 正規化(単位、大小文字、トリム、秒→ms、文字列→数値…)🧼✨
- 翻訳(マッピング):外部DTO → ドメインVO/Entity 🏰💎
- エラー変換:外のエラー → 中のエラー(層に合わせる)🧯🔄
これで「外が荒れても、中は平和」になります🕊️✨
例題:決済プロバイダAPIの“クセ”を封じ込める💳😵💫➡️🛡️
外部APIが返してくるレスポンス(例)📨
- 金額が文字列
"1200" - 時刻が 秒
- 命名が
snake_case - ステータスが謎の文字列(将来増えるかも)
こんな JSON が来るとします👇
{
"payment_id": "pay_9f8a",
"amount": "1200",
"currency": "jpy",
"status": "paid",
"created_at": 1737000000,
"customer_email": " Alice@Example.com "
}
実装の置き場所(おすすめ構成)📁✨
src/
domain/
money.ts
email.ts
payment.ts
acl/
payfriend/
schema.ts
normalize.ts
mapper.ts
client.ts
errors.ts
boundary/
checkout.ts
ポイントは acl/payfriend の中だけで外部の歪みを完結 させること🙂🏰
1) ドメイン側(きれいな世界)🏰💎
Money(例:マイナス禁止)💰🚫
// src/domain/money.ts
export type Currency = "JPY" | "USD";
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: Currency
) {}
static create(amount: number, currency: Currency): Money {
if (!Number.isFinite(amount)) throw new Error("Money.amount must be finite");
if (amount < 0) throw new Error("Money.amount must be >= 0");
return new Money(amount, currency);
}
}
Email(正規化込み:trim + 小文字化)📧🧼
// src/domain/email.ts
export class Email {
private constructor(public readonly value: string) {}
static create(raw: string): Email {
const normalized = raw.trim().toLowerCase();
if (!normalized.includes("@")) throw new Error("Invalid email");
return new Email(normalized);
}
}
PaymentStatus(外部の値をそのまま使わない)🚦✨
// src/domain/payment.ts
import { Money } from "./money";
import { Email } from "./email";
export type PaymentStatus = "Pending" | "Succeeded" | "Failed" | "Refunded";
export class Payment {
private constructor(
public readonly id: string,
public readonly money: Money,
public readonly status: PaymentStatus,
public readonly createdAt: Date,
public readonly customerEmail: Email
) {}
static create(args: {
id: string;
money: Money;
status: PaymentStatus;
createdAt: Date;
customerEmail: Email;
}): Payment {
// ここにドメイン不変条件を追加していく
return new Payment(
args.id,
args.money,
args.status,
args.createdAt,
args.customerEmail
);
}
}
ドメインは “自分ルールが通る世界” にしてOKです🙂💎
2) ACL側:スキーマ検証(Zod)📐✅
// src/acl/payfriend/schema.ts
import { z } from "zod";
export const PayfriendPaymentSchema = z.object({
payment_id: z.string(),
amount: z.union([z.string(), z.number()]), // 文字列で来るかも!
currency: z.string(),
status: z.string(),
created_at: z.number(), // 秒で来る想定
customer_email: z.string()
});
export type PayfriendPaymentDTO = z.infer<typeof PayfriendPaymentSchema>;
ここが超大事👇 外部の型は “ドメインに入れる前の一時型”。永住させない😤🏠🚫
3) ACL側:正規化(汚れを落とす🧼✨)
// src/acl/payfriend/normalize.ts
import type { PayfriendPaymentDTO } from "./schema";
export type NormalizedPayfriendPayment = {
paymentId: string;
amount: number; // numberへ
currency: string; // 大文字へ
status: string; // そのまま(次で翻訳)
createdAtMs: number; // msへ
customerEmail: string; // trim + lower はドメインEmailでもやるけど、ここでもOK
};
export function normalizePayfriend(dto: PayfriendPaymentDTO): NormalizedPayfriendPayment {
const amount =
typeof dto.amount === "string" ? Number(dto.amount) : dto.amount;
return {
paymentId: dto.payment_id,
amount,
currency: dto.currency.trim().toUpperCase(),
status: dto.status.trim().toLowerCase(),
createdAtMs: dto.created_at * 1000,
customerEmail: dto.customer_email
};
}
“秒→ms” とか “文字列→数値” とか、こういう事故ポイントは ACLが全部背負う💪😆
4) ACL側:翻訳(外→中のマッピング)🧑💼📘➡️🏰
// src/acl/payfriend/mapper.ts
import { Money, type Currency } from "../../domain/money";
import { Email } from "../../domain/email";
import { Payment, type PaymentStatus } from "../../domain/payment";
import type { NormalizedPayfriendPayment } from "./normalize";
function mapCurrency(cur: string): Currency {
if (cur === "JPY" || cur === "USD") return cur;
throw new Error(`Unsupported currency from provider: ${cur}`);
}
function mapStatus(st: string): PaymentStatus {
// 外部の paid / pending / failed ... をドメイン語彙に翻訳する✨
switch (st) {
case "paid":
case "succeeded":
return "Succeeded";
case "pending":
return "Pending";
case "failed":
return "Failed";
case "refunded":
return "Refunded";
default:
// ここが“外部の仕様追加”に強くなるポイント💡
throw new Error(`Unknown payment status from provider: ${st}`);
}
}
export function toDomainPayment(n: NormalizedPayfriendPayment): Payment {
const money = Money.create(n.amount, mapCurrency(n.currency));
const email = Email.create(n.customerEmail);
const createdAt = new Date(n.createdAtMs);
return Payment.create({
id: n.paymentId,
money,
status: mapStatus(n.status),
createdAt,
customerEmail: email
});
}
✅ 外部の値(status/currency)をドメインに直通させない ✅ 翻訳できない値は“ここで落とす”(ログ/監視しやすい)
これで「変な値が内部を汚染する事故」が激減します🥹🛡️
5) ACL側:HTTPクライアント(fetch + タイムアウト)📡⏱️
外部APIは 遅い/落ちる/たまに壊れる が前提😇 最低限、タイムアウトは付けよ〜!って感じです🙂
// src/acl/payfriend/client.ts
import { PayfriendPaymentSchema } from "./schema";
import { normalizePayfriend } from "./normalize";
import { toDomainPayment } from "./mapper";
import { ExternalApiError, ExternalSchemaError, ExternalMappingError } from "./errors";
import type { Payment } from "../../domain/payment";
export async function fetchPayment(paymentId: string): Promise<Payment> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8_000);
try {
const res = await fetch(`https://api.payfriend.example/payments/${paymentId}`, {
signal: controller.signal,
headers: { "Accept": "application/json" }
});
if (!res.ok) {
throw new ExternalApiError(`HTTP ${res.status}`);
}
const raw: unknown = await res.json();
const parsed = PayfriendPaymentSchema.safeParse(raw);
if (!parsed.success) {
throw new ExternalSchemaError(parsed.error);
}
try {
const normalized = normalizePayfriend(parsed.data);
return toDomainPayment(normalized);
} catch (e) {
throw new ExternalMappingError(e);
}
} catch (e) {
// AbortError もここに来る
if (e instanceof ExternalApiError || e instanceof ExternalSchemaError || e instanceof ExternalMappingError) {
throw e;
}
throw new ExternalApiError("Network/Timeout", e);
} finally {
clearTimeout(timeoutId);
}
}
6) ACL側:エラー型(“どこで壊れたか”が一瞬で分かる🧯✨)
// src/acl/payfriend/errors.ts
import type { ZodError } from "zod";
export class ExternalApiError extends Error {
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = "ExternalApiError";
}
}
export class ExternalSchemaError extends Error {
constructor(public readonly zodError: ZodError) {
super("External response schema mismatch");
this.name = "ExternalSchemaError";
}
}
export class ExternalMappingError extends Error {
constructor(public readonly cause: unknown) {
super("External response cannot be mapped to domain");
this.name = "ExternalMappingError";
}
}
これをやると、運用で
- 「通信が死んだ」📡💥
- 「外部レスポンスの形が変わった」📨🔧
- 「値はあるけど翻訳できない(未知ステータス等)」🌀 がすぐ切り分けできます😆✨
7) 境界(Boundary)では“層に合わせて”返す🌉🧭
たとえばAPIハンドラ側では👇
ExternalSchemaError→ 502(相手の仕様変更っぽい)ExternalApiError→ 504(タイムアウト)ExternalMappingError→ 502(未知の値が来た)
みたいに変換すると、ユーザーにも優しいし運用もしやすいです🫶✨
よくある外部APIの“歪み”チェックリスト✅😵💫
ACLに入れる前に、これを眺めると事故が減ります👇
- 数字が
"123"で来る?🔢➡️🧾 - 日付が 秒/ミリ秒/ISO文字列?⏱️📅
- 通貨が小文字?空白混入?💴🧼
- enum(status)が増える可能性ある?🚦➕
- optional が突然 null になったりしない?🤹♀️
- 配列が空/重複/上限超えの時どうする?📦🚫
- 仕様にないキーが増える?(無視してOK?)🗝️➕
ミニ課題(この章のゴール🎯✨)📝😊
課題1:未知ステータス耐性を上げる🚦🛡️
mapStatus() の default で落とす代わりに、次のどっちかにしてみてね👇
- A)
Resultで返して、境界で 502 にする - B)
Unknownステータスをドメインに入れず、ACL内でログして落とす(ドメインは絶対に汚さない)
課題2:単位変換を1つ追加💱✨
例:amount が「銭(cents)」で来る想定にして、Money には「円」で入るように変換!
課題3:テスト(ゴールデンデータ方式)🧪✨
上の JSON を fixtures として置いて、
- 正常系(paid)
- 異常系(status="mystery")
- 異常系(amount="not-a-number") を3本テストにしてみよ🙂🎉
AI活用(この章向けプロンプト集🤖✨)
- 「この外部APIレスポンスで起きがちな変換ミスを20個出して」🧠
- 「status の未知値が来たときの設計案を3パターン提案して。メリデメ付きで」⚖️
- 「この mapper のテストケース(境界値含む)を列挙して」🧪
- 「“単位ズレ”の例を10個作って、正規化関数の仕様に落として」📏
まとめ:ACLは“翻訳所”。ドメインを守る最強の壁🏰🛡️✨
- 外部APIは変わる、壊れる、クセがある😇
- だから ACLで unknown → 検証 → 正規化 → 翻訳 を徹底する🔄✨
- ドメインには 自分の言葉(型)だけ を入れる💎🙂
次の章(テスト設計🧪😍)に繋がる最高の準備になりました〜!🎉✨