Claude Codeで作る本番通知システム: 既読、冪等性、リトライまで
Claude Codeで本番向け通知システムを設計。DB、API、React、冪等性、再送、テストまで解説。
通知は「ベルを出すだけ」の機能ではありません。 本番で壊れるのは、表示ではなく、重複配信、未読数のずれ、メール連打、リトライ不能、個人情報の出し過ぎです。 この記事では Claude Code に任せても破綻しにくい通知システムを、DB、API、React、テストまでそのまま写せる形で作ります。
Claude Code はコードを書く速度を上げてくれますが、通知の要件を曖昧に渡すと「とりあえず toast を出す」だけの実装になりがちです。ここで扱う通知システムは、アプリ内通知を中心に置き、メールやプッシュ通知を境界の外側として扱います。つまり、まず DB に確実に記録し、未読状態を管理し、必要な場合だけ外部チャネルに流す設計です。
公式情報として、ブラウザ通知は MDN の Notifications API、API ルートは Next.js Route Handlers、DDL は PostgreSQL CREATE TABLE を確認してください。メールを使う場合は Resend の Next.js ガイド のような公式ドキュメントを見ながら、この記事のキューから先に接続します。Claude Code 自体の基本は Claude Code 公式ドキュメント にまとまっています。
全体設計: 通知の責務を分ける
通知システムは、次の4層に分けると運用しやすくなります。
| 層 | 役割 | 失敗しやすい点 |
|---|---|---|
| イベント | 注文完了、コメント、ジョブ失敗などの発生源 | 同じイベントを何度も送る |
| 通知サービス | DB保存、冪等性、設定、レート制限 | ここを薄くしすぎて整合性が崩れる |
| 配信キュー | email/push/webhook の再送 | 失敗時に通知が消える |
| UI | 通知センター、未読数、ブラウザ通知 | 既読と未読バッジがずれる |
この記事では、メールと push をいきなり主役にしません。理由は単純です。メールは送信コスト、配信停止、苦情対応があり、push はブラウザ権限、Service Worker、端末差分があります。MDN でも Notifications API は HTTPS とユーザー許可が前提で、モバイルではページ内の new Notification() ではなく Service Worker からの永続通知が必要になることがあります。だから、まずアプリ内通知を正にして、外部チャネルは「キューから配送する副作用」にします。
Claude Code には、最初に次のように依頼すると崩れにくくなります。
Next.js App Router と PostgreSQL で通知システムを作る。
アプリ内通知を正とし、email/push は delivery queue に積むだけにする。
要件: unread state, idempotency_key, batching, user preferences, rate limit, retry queue, privacy-safe payload.
DB schema, service, route handler, React notification center, Vitest test を分けて実装して。
専門用語を整理します。冪等性とは「同じ処理を2回実行しても結果が増えない性質」です。通知では、決済 webhook が2回届いても「支払い完了」通知を1件だけにすることを指します。バッチングとは、短時間に発生した通知をまとめることです。例えば「10件のコメント」を1通のメールにまとめ、アプリ内では個別に読めるようにします。リトライキューとは、外部送信に失敗した仕事を再実行できる待ち行列です。
ユースケースを先に決める
実装前に、最低3つのユースケースを文章にします。ここを Claude Code に渡すと、テーブル設計と UI がぶれません。
1つ目は、SaaS の運用アラートです。請求失敗、同期失敗、バックグラウンドジョブの停止などは、アプリ内通知とメールの両方が必要です。ただし、失敗ジョブが1分間に100件出たときに100通送ると迷惑メールになります。重要度 critical だけ即時メール、その他は5分バッチにします。
2つ目は、チームコラボレーションです。コメント、メンション、レビュー依頼は未読管理が重要です。ユーザーは「今見る」「あとで見る」「全部既読にする」を期待します。通知本文にはコメント全文を入れず、短い要約と対象URLだけを入れると、メール転送やログ漏れのリスクを下げられます。
3つ目は、記事運用や商品販売の導線です。ClaudeCodeLab のようなコンテンツサイトでは、公開前チェックの完了、CTAリンク切れ、教材購入後の案内などを通知にできます。収益に関わる通知は、無料PDF、教材一覧、導入相談 への導線を壊さないことが重要です。通知が多すぎると逆に CTA のクリックを邪魔するので、ユーザー設定とレート制限を入れます。
4つ目は、管理者向けの監査通知です。権限変更、APIキー作成、エクスポート実行などは、後から追える必要があります。この種類は削除よりも保持期限を決める方が安全です。個人情報を本文に入れず、監査ログIDへリンクします。
DBスキーマ: 既読、設定、キューを分ける
次の SQL は PostgreSQL 向けです。notifications はアプリ内通知の正本、notification_delivery_queue はメールや push の配送待ち、notification_preferences はユーザー設定、notification_rate_limits は連打防止です。
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE notification_preferences (
user_id text PRIMARY KEY,
in_app_enabled boolean NOT NULL DEFAULT true,
email_enabled boolean NOT NULL DEFAULT false,
push_enabled boolean NOT NULL DEFAULT false,
digest_minutes integer NOT NULL DEFAULT 5,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
event_name text NOT NULL,
title text NOT NULL,
body text NOT NULL,
target_url text,
severity text NOT NULL DEFAULT 'info',
data jsonb NOT NULL DEFAULT '{}'::jsonb,
idempotency_key text,
batch_key text,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX notifications_user_idempotency_unique
ON notifications (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX notifications_user_created_idx
ON notifications (user_id, created_at DESC);
CREATE INDEX notifications_user_unread_idx
ON notifications (user_id, created_at DESC)
WHERE read_at IS NULL;
CREATE TABLE notification_delivery_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id uuid NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
channel text NOT NULL CHECK (channel IN ('email', 'push')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
attempts integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 5,
available_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notification_delivery_queue_pick_idx
ON notification_delivery_queue (status, available_at, created_at);
CREATE TABLE notification_rate_limits (
user_id text NOT NULL,
channel text NOT NULL,
window_start timestamptz NOT NULL,
count integer NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel, window_start)
);
この設計で重要なのは、未読状態を read_at だけで表すことです。is_read と read_at を両方持つと、片方だけ更新されて矛盾します。削除機能を入れる場合も、物理削除ではなく archived_at を追加する方が、配送キューや監査と整合しやすいです。
通知サービス: 冪等性、設定、レート制限、リトライ
次は lib/notification-service.ts の例です。DATABASE_URL が設定された Next.js/Node.js 環境で動きます。メール送信そのものはここでは実行せず、キューに積みます。Resend や SendGrid を使う場合も、このキューを worker が読んで送る形にすると、API リクエスト中に外部障害で落ちにくくなります。
import { Pool, type PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type NotificationSeverity = "info" | "success" | "warning" | "critical";
export type ExternalChannel = "email" | "push";
export type NotificationRow = {
id: string;
user_id: string;
event_name: string;
title: string;
body: string;
target_url: string | null;
severity: NotificationSeverity;
data: Record<string, unknown>;
idempotency_key: string | null;
batch_key: string | null;
read_at: string | null;
created_at: string;
};
type Preferences = {
in_app_enabled: boolean;
email_enabled: boolean;
push_enabled: boolean;
digest_minutes: number;
};
export type CreateNotificationInput = {
userId: string;
eventName: string;
title: string;
body: string;
targetUrl?: string;
severity?: NotificationSeverity;
data?: Record<string, unknown>;
resourceId?: string;
idempotencyKey?: string;
batchKey?: string;
};
export function makeIdempotencyKey(input: {
userId: string;
eventName: string;
resourceId?: string;
}) {
if (!input.resourceId) return null;
return `${input.userId}:${input.eventName}:${input.resourceId}`;
}
export function shouldQueueExternal(input: {
channel: ExternalChannel;
severity: NotificationSeverity;
preferences: Pick<Preferences, "email_enabled" | "push_enabled">;
}) {
if (input.channel === "email" && !input.preferences.email_enabled) return false;
if (input.channel === "push" && !input.preferences.push_enabled) return false;
return input.severity === "warning" || input.severity === "critical";
}
async function ensurePreferences(client: PoolClient, userId: string) {
const result = await client.query<Preferences>(
`
INSERT INTO notification_preferences (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO UPDATE
SET updated_at = notification_preferences.updated_at
RETURNING in_app_enabled, email_enabled, push_enabled, digest_minutes
`,
[userId],
);
return result.rows[0];
}
async function consumeRateLimit(
client: PoolClient,
userId: string,
channel: ExternalChannel,
limitPerMinute: number,
) {
const result = await client.query<{ count: number }>(
`
INSERT INTO notification_rate_limits (user_id, channel, window_start, count)
VALUES ($1, $2, date_trunc('minute', now()), 1)
ON CONFLICT (user_id, channel, window_start)
DO UPDATE SET count = notification_rate_limits.count + 1
RETURNING count
`,
[userId, channel],
);
return result.rows[0].count <= limitPerMinute;
}
async function queueDelivery(
client: PoolClient,
notificationId: string,
channel: ExternalChannel,
availableAt: Date,
) {
await client.query(
`
INSERT INTO notification_delivery_queue (notification_id, channel, available_at)
VALUES ($1, $2, $3)
`,
[notificationId, channel, availableAt],
);
}
export async function createNotification(input: CreateNotificationInput) {
const client = await pool.connect();
const severity = input.severity ?? "info";
const idempotencyKey =
input.idempotencyKey ??
makeIdempotencyKey({
userId: input.userId,
eventName: input.eventName,
resourceId: input.resourceId,
});
try {
await client.query("BEGIN");
const preferences = await ensurePreferences(client, input.userId);
if (!preferences.in_app_enabled) {
await client.query("COMMIT");
return null;
}
const result = await client.query<NotificationRow & { inserted: boolean }>(
`
WITH inserted AS (
INSERT INTO notifications (
user_id, event_name, title, body, target_url, severity,
data, idempotency_key, batch_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
ON CONFLICT (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL
DO NOTHING
RETURNING *, true AS inserted
)
SELECT * FROM inserted
UNION ALL
SELECT *, false AS inserted
FROM notifications
WHERE user_id = $1
AND idempotency_key = $8
AND $8 IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM inserted)
LIMIT 1
`,
[
input.userId,
input.eventName,
input.title,
input.body,
input.targetUrl ?? null,
severity,
JSON.stringify(input.data ?? {}),
idempotencyKey,
input.batchKey ?? null,
],
);
const notification = result.rows[0];
if (!notification) {
throw new Error("Notification insert failed");
}
if (notification.inserted) {
for (const channel of ["email", "push"] as const) {
const allowedByPreference = shouldQueueExternal({
channel,
severity,
preferences,
});
if (!allowedByPreference) continue;
const allowedByRateLimit = await consumeRateLimit(
client,
input.userId,
channel,
channel === "email" ? 5 : 20,
);
if (!allowedByRateLimit) continue;
const delayMs = input.batchKey ? preferences.digest_minutes * 60_000 : 0;
await queueDelivery(
client,
notification.id,
channel,
new Date(Date.now() + delayMs),
);
}
}
await client.query("COMMIT");
return notification;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listNotifications(userId: string, unreadOnly = false, limit = 30) {
const result = await pool.query<NotificationRow>(
`
SELECT *
FROM notifications
WHERE user_id = $1
AND ($2::boolean = false OR read_at IS NULL)
ORDER BY created_at DESC
LIMIT $3
`,
[userId, unreadOnly, Math.min(limit, 100)],
);
return result.rows;
}
export async function markNotificationRead(userId: string, notificationId: string) {
const result = await pool.query<NotificationRow>(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()), updated_at = now()
WHERE user_id = $1 AND id = $2
RETURNING *
`,
[userId, notificationId],
);
return result.rows[0] ?? null;
}
export async function markAllNotificationsRead(userId: string) {
const result = await pool.query(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()), updated_at = now()
WHERE user_id = $1 AND read_at IS NULL
`,
[userId],
);
return result.rowCount ?? 0;
}
export type DeliveryJob = {
id: string;
notification_id: string;
channel: ExternalChannel;
attempts: number;
max_attempts: number;
};
export async function claimNextDeliveryJob() {
const result = await pool.query<DeliveryJob>(
`
UPDATE notification_delivery_queue
SET status = 'sending', attempts = attempts + 1, locked_at = now(), updated_at = now()
WHERE id = (
SELECT id
FROM notification_delivery_queue
WHERE status = 'pending' AND available_at <= now()
ORDER BY available_at ASC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, notification_id, channel, attempts, max_attempts
`,
);
return result.rows[0] ?? null;
}
export async function finishDeliveryJob(job: DeliveryJob, error?: unknown) {
if (!error) {
await pool.query(
"UPDATE notification_delivery_queue SET status = 'sent', updated_at = now() WHERE id = $1",
[job.id],
);
return;
}
const retryDelayMs = Math.min(30 * 60_000, 60_000 * 2 ** job.attempts);
const failedPermanently = job.attempts >= job.max_attempts;
await pool.query(
`
UPDATE notification_delivery_queue
SET status = $2,
available_at = $3,
last_error = $4,
updated_at = now()
WHERE id = $1
`,
[
job.id,
failedPermanently ? "failed" : "pending",
new Date(Date.now() + retryDelayMs),
error instanceof Error ? error.message : String(error),
],
);
}
このサービスでは、同じ idempotency_key が来たときに新しい通知を作りません。外部キューへ積むのも初回挿入時だけです。ここがないと、決済 webhook、GitHub webhook、ジョブ再実行のたびにメールが増えます。
Next.js Route Handler: UIから使うAPI
Next.js App Router では、app/api/notifications/route.ts に HTTP メソッドを export します。実サービスではセッションから userId を取りますが、下の例はコピーして試しやすいように x-demo-user-id ヘッダーを使っています。
import { NextRequest, NextResponse } from "next/server";
import {
createNotification,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "@/lib/notification-service";
export const runtime = "nodejs";
function requireDemoUserId(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) {
throw new Error("Missing x-demo-user-id header. Replace this with real auth.");
}
return userId;
}
export async function GET(request: NextRequest) {
try {
const userId = requireDemoUserId(request);
const unreadOnly = request.nextUrl.searchParams.get("unread") === "1";
const notifications = await listNotifications(userId, unreadOnly);
return NextResponse.json({ notifications });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 401 },
);
}
}
export async function POST(request: NextRequest) {
try {
const userId = requireDemoUserId(request);
const body = await request.json();
const notification = await createNotification({
userId,
eventName: String(body.eventName),
title: String(body.title),
body: String(body.body),
targetUrl: body.targetUrl ? String(body.targetUrl) : undefined,
severity: body.severity,
resourceId: body.resourceId ? String(body.resourceId) : undefined,
batchKey: body.batchKey ? String(body.batchKey) : undefined,
data: typeof body.data === "object" && body.data ? body.data : {},
});
return NextResponse.json({ notification }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 400 },
);
}
}
export async function PATCH(request: NextRequest) {
const userId = requireDemoUserId(request);
const body = await request.json().catch(() => ({}));
if (body.notificationId) {
const notification = await markNotificationRead(userId, String(body.notificationId));
return NextResponse.json({ notification });
}
const updated = await markAllNotificationsRead(userId);
return NextResponse.json({ updated });
}
APIの境界で注意するのは、userId をリクエストボディから信じないことです。通知は他人の未読情報を含むため、必ずセッションや認証ミドルウェアからユーザーを決めます。この記事のデモヘッダーは動作確認用で、本番では置き換えてください。
React通知センター: 未読とブラウザ通知を分ける
UIはアプリ内通知センターを主役にします。ブラウザ通知はユーザーがボタンを押した時だけ許可を求めます。これは MDN が説明している通り、権限要求はユーザー操作に紐づけるべきだからです。
"use client";
import { useEffect, useMemo, useState } from "react";
type AppNotification = {
id: string;
title: string;
body: string;
target_url: string | null;
severity: "info" | "success" | "warning" | "critical";
read_at: string | null;
created_at: string;
};
const demoHeaders = {
"x-demo-user-id": "demo-user-1",
};
export function NotificationCenter() {
const [items, setItems] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [desktopEnabled, setDesktopEnabled] = useState(false);
const unreadCount = useMemo(
() => items.filter((item) => item.read_at === null).length,
[items],
);
async function loadNotifications() {
const response = await fetch("/api/notifications", {
headers: demoHeaders,
cache: "no-store",
});
if (!response.ok) return;
const data = (await response.json()) as { notifications: AppNotification[] };
setItems(data.notifications);
}
async function markRead(notificationId: string) {
await fetch("/api/notifications", {
method: "PATCH",
headers: {
...demoHeaders,
"content-type": "application/json",
},
body: JSON.stringify({ notificationId }),
});
setItems((current) =>
current.map((item) =>
item.id === notificationId
? { ...item, read_at: item.read_at ?? new Date().toISOString() }
: item,
),
);
}
async function markAllRead() {
await fetch("/api/notifications", {
method: "PATCH",
headers: {
...demoHeaders,
"content-type": "application/json",
},
body: JSON.stringify({ all: true }),
});
const now = new Date().toISOString();
setItems((current) => current.map((item) => ({ ...item, read_at: item.read_at ?? now })));
}
async function enableDesktopNotifications() {
if (!("Notification" in window)) return;
const permission = await Notification.requestPermission();
setDesktopEnabled(permission === "granted");
}
useEffect(() => {
loadNotifications();
const timer = window.setInterval(loadNotifications, 30_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!desktopEnabled || document.visibilityState === "visible") return;
const newest = items.find((item) => item.read_at === null);
if (!newest) return;
new Notification(newest.title, {
body: newest.body,
tag: newest.id,
});
}, [desktopEnabled, items]);
return (
<div className="notification-center">
<button type="button" onClick={() => setOpen((value) => !value)}>
Notifications
{unreadCount > 0 ? <span aria-label={`${unreadCount} unread`}>{unreadCount}</span> : null}
</button>
{open ? (
<section aria-label="Notification center">
<div>
<button type="button" onClick={markAllRead} disabled={unreadCount === 0}>
Mark all read
</button>
<button type="button" onClick={enableDesktopNotifications}>
Enable desktop alerts
</button>
</div>
{items.length === 0 ? (
<p>No notifications.</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id} data-unread={item.read_at === null}>
<strong>{item.title}</strong>
<p>{item.body}</p>
<small>{new Date(item.created_at).toLocaleString()}</small>
<div>
{item.target_url ? <a href={item.target_url}>Open</a> : null}
{item.read_at === null ? (
<button type="button" onClick={() => markRead(item.id)}>
Mark read
</button>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
) : null}
</div>
);
}
ここでは30秒ポーリングにしています。WebSocket や Server-Sent Events に変えても構いませんが、最初からリアルタイムに寄せるより、既読更新、未読数、重複防止を先に固めた方が運用で困りません。通知数が多い管理画面では、最初の表示を未読だけにし、履歴は別タブに分けると読みやすくなります。
テスト: Claude Codeに失敗条件を書かせる
通知システムのテストは、成功例より失敗例を重視します。重複イベントが増えないこと、設定が off のチャネルに送らないこと、低重要度の通知をメールにしないことを先に固定します。
import { describe, expect, it } from "vitest";
import { makeIdempotencyKey, shouldQueueExternal } from "./notification-service";
describe("notification policy", () => {
it("builds a stable idempotency key from user, event, and resource", () => {
expect(
makeIdempotencyKey({
userId: "user_1",
eventName: "invoice.payment_failed",
resourceId: "invoice_9",
}),
).toBe("user_1:invoice.payment_failed:invoice_9");
});
it("does not build an idempotency key without a resource id", () => {
expect(
makeIdempotencyKey({
userId: "user_1",
eventName: "system.notice",
}),
).toBeNull();
});
it("queues email only when preferences and severity allow it", () => {
expect(
shouldQueueExternal({
channel: "email",
severity: "warning",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(true);
expect(
shouldQueueExternal({
channel: "email",
severity: "info",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(false);
expect(
shouldQueueExternal({
channel: "push",
severity: "critical",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(false);
});
});
Claude Code には「正常系だけでなく、重複 webhook、メール設定 off、レート制限超過、キュー失敗、他人の通知IDを既読にする攻撃をテストして」と依頼します。セキュリティレビューでは、APIの WHERE user_id = $1 AND id = $2 のような所有者条件を特に見ます。
落とし穴と運用チェック
1つ目の落とし穴は、通知本文に個人情報を入れすぎることです。メールや push は端末ロック画面、メール転送、外部プロバイダのログに出る可能性があります。本文は短い要約にし、詳細はログイン後のページに置きます。
2つ目は、未読数をクライアントだけで計算することです。複数タブ、スマホ、管理画面が混ざるとすぐずれます。DBの read_at IS NULL を正にし、UIは楽観更新しても次回取得でサーバー値に戻します。
3つ目は、レート制限を外部送信だけでなく通知作成にもかけてしまうことです。重要な監査通知まで落ちると危険です。この記事の例ではアプリ内通知を残し、外部チャネルの配送を抑制します。
4つ目は、キューの sending が永久に残ることです。worker が落ちたときのために、locked_at が古い job を pending に戻す監視を入れてください。さらに attempts と max_attempts を見て、永久失敗を管理画面に出します。
5つ目は、Claude Code に「通知機能を作って」とだけ頼むことです。その依頼だと toast と WebSocket だけが出てくる可能性があります。必ずデータモデル、冪等性、設定、レート制限、プライバシー、再送、テストを要件に入れます。
収益導線と次に読む記事
通知システムは、プロダクトの継続率だけでなく収益導線にも効きます。無料PDFの配布、教材購入後のオンボーディング、法人相談のリマインドを、メールだけに頼らずアプリ内通知にも残すと、ユーザーが戻ってきた時に次の行動が明確になります。
Claude Code の開発効率を上げたい場合は、Claude Code生産性Tips と Claude Codeでフォームバリデーションを設計する も合わせて読むと、通知の入力検証とレビュー手順をつなげやすくなります。実装テンプレートや運用チェックリストが必要なら、ClaudeCodeLabの教材一覧 から通知、レビュー、コンテンツ運用に使える資料を確認してください。チーム導入の相談は 導入相談ページ にまとめています。
実際に試した結果
この記事のコードをローカルの Next.js 検証プロジェクトに貼り、PostgreSQL にスキーマを流して、同じ resourceId の invoice.payment_failed を2回 POST しました。結果は notifications が1件、notification_delivery_queue も初回分だけになり、未読数は1のままでした。さらに email 設定を off にするとアプリ内通知だけが残り、warning 以上で設定 on の場合だけキューに入りました。実装で一番効いたのは、UIより先に idempotency_key と read_at を決めたことです。
まとめ
本番の通知システムは、派手なリアルタイム表示よりも、データの正しさが先です。Claude Code に依頼するときは、アプリ内通知を正本にし、email/push はキューに逃がし、未読状態、冪等性、バッチング、設定、レート制限、プライバシー、リトライ、テストを最初から要件化してください。ここまで渡せば、Claude Code は単なる toast ではなく、運用できる通知基盤を作る相棒になります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code Permission Receipt Pattern: 許可、証拠、ロールバックを残す運用
Claude Codeの権限運用を安全にする permission receipt。許可範囲、承認待ち、検証コマンド、CTA導線を記録します。
Claude CodeとCodex、結局どっち?事故らない“併用”の現実解
OpenAIのCodexとClaude Code、どっちが得意でどっちに任せる?両方を安全に併用する作業分担と権限・検証のワークフローを、僕の失敗談つきで解説します。
Claude Codeサブエージェント実装ガイド: 記事・コード作業を安全に並列委譲する方法
Claude Codeサブエージェントで記事・コード作業を安全に並列化する実装ガイド。委譲基準、プロンプト、失敗例を解説。