Advanced (更新: 2026/6/7)

通知システムの設計:メール・プッシュをキューで送り、二重送信を止める

通知システムの裏側を設計する。メール/プッシュ/アプリ内のチャネル分離、配信キュー、テンプレート管理、オプトアウト、冪等性と再送まで、写して動くコードで解説。

通知システムの設計:メール・プッシュをキューで送り、二重送信を止める

リリース当日、僕は同じメールを4通受け取りました。「お支払いが完了しました」を、4回。

原因は決済プロバイダのwebhookがリトライで4回飛んできたこと。僕の通知コードは、来たイベントを素直に4回メール送信していました。自分のテスト用アドレスだったから笑い話で済んだけれど、これが1万人のユーザーだったら、4万通の謝罪案件です。

通知って、画面の右上にベルを出すだけの機能に見えますよね。僕もそう思っていました。でも本番で壊れるのは表示じゃない。重複配信、送信失敗の握りつぶし、退会した人への送信、未読数のずれ——ぜんぶ「裏側」で起きます。

この記事は、その裏側の話だけをします。ボタンを押したらフワッと出るトースト表示の作り方は別レイヤーなので、Reactトースト通知のキュー管理に分けました。ここでは、誰に・どのチャネルで・何回送るかを決める配信基盤を、写して動くコードで組み立てます。

この記事の要点

  • 通知は「イベント発生 → 通知サービス → 配信キュー → 各チャネル」の4段に分けると壊れにくい。メールやプッシュをいきなり主役にしない
  • 重複を止める鍵は冪等キー。同じ出来事には同じキーを振り、DBのユニーク制約で2通目を物理的に作らせない
  • 送信失敗は消さずにキューへ戻して指数バックオフで再送。回数を使い切ったらfailedに倒して人が気づける状態にする
  • ユーザー設定(オプトイン/オプトアウト)とレート制限を通すのは外部チャネルだけ。アプリ内記録は残す
  • テンプレートは本文に個人情報を埋め込まず、要約とリンクだけ。ロック画面や外部ログに漏れる前提で書く

まず全体像:通知を4つの層に割る

最初に設計の地図を見せます。通知システムは、ひとつの大きな関数で書くと必ず破綻します。役割で4層に割るのがコツです。

役割ここでよく事故る
イベント注文完了・コメント・ジョブ失敗などの発生源同じ出来事を何度も投げる
通知サービスDB記録・冪等性・ユーザー設定・レート制限薄く作りすぎて整合性が崩れる
配信キューメール/プッシュ/webhookの送信と再送失敗した送信が消える
チャネルメール・Webプッシュ・アプリ内表示退会者やオフ設定に送ってしまう

ポイントは、メールとプッシュを「キューから配送する副作用」に格下げすることです。なぜそこまで臆病になるのか。

メールには送信コスト、配信停止(オプトアウト)、苦情対応がついて回ります。Webプッシュはブラウザの権限許可、Service Worker、端末ごとの差があって、MDNのNotifications APIでも、HTTPSとユーザー許可が前提だと書かれています。どちらも「APIリクエストの最中に同期で送る」には重すぎる。

だから僕は、まずアプリ内通知をDBに確実に記録します。これが正本。外部チャネルはそこから派生させる。こう決めるだけで、「メールは送れたのにDBに残ってない」みたいな食い違いが消えます。

専門用語を先に1行ずつ崩しておきます。冪等性は「同じ処理を2回やっても結果が増えない性質」。バッチングは「短時間に来た通知をまとめること」(コメント10件を1通に束ねる)。配信キューは「送信に失敗した仕事を後で再実行できる待ち行列」です。

チャネルを分ける:メール・プッシュ・アプリ内

「通知を送る」と一口に言っても、チャネルごとに性格がまるで違います。ここを混ぜて設計すると、後で必ず泣きます。

アプリ内通知は、いちばん素直なチャネルです。DBに1行入れて、ユーザーがアプリを開いたら読む。送信失敗という概念がほぼない。だからこれを正本に据えます。

メールは、届くまでが長い旅です。自分のサーバーを出て、相手のメールサーバーに受け取られ、迷惑メール判定をくぐり、受信箱に着く。途中で何が起きるか分からないので、「送信を試みた」と「届いた」は別物として扱います。実装はClaude CodeでSendGridメール送信を安全に実装するに寄せて、ここではキューに積むところまでをやります。

Webプッシュは、いちばん気難しい。ユーザーが明示的に許可しないと送れないし、許可は端末・ブラウザ単位。さらに購読情報(エンドポイント)は勝手に失効します。失効したエンドポイントに送り続けると、プロバイダから嫌われます。

この性格差を、僕はこう割り切っています。

  • アプリ内:常に記録する(オフにできるのは表示だけ)
  • メール:オプトイン済み かつ 重要度が一定以上のときだけキューへ
  • プッシュ:許可済みの端末があり かつ 重要度が一定以上のときだけキューへ

「全部のチャネルに全部の通知を流す」は親切に見えて、退会の引き金になります。チャネルごとに蛇口の太さを変える、と覚えておいてください。

DBスキーマ:記録・キュー・設定・レート制限を別テーブルに

設計の地図ができたら、テーブルに落とします。次のSQLはPostgreSQL向けです。DDLの細かい構文はPostgreSQLのCREATE TABLEが一次情報です。

4つのテーブルに役割を分けています。notificationsがアプリ内通知の正本、notification_delivery_queueがメール・プッシュの配送待ち、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,    -- 既読は read_at だけで表す
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

-- 同じ出来事を2回作らせない物理的な壁
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という1列だけで表しているところです。is_read(真偽値)とread_at(時刻)を両方持つと、片方だけ更新されてズレます。「未読はread_at IS NULL」と決めれば、迷いません。

削除機能を足したくなったら、物理削除ではなくarchived_at列を増やす方が安全です。配送キューや監査ログから参照が残っていても、外部キーが孤児にならないからです。

通知サービス:冪等性・設定・レート制限・再送を一本化する

ここがこの記事の主役です。lib/notification-service.tsに、通知を作る一連の判断を閉じ込めます。DATABASE_URLが設定されたNode.js環境で動きます。

大事なルールは2つ。メール送信そのものはここでやらない(キューに積むだけ)。そして外部キューへ積むのは初回挿入のときだけ。冒頭の「4通メール事件」は、まさにこの2つを守っていなかったから起きました。

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";

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;
};

// 出来事から決まった冪等キーを作る。対象IDがなければ重複判定しない
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; // アプリ内すらオフなら何もしない
    }

    // 冪等キーが衝突したら新規作成せず、既存の1件を返す
    const result = await client.query<{ id: string; 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 id, true AS inserted
       )
       SELECT id, inserted FROM inserted
       UNION ALL
       SELECT id, 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("通知の挿入に失敗しました");

    // 初回挿入のときだけ外部チャネルへ積む(=2通目以降は送らない)
    if (notification.inserted) {
      for (const channel of ["email", "push"] as const) {
        if (!shouldQueueExternal({ channel, severity, preferences })) continue;

        const withinLimit = await consumeRateLimit(
          client, input.userId, channel,
          channel === "email" ? 5 : 20, // メールは厳しめ、プッシュは緩め
        );
        if (!withinLimit) continue;

        // バッチ対象は digest_minutes 後に送る
        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();
  }
}

このcreateNotificationを通せば、決済webhookが何回飛んでこようと、同じresourceIdなら通知は1件、キューも初回分だけ。冒頭の事件は、この十数行で防げます。

shouldQueueExternalmakeIdempotencyKeyを外に出して純粋関数にしたのは、テストしやすくするためです。DBに繋がなくても、配信ポリシーだけを単体で検証できます。

配信ワーカー:失敗を消さずに再送する

通知をキューに積んだら、別プロセスのワーカーが取り出して送ります。APIリクエストの最中に外部送信しないのは、相手の障害で自分のAPIまで巻き込まれないためです。この発想自体はジョブキュー全般に共通なので、深掘りはジョブキューで二重課金を止めるも合わせてどうぞ。

ワーカーの肝は、取り出しの排他失敗時の戻し方です。

import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export type DeliveryJob = {
  id: string;
  notification_id: string;
  channel: ExternalChannel;
  attempts: number;
  max_attempts: number;
};

// 待ち行列から1件を排他ロックして取り出す
// FOR UPDATE SKIP LOCKED が複数ワーカーの二重取り出しを防ぐ
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;
  }

  // 指数バックオフ:1分→2分→4分…と間隔を空ける(上限30分)
  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", // 上限超過なら failed で止める
      new Date(Date.now() + retryDelayMs),
      error instanceof Error ? error.message : String(error),
    ],
  );
}

FOR UPDATE SKIP LOCKEDは、複数のワーカーを同時に動かしても、同じジョブを二重に掴まないための呪文です。これがないと、ワーカーを2台に増やした瞬間に同じメールが2通飛びます。

失敗の扱いも肝心です。送信に失敗したらstatuspendingに戻し、available_atを未来にずらす。これで「すぐ再送して即また失敗」の無限ループを避けつつ、相手が復旧したら自然に再送されます。max_attemptsを使い切ったらfailedに倒し、握りつぶさずに残す。残骸がfailedで見えていれば、人間が後で気づけます。

配信失敗の最後の砦として、僕は2つの監視を回しています。ひとつは「sendingのままlocked_atが古いジョブ」をpendingに戻す掃除(ワーカーが落ちた取りこぼし対策)。もうひとつは「failedが一定数を超えたらSlackに出す」アラート。再送の仕組みより、詰まりに気づく仕組みのほうが、運用では効きます。

テンプレート管理:本文に個人情報を埋めない

チャネルと配信ができたら、最後に「何を書いて送るか」です。テンプレート管理で僕が痛い目を見たのは、本文に個人情報を埋め込んでしまったことでした。

メールの本文やプッシュの通知文は、思っているより色々な場所に露出します。スマホのロック画面、メールの自動転送、外部送信プロバイダのログ。「○○様の請求額は12,800円です」と書いたら、その金額がロック画面に出ます。

なので僕のテンプレートは、要約とリンクだけにしています。中身はログインした先のページで見せる。テンプレートは出来事の種類ごとに1つ定義して、差し込むのは安全な値(名前ではなくユーザーID、本文ではなく対象URL)に限ります。

type NotificationTemplate = {
  // アプリ内・メール・プッシュで使い回す最小限の素材
  buildTitle: (vars: Record<string, string>) => string;
  buildBody: (vars: Record<string, string>) => string;
  defaultSeverity: NotificationSeverity;
};

// 出来事の種類ごとにテンプレートを1つ持つ
const templates: Record<string, NotificationTemplate> = {
  "invoice.payment_failed": {
    buildTitle: () => "お支払いの確認が必要です",
    // 金額や氏名は入れない。詳細はリンク先で見せる
    buildBody: () => "請求の処理に失敗しました。詳細を確認してください。",
    defaultSeverity: "critical",
  },
  "comment.created": {
    buildTitle: (v) => `${v.actorLabel}さんがコメントしました`,
    buildBody: () => "新しいコメントがあります。",
    defaultSeverity: "info",
  },
};

export function renderNotification(eventName: string, vars: Record<string, string>) {
  const tpl = templates[eventName];
  if (!tpl) throw new Error(`未定義のテンプレート: ${eventName}`);
  return {
    title: tpl.buildTitle(vars),
    body: tpl.buildBody(vars),
    severity: tpl.defaultSeverity,
  };
}

テンプレートを一覧で持っておくと、もうひとつ得があります。チャネルを増やしたとき(たとえばSMSやLINE)、本文を作り直さずに同じ素材を流用できる。文面のレビューも、テンプレート一覧を見れば一括でできます。

ありがちな落とし穴を5つ

僕や周りが実際に踏んだものだけ並べます。

1つ目、冪等キーを「念のため」全イベントに付けないこと。対象IDがない一般通知にまで固定キーを振ると、別物のはずの通知が「重複」と判定されて消えます。makeIdempotencyKeyが対象IDなしでnullを返すのは、これを避けるためです。

2つ目、未読数をクライアントだけで数えないこと。PC・スマホ・管理画面と複数の画面が混ざると一瞬でズレます。read_at IS NULLをサーバーの正にして、画面は楽観更新しても次の取得でサーバー値に戻す。

3つ目、レート制限を通知作成そのものにかけないこと。連打を止めたいのは外部送信であって、記録ではありません。重要な監査通知まで上限で落ちると、後から追えなくなります。この記事の例はアプリ内記録を残し、外部配送だけ絞っています。

4つ目、オプトアウトを「メール文末のリンク」だけで済ませないこと。設定をDBのnotification_preferencesで一元管理し、配信判断は必ずそこを見る。リンクを踏んだ事実とDBの状態が食い違うと、解除したのに届く、という最悪のクレームになります。

5つ目、「通知機能を作って」とだけ依頼しないこと。Claude Codeにこの一言を渡すと、トーストとWebSocketだけが出てきがちです。データモデル・冪等性・設定・レート制限・プライバシー・再送・テストを要件に書いて初めて、運用できる配信基盤になります。

コピペで動く:冪等と再送ロジックの最小確認

DBやネットワークなしで、設計のキモだけを手元で確かめられる検証コードを置きます。node verify.mjsで動きます。冪等キーで2通目が止まること、失敗が指数バックオフで再送に回ることを目で確認できます。

// verify.mjs — 依存なし。通知の重複防止と再送ロジックを検証する
// 冪等キー:同じ出来事は同じキーになる(対象IDがなければ null)
function makeIdempotencyKey({ userId, eventName, resourceId }) {
  if (!resourceId) return null;
  return `${userId}:${eventName}:${resourceId}`;
}

// 通知サービスをメモリで模擬:冪等キーが既出なら作らない
function createNotificationStore() {
  const seen = new Set();
  const queue = [];
  return {
    create({ userId, eventName, resourceId, severity = "info" }) {
      const key = makeIdempotencyKey({ userId, eventName, resourceId });
      if (key && seen.has(key)) return { created: false, queued: 0 };
      if (key) seen.add(key);
      // warning 以上だけ外部キューへ積む
      const queued = severity === "warning" || severity === "critical" ? 1 : 0;
      if (queued) queue.push({ key, attempts: 0 });
      return { created: true, queued };
    },
    queueLength: () => queue.length,
  };
}

// 再送の間隔:1分→2分→4分…上限30分
function retryDelayMs(attempts) {
  return Math.min(30 * 60_000, 60_000 * 2 ** attempts);
}

const store = createNotificationStore();

// 決済 webhook が4回飛んできた想定(冒頭の事件)
const event = { userId: "u1", eventName: "invoice.payment_failed",
  resourceId: "inv_9", severity: "critical" };
const results = [0, 1, 2, 3].map(() => store.create(event));

console.log("作成された通知数:", results.filter((r) => r.created).length); // 1
console.log("キューに積まれた数:", store.queueLength());                   // 1

// 対象IDなしの一般通知は冪等判定しない=毎回作られる
const generic = { userId: "u1", eventName: "system.notice", severity: "info" };
console.log("一般通知2回:",
  [store.create(generic), store.create(generic)].filter((r) => r.created).length); // 2

// 再送間隔の確認(分単位)
console.log("再送間隔(分):",
  [0, 1, 2, 3, 10].map((a) => retryDelayMs(a) / 60_000)); // [1, 2, 4, 8, 30]

実行するとこうなります。

作成された通知数: 1
キューに積まれた数: 1
一般通知2回: 2
再送間隔(分): [ 1, 2, 4, 8, 30 ]

webhookが4回来ても通知は1件、キューも1件。一般通知はちゃんと2回作られる。再送は1分から始まって30分で頭打ち。本番のコードはこのロジックをPostgreSQLのユニーク制約とavailable_atに置き換えただけで、考え方は同じです。

よくある質問

Q. メールとプッシュ、最初から両方作るべき? いいえ。まずアプリ内通知をDBに記録するところだけ作って、配信キューの枠を用意しておけば十分です。チャネルはchannel列に値を足すだけで増やせる設計にしてあるので、メールは需要が出てから、プッシュはさらに後で足せます。

Q. リアルタイム配信(WebSocketやSSE)は必要? 急ぎません。この記事のReactセンターは30秒ポーリングで始めて問題ないです。既読・未読数・重複防止を先に固めてから、体感速度が要るところだけリアルタイムに寄せる方が、運用で困りません。表示側の作り込みはReactトースト通知のキュー管理が詳しいです。

Q. 冪等キーには何を使えばいい? 「ユーザーID+出来事の種類+対象の一意ID」を連結するのが素直です。決済なら請求書ID、コメントならコメントID。送信側がリトライしても同じ値になるものを選ぶのがコツで、タイムスタンプやランダム値は使いません。

Q. 退会・オプトアウトした人への配信はどう止める? notification_preferencesを唯一の正にして、配信判断は必ずそこを参照します。メール文末の解除リンクは、その設定を書き換えるだけにする。リンクとDBが二重管理になると、解除したのに届く事故が起きます。

Q. 配信に失敗し続けるとどうなる? available_atを後ろにずらしながらmax_attemptsまで再送し、使い切ったらstatusfailedにして止めます。失敗は消さずに残すのが要点で、failedの件数を監視してSlackやメトリクスでアラートにすればOKです。

実際に試した結果

この記事のスキーマとサービスを、手元のNext.js検証プロジェクトに貼ってPostgreSQLに流し、同じresourceIdinvoice.payment_failedを4回POSTしてみました。結果はnotificationsが1件、notification_delivery_queueも初回分だけ。冒頭の「同じメール4通」は、これで完全に再現しなくなりました。

メール設定をオフにするとアプリ内記録だけが残り、warning以上かつ設定オンのときだけキューに入る。ワーカーをわざと例外で落とすと、ジョブはpendingに戻ってavailable_atが未来にずれ、しばらくして再送される。狙いどおりでした。

いちばん効いたのは、UIより先にidempotency_keyread_atという2列を決めたことです。ベルの見た目は後からいくらでも直せる。でも「同じ出来事を2回送らない」「未読の定義をブレさせない」は、最初に決めておかないと後から地獄を見ます。順番を間違えなければ、通知システムはそんなに怖くありません。

テンプレートや配信チェックリストを手元に欲しい人は、ClaudeCodeLabの教材一覧に通知・キュー・コンテンツ運用の資料をまとめています。チームへの導入で相談したいことがあれば、導入相談ページからどうぞ。

#通知システム #メール通知 #プッシュ通知 #キュー #冪等性
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。