疑問 01
データベースへの保存後に、外部送信だけ失敗した場合をどう扱えばよいですか?
メール送信、外部API呼び出し、メッセージ公開は、DBコミットと同じトランザクションでは確定できません。
判断基準
外部送信が失敗した場合、処理全体の失敗扱い・再操作・手動再送で問題ないか
未選択
外部送信失敗時の扱いを選んでください
必要なら具体的な判断基準を開き、処理全体の失敗扱い、再操作、手動再送で問題ないかを選びます。
判断結果
送信箱(Outbox)はこの時点では導入しない
処理全体の失敗扱い、再操作、手動再送で業務上の不整合を避けられる条件です。送信箱を入れる前に、次の扱いを設計メモに残します。
次に確認すること
送信箱のコードは表示しません。失敗扱いと再操作の制御を、既存の処理でどこに置くかを確認します。
判断結果
送信箱(Outbox)を検討する
外部送信の失敗を、処理全体の失敗扱い・再操作・手動再送だけでは扱えない条件です。この場合は、データベース更新と同じトランザクションで送信予定を保存し、実際の外部送信は中継処理へ分けます。
01送信箱とは
外部へ送る予定を、データベース更新と一緒に保存しておく方式
メール送信、外部API呼び出し、メッセージ公開は、業務データのDBトランザクションには入りません。送信箱は、外部へ送る内容を送信予定として先にDBへ残し、後から中継処理が読み取って送信する方式です。
保存処理は、業務データと送信予定の保存までを担当します。外部送信は直接呼びません。中継処理が未送信の予定を読み取り、送信に成功したら送信済みに更新し、失敗したら再試行できる状態で残します。
同じDBトランザクションに残す
業務データの保存と、外部へ送る予定の保存を同じDBコミットに含めます。
後で中継して送る
中継処理が未送信レコードを読み、送信ポートを通じて外部へ送ります。
結果を記録する
送信成功は送信済み、失敗は再試行待ちとして送信箱へ戻します。
送信箱は外部送信の失敗を前提に、送信予定を残して再試行できるようにする方式です。少なくとも1回送る前提になるため、重複送信、順序ずれ、受信側の重複排除を別に設計します。
02中継方式
定期実行で送信対象を取得する
このコード例では、ワーカーまたはスケジューラーが一定間隔で送信箱テーブルを検索し、送信対象を取得して外部へ送ります。未送信の送信予定を定期的に検索するプル方式(Pull型)として扱います。この前提を固定してから、保存処理、中継処理、送信アダプター、送信結果更新を読みます。
送信予定の保存
外部へ送る予定を、業務データと同じDBトランザクションで保存します。
定期的な取得
ワーカーまたはスケジューラーが未送信または再試行待ちのレコードを検索し、中継処理へ渡します。
外部送信の境界
メール、メッセージ基盤、外部APIなどの具体的な送信先を、送信箱の外側へ分けます。
重複への備え
少なくとも1回送る方式なので、同じメッセージが再送される前提で設計します。
03実装完了の条件
実装完了の条件を確認する
コード例に入る前に、送信箱として最低限そろえる要素を確認します。同じコンポーネント / 同じDB境界では、集約ごとにテーブルを分けず、1つの outbox_messages テーブルに送信予定を集めます。
同じDBコミットに含める
業務更新と送信予定保存を同じDBトランザクションに入れ、更新処理から外部送信を直接呼びません。
コンポーネントで1つのテーブルに集める
同じコンポーネント内の複数集約は、aggregate_type、aggregate_id、message_type、destination で送信予定を分けます。
ワーカーで中継処理を起動する
実行間隔、取得件数、再試行対象の条件を決め、ワーカーまたはスケジューラーが RunOutboxRelay / runOutboxRelay を呼びます。
処理中へ更新する
送信対象を取得するときに processing へ更新し、複数ワーカーが同じレコードを同時に扱いにくくします。
結果を送信箱へ戻す
成功時は sent(送信済み)、失敗時は retry(再試行待ち)、再試行上限超過時は failed(失敗)として attempts、nextAttemptAt、lastError を残します。
重複送信を前提にする
送信済み更新に失敗した場合は、次回サイクルで同じメッセージを再送します。受信側の重複排除を別に設計します。
滞留を検知する
pending / retry の滞留、処理中のまま残ったレコード、再試行上限超過、送信順序を監視や運用判断として残します。
04実装順序
保存、取得、送信、中継処理、起動点の順に作る
送信箱は実行時にはワーカーから始まります。ただし実装では、中継処理が使うストアと送信アダプターを先に作り、最後にワーカーで定期実行します。
- 01
送信箱テーブルを作る
コンポーネント内の送信予定を集め、aggregate_type、aggregate_id、message_type、destination、状態、試行回数を保存できるスキーマを用意します。
- 02
業務更新と同じDBトランザクションに保存する
業務データと送信予定を同じDBコミットに含め、外部送信は直接呼びません。
- 03
送信対象を取得して処理中にする
pending / retry のレコードだけを取得し、複数ワーカーの同時処理を抑えるために processing へ更新します。
- 04
送信ポートとアダプターを作る
中継処理が呼ぶポートを定義し、送信先サービスへの接続をアダプターへ閉じ込めます。
- 05
中継処理で取得と送信をつなぐ
中継処理はストアから送信対象を取得し、送信ポートを呼び、成功または失敗の結果をストアへ戻します。
- 06
ワーカーで中継処理を定期実行する
ワーカー / スケジューラー / ジョブ実行基盤が、OutboxStore と送信アダプターを渡して RunOutboxRelay / runOutboxRelay を一定間隔で呼び出します。
- 07
送信箱の失敗経路をテストする
送信失敗、送信済み更新失敗、再試行上限、処理中のまま残ったレコードを最低限確認します。
05処理シーケンス
保存後にワーカーが取得し、中継処理が結果を戻す
コードへ入る前に、DB保存、ワーカー / スケジューラー、中継処理、送信アダプター、送信結果更新の呼び出し順を固定します。送信成功後に送信済み更新へ失敗すると、次回の処理サイクルで重複送信が起こり得ます。
sequenceDiagram
participant A as API / application処理
participant DB as DB + outbox
participant W as ワーカー / スケジューラー
participant R as 送信箱の中継処理
participant P as 送信アダプター
A->>DB: 業務データと送信予定を同じDBトランザクションで保存
Note over A,DB: ここでは外部送信しない
W->>R: RunOutboxRelay / runOutboxRelayを呼ぶ
R->>DB: 送信対象を取得してprocessingへ更新
R->>P: 送信ポートで送信
alt 送信失敗
R->>DB: retry回数、nextAttemptAt、lastErrorを更新
else publish成功
R->>DB: sentへ更新
alt 送信済み更新失敗
Note over R,DB: 次回サイクルで再送される可能性
end
end
Note over W,R: 呼び出し元はワーカー / スケジューラー / ジョブ実行基盤
Note over R,DB: 送信済み更新に失敗すると同じメッセージを再送する可能性がある
構成とコードの言語を選ぶ
ファイル構成とコード例を同じ言語で表示します。
06ファイル構成
コード例を読むための責務配置
ここで示すtreeは完成構成ではなく、次のコード例をどの責務に置くかを読むための配置例です。
cmd/
+-- outbox-relay/
+-- main.go
support/
|-- application/
| +-- change_ticket_priority.go
|-- domain/
| |-- ticket.go
| +-- ticket_events.go
|-- integration/
| |-- outbox_record.go
| |-- outbox_store.go
| |-- outbox_relay.go
| |-- outbox_relay_test.go
| +-- publisher_port.go
+-- infrastructure/
|-- migrations/001_create_outbox_messages.sql
|-- postgres_outbox_store.go
+-- ses_email_publisher.go
src/
|-- application/
| +-- change-ticket-priority.ts
|-- domain/
| |-- ticket.ts
| +-- ticket-events.ts
|-- integration/
| |-- outbox-store.ts
| |-- outbox-relay.ts
| |-- outbox-relay.test.ts
| +-- publisher-port.ts
|-- infrastructure/
| |-- postgres-outbox-store.ts
| +-- ses-email-publisher.ts
+-- worker/
+-- outbox-relay.ts
db/
+-- migrations/
+-- 001-create-outbox-messages.sql
treeは次のコード例を読むための配置例です。`cmd/outbox-relay/main.go` と `src/worker/outbox-relay.ts` が RunOutboxRelay / runOutboxRelay の呼び出し元です。リポジトリ構成は固定せず、実DB、実メッセージ基盤、監視実装はここでは確定しません。
07ステップ別コード
実装順序とコード断片を対応させる
上の7ステップに沿って、スキーマ、トランザクション、送信対象の取得、送信アダプター、中継関数、ワーカー起動点、失敗経路テストを確認します。
このコードで見ること
送信箱テーブル、同じDBトランザクションでの保存、送信対象の取得、送信ポートとアダプター、中継関数、ワーカー起動点、送信失敗・送信済み更新失敗・再試行上限・処理中のまま残ったレコードのテスト対応を確認します。
ここでは扱わないこと
実DB接続、実メッセージ基盤、監視、退避キュー、受信側の冪等性、全ての競合制御は別に設計します。送信箱は重複をなくす方式ではありません。
送信箱テーブルを作る
同じコンポーネント内の送信予定を1つのテーブルへ集め、aggregate_type、message_type、destinationで扱いを分けます。
create table outbox_messages (
id text primary key,
aggregate_type text not null,
aggregate_id text not null,
message_type text not null,
destination text not null,
payload jsonb not null,
status text not null check (status in ('pending', 'processing', 'sent', 'retry', 'failed')),
attempts integer not null default 0,
next_attempt_at timestamptz not null,
locked_until timestamptz,
last_error text,
created_at timestamptz not null,
sent_at timestamptz
);
create index outbox_messages_ready_idx
on outbox_messages (destination, status, next_attempt_at);
業務更新と同じDBトランザクションに保存する
業務更新と送信予定を同じDBコミットへ入れ、更新処理から外部送信を直接呼ばない形にします。
type UnitOfWork interface {
WithinTx(ctx context.Context, fn func(Tx) error) error
}
type Tx interface {
Tickets() TicketRepository
Outbox() OutboxStore
}
type OutboxStore interface {
SavePending(ctx context.Context, record OutboxRecord) error
}
func NewPriorityChangedEmailRecord(
event domain.TicketPriorityChanged,
notifyTo string,
now time.Time,
) (OutboxRecord, error) {
messageID := newMessageID()
payload, err := json.Marshal(struct {
MessageID string `json:"messageId"`
To string `json:"to"`
Subject string `json:"subject"`
TextBody string `json:"textBody"`
}{
MessageID: messageID,
To: notifyTo,
Subject: "Ticket priority changed",
TextBody: "Ticket " + event.TicketID.String() + " priority changed to " + event.Priority.String() + ".",
})
if err != nil {
return OutboxRecord{}, err
}
return OutboxRecord{
ID: messageID,
AggregateType: "ticket",
AggregateID: event.TicketID.String(),
Type: "support.ticket-priority-changed.email.v1",
Destination: "email",
Payload: payload,
Status: OutboxPending,
Attempts: 0,
NextAttemptAt: now,
}, nil
}
func (h ChangeTicketPriorityHandler) Handle(
ctx context.Context,
cmd ChangeTicketPriority,
) error {
return h.Uow.WithinTx(ctx, func(tx Tx) error {
ticket, err := tx.Tickets().Load(ctx, cmd.TicketID)
if err != nil {
return err
}
event, err := ticket.ChangePriority(cmd.Priority)
if err != nil {
return err
}
record, err := NewPriorityChangedEmailRecord(event, cmd.NotifyTo, h.Clock.Now())
if err != nil {
return err
}
if err := tx.Tickets().Save(ctx, ticket); err != nil {
return err
}
return tx.Outbox().SavePending(ctx, record)
})
}
送信対象を取得して処理中にする
pending / retry のレコードだけを取得し、同時にprocessingへ更新します。複数workerが同じレコードを同時に処理しにくくするためです。
type OutboxStatus string
const (
OutboxPending OutboxStatus = "pending"
OutboxProcessing OutboxStatus = "processing"
OutboxSent OutboxStatus = "sent"
OutboxRetry OutboxStatus = "retry"
OutboxFailed OutboxStatus = "failed"
)
type OutboxRecord struct {
ID string
AggregateType string
AggregateID string
Type string
Destination string
Payload []byte
Status OutboxStatus
Attempts int
NextAttemptAt time.Time
}
type RetryUpdate struct {
ID string
Attempts int
NextAttemptAt time.Time
Reason string
}
type FailedUpdate struct {
ID string
Attempts int
Reason string
}
type RelayStore interface {
ClaimPending(ctx context.Context, now time.Time, limit int) ([]OutboxRecord, error)
MarkSent(ctx context.Context, id string) error
MarkRetry(ctx context.Context, update RetryUpdate) error
MarkFailed(ctx context.Context, update FailedUpdate) error
RecoverStaleProcessing(ctx context.Context, now time.Time) error
}
func (s PostgresOutboxStore) ClaimPending(
ctx context.Context,
now time.Time,
limit int,
) ([]OutboxRecord, error) {
rows, err := s.DB.QueryContext(ctx, `
update outbox_messages
set status = 'processing',
locked_until = $1
where id in (
select id
from outbox_messages
where status in ('pending', 'retry')
and next_attempt_at <= $2
order by next_attempt_at
limit $3
for update skip locked
)
returning id, aggregate_type, aggregate_id, message_type, destination, payload,
status, attempts, next_attempt_at
`, now.Add(30*time.Second), now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanOutboxRows(rows)
}
送信ポートとアダプターを作る
中継処理が依存するポートを内側に置き、Amazon SESなどの具体的な送信先は外側のアダプターへ閉じ込めます。
type PublisherPort interface {
Publish(ctx context.Context, record OutboxRecord) error
}
import (
"context"
"encoding/json"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)
type SesEmailClient interface {
SendEmail(
ctx context.Context,
input *sesv2.SendEmailInput,
optFns ...func(*sesv2.Options),
) (*sesv2.SendEmailOutput, error)
}
type SesEmailPublisher struct {
Client SesEmailClient
From string
ConfigurationSetName string
}
type OutboundEmailV1 struct {
MessageID string `json:"messageId"`
To string `json:"to"`
Subject string `json:"subject"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody,omitempty"`
}
func (p SesEmailPublisher) Publish(ctx context.Context, record OutboxRecord) error {
if record.Destination != "email" {
return fmt.Errorf("unsupported outbox destination: %s", record.Destination)
}
if record.Type != "support.ticket-priority-changed.email.v1" {
return fmt.Errorf("unsupported email message type: %s", record.Type)
}
var email OutboundEmailV1
if err := json.Unmarshal(record.Payload, &email); err != nil {
return err
}
if email.MessageID == "" {
email.MessageID = record.ID
}
body := &types.Body{
Text: &types.Content{Charset: aws.String("UTF-8"), Data: aws.String(email.TextBody)},
}
if email.HTMLBody != "" {
body.Html = &types.Content{Charset: aws.String("UTF-8"), Data: aws.String(email.HTMLBody)}
}
input := &sesv2.SendEmailInput{
FromEmailAddress: aws.String(p.From),
Destination: &types.Destination{ToAddresses: []string{email.To}},
Content: &types.EmailContent{Simple: &types.Message{
Subject: &types.Content{Charset: aws.String("UTF-8"), Data: aws.String(email.Subject)},
Body: body,
}},
EmailTags: []types.MessageTag{
{Name: aws.String("outboxMessageId"), Value: aws.String(email.MessageID)},
},
}
if p.ConfigurationSetName != "" {
input.ConfigurationSetName = aws.String(p.ConfigurationSetName)
}
_, err := p.Client.SendEmail(ctx, input)
return err
}
中継処理で取得と送信をつなぐ
中継処理はストアから送信対象を取得し、送信ポートで送信し、成功なら送信済み、失敗なら再試行待ちへ戻します。
func RunOutboxRelay(
ctx context.Context,
store RelayStore,
publisher PublisherPort,
now time.Time,
) error {
if err := store.RecoverStaleProcessing(ctx, now); err != nil {
return err
}
records, err := store.ClaimPending(ctx, now, 100)
if err != nil {
return err
}
for _, record := range records {
if err := publisher.Publish(ctx, record); err != nil {
if retryErr := continueWithRetry(ctx, store, record, now, err); retryErr != nil {
return retryErr
}
continue
}
if err := store.MarkSent(ctx, record.ID); err != nil {
return err
}
}
return nil
}
func continueWithRetry(
ctx context.Context,
store RelayStore,
record OutboxRecord,
now time.Time,
err error,
) error {
nextAttempts := record.Attempts + 1
if nextAttempts >= 5 {
return store.MarkFailed(ctx, FailedUpdate{
ID: record.ID,
Attempts: nextAttempts,
Reason: err.Error(),
})
}
return store.MarkRetry(ctx, RetryUpdate{
ID: record.ID,
Attempts: nextAttempts,
NextAttemptAt: now.Add(backoff(nextAttempts)),
Reason: err.Error(),
})
}
ワーカーで中継処理を定期実行する
ここが RunOutboxRelay の呼び出し元です。ワーカーやスケジューラーの起動点で、DB側のストアと外部送信アダプターを作り、一定間隔で中継処理へ渡します。
func main() {
ctx := context.Background()
db := openPostgres()
store := NewPostgresOutboxStore(db)
cfg := loadAWSConfig(ctx)
publisher := SesEmailPublisher{
Client: sesv2.NewFromConfig(cfg),
From: mustEnv("SES_FROM_ADDRESS"),
ConfigurationSetName: os.Getenv("SES_CONFIGURATION_SET"),
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
// この起動点が RunOutboxRelay を繰り返し呼ぶ。
if err := RunOutboxRelay(ctx, store, publisher, now); err != nil {
log.Printf("outbox relay failed: %v", err)
}
}
}
}
送信箱の失敗経路をテストする
送信失敗、送信済み更新失敗、再試行上限、処理中のまま残ったレコードを分けて確認します。重複送信は起き得るため、再送条件と記録が正しいことをテストします。
func TestRunOutboxRelayMarksRetryOnPublishFailure(t *testing.T) {
record := OutboxRecord{ID: "msg-1", Attempts: 1}
store := NewFakeRelayStore(record)
publisher := FailingPublisher{Err: errors.New("network")}
now := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
err := RunOutboxRelay(context.Background(), store, publisher, now)
require.NoError(t, err)
require.Equal(t, 2, store.RetryUpdate.Attempts)
require.True(t, store.RetryUpdate.NextAttemptAt.After(now))
require.False(t, store.MarkedSent)
}
func TestRunOutboxRelayReturnsErrorWhenMarkSentFails(t *testing.T) {
record := OutboxRecord{ID: "msg-1", Attempts: 0}
store := NewFakeRelayStore(record)
store.MarkSentErr = errors.New("db down after publish")
publisher := RecordingPublisher{}
err := RunOutboxRelay(context.Background(), store, publisher, time.Now())
require.Error(t, err)
require.Equal(t, []string{"msg-1"}, publisher.PublishedIDs)
// 送信済み更新に失敗したため、滞留回復後に再送される可能性が残る。
require.False(t, store.MarkedSent)
}
func TestRunOutboxRelayMarksFailedAfterRetryLimit(t *testing.T) {
record := OutboxRecord{ID: "msg-1", Attempts: 4}
store := NewFakeRelayStore(record)
publisher := FailingPublisher{Err: errors.New("network")}
err := RunOutboxRelay(context.Background(), store, publisher, time.Now())
require.NoError(t, err)
require.Equal(t, 5, store.FailedUpdate.Attempts)
require.Empty(t, store.RetryUpdate.ID)
}
func TestRunOutboxRelayRecoversStaleProcessing(t *testing.T) {
store := NewFakeRelayStore()
now := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
err := RunOutboxRelay(context.Background(), store, RecordingPublisher{}, now)
require.NoError(t, err)
require.Equal(t, now, store.RecoveredStaleAt)
}
送信箱テーブルを作る
同じコンポーネント内の送信予定を1つのテーブルへ集め、aggregateType、messageType、destinationで扱いを分けます。
create table outbox_messages (
id text primary key,
aggregate_type text not null,
aggregate_id text not null,
message_type text not null,
destination text not null,
payload jsonb not null,
status text not null check (status in ('pending', 'processing', 'sent', 'retry', 'failed')),
attempts integer not null default 0,
next_attempt_at timestamptz not null,
locked_until timestamptz,
last_error text,
created_at timestamptz not null,
sent_at timestamptz
);
create index outbox_messages_ready_idx
on outbox_messages (destination, status, next_attempt_at);
業務更新と同じDBトランザクションに保存する
業務更新と送信予定を同じDBトランザクションへ入れ、更新処理から外部送信を直接呼ばない形にします。
type UnitOfWork = Readonly<{
transaction: <A>(fn: (tx: Tx) => Promise<A>) => Promise<A>;
}>;
type Tx = Readonly<{
tickets: TicketRepository;
outbox: OutboxStore;
}>;
type OutboxStore = Readonly<{
savePending: (record: OutboxRecord) => Promise<void>;
}>;
export const toOutboxRecord = (
event: TicketPriorityChanged,
notifyTo: string,
now: string
): OutboxRecord => {
const messageId = newMessageId();
const payload = {
messageId,
to: notifyTo,
subject: "Ticket priority changed",
textBody: `Ticket ${event.ticketId} priority changed to ${event.priority}.`
};
return {
id: messageId,
aggregateType: "ticket",
aggregateId: event.ticketId,
type: "support.ticket-priority-changed.email.v1",
destination: "email",
payload,
status: "pending",
attempts: 0,
nextAttemptAt: now
};
};
export const changeTicketPriority =
(deps: { uow: UnitOfWork; now: () => string }) =>
(command: ChangeTicketPriority): Promise<void> =>
deps.uow.transaction(async (tx) => {
const ticket = await tx.tickets.load(command.ticketId);
const { updated, event } = changePriority(ticket, command.priority);
await tx.tickets.save(updated);
await tx.outbox.savePending(toOutboxRecord(event, command.notifyTo, deps.now()));
});
送信対象を取得して処理中にする
pending / retry のレコードだけを取得し、同時にprocessingへ更新します。複数workerが同じレコードを同時に処理しにくくするためです。
export type OutboxRecord = Readonly<{
id: string;
aggregateType: string;
aggregateId: string;
type: string;
destination: string;
payload: unknown;
status: "pending" | "processing" | "sent" | "retry" | "failed";
attempts: number;
nextAttemptAt: string;
}>;
export type RetryUpdate = Readonly<{
id: string;
attempts: number;
nextAttemptAt: string;
reason: string;
}>;
export type FailedUpdate = Readonly<{
id: string;
attempts: number;
reason: string;
}>;
export type RelayStore = Readonly<{
claimPending: (now: string, limit: number) => Promise<readonly OutboxRecord[]>;
markSent: (id: string) => Promise<void>;
markRetry: (update: RetryUpdate) => Promise<void>;
markFailed: (update: FailedUpdate) => Promise<void>;
recoverStaleProcessing: (now: string) => Promise<void>;
}>;
export const postgresOutboxStore = (db: Database): RelayStore => ({
claimPending: (now, limit) =>
db.query<OutboxRecord>(
`
update outbox_messages
set status = 'processing',
locked_until = $1
where id in (
select id
from outbox_messages
where status in ('pending', 'retry')
and next_attempt_at <= $2
order by next_attempt_at
limit $3
for update skip locked
)
returning id,
aggregate_type as "aggregateType",
aggregate_id as "aggregateId",
message_type as "type",
destination,
payload,
status,
attempts,
next_attempt_at as "nextAttemptAt"
`,
[addSeconds(now, 30), now, limit]
),
markSent: (id) => db.execute(
"update outbox_messages set status = 'sent', sent_at = now(), locked_until = null where id = $1",
[id]
),
markRetry: (update) =>
db.execute(
"update outbox_messages set status = 'retry', attempts = $2, next_attempt_at = $3, last_error = $4, locked_until = null where id = $1",
[update.id, update.attempts, update.nextAttemptAt, update.reason]
),
markFailed: (update) =>
db.execute(
"update outbox_messages set status = 'failed', attempts = $2, last_error = $3, locked_until = null where id = $1",
[update.id, update.attempts, update.reason]
),
recoverStaleProcessing: (now) =>
db.execute(
"update outbox_messages set status = 'retry', locked_until = null where status = 'processing' and locked_until <= $1",
[now]
)
});
送信ポートとアダプターを作る
中継処理が依存するポートを内側に置き、Amazon SESなどの具体的な送信先は外側のアダプターへ閉じ込めます。
import type { OutboxRecord } from "./outbox-store";
export type PublisherPort = Readonly<{
publish: (record: OutboxRecord) => Promise<void>;
}>;
import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2";
import type { OutboxRecord } from "../integration/outbox-store";
import type { PublisherPort } from "../integration/publisher-port";
type OutboundEmailV1 = Readonly<{
messageId?: string;
to: string;
subject: string;
textBody: string;
htmlBody?: string;
}>;
const toEmailMessage = (record: OutboxRecord): OutboundEmailV1 =>
record.payload as OutboundEmailV1;
export const sesEmailPublisher =
(deps: { client: SESv2Client; from: string; configurationSetName?: string }): PublisherPort => ({
publish: async (record) => {
if (record.destination !== "email") {
throw new Error(`unsupported outbox destination: ${record.destination}`);
}
if (record.type !== "support.ticket-priority-changed.email.v1") {
throw new Error(`unsupported email message type: ${record.type}`);
}
const email = toEmailMessage(record);
await deps.client.send(new SendEmailCommand({
FromEmailAddress: deps.from,
Destination: { ToAddresses: [email.to] },
Content: { Simple: {
Subject: { Charset: "UTF-8", Data: email.subject },
Body: {
Text: { Charset: "UTF-8", Data: email.textBody },
...(email.htmlBody ? { Html: { Charset: "UTF-8", Data: email.htmlBody } } : {})
}
}},
EmailTags: [{ Name: "outboxMessageId", Value: email.messageId ?? record.id }],
...(deps.configurationSetName ? { ConfigurationSetName: deps.configurationSetName } : {})
}));
}
});
中継処理で取得と送信をつなぐ
中継処理はストアから送信対象を取得し、送信ポートで送信し、成功なら送信済み、失敗なら再試行待ちへ戻します。
import type { OutboxRecord, RelayStore } from "./outbox-store";
import type { PublisherPort } from "./publisher-port";
export const runOutboxRelay =
(deps: { store: RelayStore; publisher: PublisherPort; now: () => string }) =>
async (): Promise<void> => {
const now = deps.now();
await deps.store.recoverStaleProcessing(now);
const records = await deps.store.claimPending(now, 100);
for (const record of records) {
try {
await deps.publisher.publish(record);
} catch (publishError) {
await markRetryOrFailed(deps.store, record, deps.now(), publishError);
continue;
}
await deps.store.markSent(record.id);
}
};
const markRetryOrFailed = async (
store: RelayStore,
record: OutboxRecord,
now: string,
error: unknown
): Promise<void> => {
const nextAttempts = record.attempts + 1;
if (nextAttempts >= 5) {
await store.markFailed({
id: record.id,
attempts: nextAttempts,
reason: String(error)
});
return;
}
await store.markRetry({
id: record.id,
attempts: nextAttempts,
nextAttemptAt: nextAttemptAt(now, nextAttempts),
reason: String(error)
});
};
ワーカーで中継処理を定期実行する
ここが runOutboxRelay の呼び出し元です。ワーカーやスケジューラーの起動点で、DB側のストアと外部送信アダプターを作り、一定間隔で中継処理へ渡します。
import { runOutboxRelay } from "../integration/outbox-relay";
import { postgresOutboxStore } from "../integration/outbox-store";
import { sesEmailPublisher } from "../infrastructure/ses-email-publisher";
import { SESv2Client } from "@aws-sdk/client-sesv2";
import type { PublisherPort } from "../integration/publisher-port";
import type { RelayStore } from "../integration/outbox-store";
export const startOutboxRelay = (deps: {
store: RelayStore;
publisher: PublisherPort;
now: () => string;
intervalMs: number;
onError: (error: unknown) => void;
}): (() => void) => {
const tick = async () => {
try {
await runOutboxRelay({
store: deps.store,
publisher: deps.publisher,
now: deps.now
})();
} catch (error) {
deps.onError(error);
}
};
const timer = setInterval(() => void tick(), deps.intervalMs);
void tick();
return () => clearInterval(timer);
};
const store = postgresOutboxStore(db);
const publisher = sesEmailPublisher({
client: new SESv2Client({ region: env.AWS_REGION }),
from: env.SES_FROM_ADDRESS,
configurationSetName: env.SES_CONFIGURATION_SET
});
startOutboxRelay({
store,
publisher,
now: systemClock.now,
intervalMs: 5_000,
onError: (error) => console.error("outbox relay failed", error)
});
送信箱の失敗経路をテストする
送信失敗、送信済み更新失敗、再試行上限、処理中のまま残ったレコードを分けて確認します。重複送信は起き得るため、再送条件と記録が正しいことをテストします。
test("publish失敗時はretry情報を更新する", async () => {
const record = outboxRecord({ id: "msg-1", attempts: 1 });
const store = fakeRelayStore([record]);
const publisher: PublisherPort = {
publish: async () => { throw new Error("network"); }
};
await runOutboxRelay({ store, publisher, now: () => "2026-06-23T10:00:00Z" })();
expect(store.retryUpdate).toMatchObject({
id: "msg-1",
attempts: 2
});
expect(store.sentIds).toEqual([]);
});
test("sent更新失敗時は再送される可能性を残す", async () => {
const record = outboxRecord({ id: "msg-1", attempts: 0 });
const store = fakeRelayStore([record], {
markSentError: new Error("db down after publish")
});
const publisher = recordingPublisher();
await expect(
runOutboxRelay({ store, publisher, now: fixedNow })()
).rejects.toThrow("db down after publish");
expect(publisher.publishedIds).toEqual(["msg-1"]);
expect(store.sentIds).toEqual([]);
expect(store.retryUpdate).toBeUndefined();
});
test("retry上限を超えたらfailedへ退避する", async () => {
const record = outboxRecord({ id: "msg-1", attempts: 4 });
const store = fakeRelayStore([record]);
const publisher: PublisherPort = {
publish: async () => { throw new Error("network"); }
};
await runOutboxRelay({ store, publisher, now: fixedNow })();
expect(store.failedUpdate).toMatchObject({
id: "msg-1",
attempts: 5
});
expect(store.retryUpdate).toBeUndefined();
});
test("processingのまま滞留した送信予定をretryへ戻す", async () => {
const store = fakeRelayStore([]);
await runOutboxRelay({
store,
publisher: recordingPublisher(),
now: fixedNow
})();
expect(store.recoveredStaleAt).toBe(fixedNow());
});
08送信箱を動かす構成を決める
コードを実行環境へ置く前に、起動場所、送信先、監視対象を分けて決めます。
ここでは特定サービスを推奨しません。送信箱テーブル、中継処理、送信アダプターをどこで動かし、何を見張るかを、候補選定の入口として整理します。
アプリ本体とは別プロセスでワーカーまたはスケジューラーを起動し、RunOutboxRelay / runOutboxRelay を一定間隔で呼びます。
cmd/outbox-relay / src/worker/outbox-relay / intervalMs / limit送信ポートの外側に、SES、SQS、SNS、EventBridge、Kinesis、外部HTTP APIなどのアダプターを接続します。
PublisherPort / 送信アダプター / destinationpending / retry の滞留、再試行上限、処理中のまま残ったレコード、重複送信、ワーカー起動失敗を別々に監視します。
最古pending秒数 / 再試行上限超過 / 処理中の滞留 / 重複処理同じコンポーネント / 同じDB境界では、複数集約の送信予定を同じ outbox_messages テーブルに集めます。コンポーネントやDB境界が違う場合は、それぞれのコンポーネントに送信箱を持ちます。
コンポーネント / aggregate_type / message_type / destination / status実行基盤の候補
まず既存の運用基盤に合わせて、中継処理の起動方法を選びます。候補は送信箱そのものではなく、送信箱を動かす場所です。
常駐ワーカー
- 向いている条件
- 数秒単位で送信したい。polling間隔を短く保ちたい。
- 注意点
- アプリ本体とは別サービスとして起動し、再起動とスケールを管理します。
- 次に決めること
- ECS / Fargate、Kubernetes Deployment、プロセス監視。
スケジューラー起動
- 向いている条件
- 分単位の遅延を許容できる。常駐ワーカーを増やしたくない。
- 注意点
- スケジューラーの起動失敗と、送信箱内の送信失敗を分けて監視します。
- 次に決めること
- EventBridge Scheduler -> ECS task / Lambda、Kubernetes CronJob。
変更通知で起動
- 向いている条件
- DB変更ログやストリームを使う運用基盤がすでにある。
- 注意点
- この画面では起動方式の差分だけを扱い、コード例は定期検索を主導線にします。
- 次に決めること
- managed CDC、DynamoDB Streams -> Lambda、トランザクションログ。
動かした後に見る状態
監視は実行基盤の候補ではありません。送信箱を動かした後に、送信予定が止まっていないかを確認するための観測対象です。
pending / retry が残り続ける時間と件数を見ます。
outbox.pending.count / outbox.oldest_pending_age_seconds送信失敗、再試行上限、手動再送が必要な件数を見ます。
outbox.relay.failure.count / outbox.retry_exhausted.countprocessing のまま locked_until を過ぎた送信予定を見ます。
outbox.stale_processing.count / stale thresholdワーカーやスケジューラーそのものが起動できているかを、送信失敗とは分けて見ます。
target invocation failure / worker heartbeat送信先アダプターがSESの場合
SESは送信箱そのものではありません。送信ポートの外側に置くメール送信アダプターとして扱います。
ECS、Lambda、EventBridge、Kubernetes、Datadog、SESなどは、実行基盤、監視基盤、送信先アダプターの候補です。送信箱そのものは、送信予定の保存、未送信の取得、送信結果の更新で構成します。
09変更通知で起動する場合
DB変更ログ、ストリーム、managed CDC などで中継処理を起動する方式は、プッシュ方式(Push型)です。ここでは、定期実行から置き換わる部分だけを確認します。
-
起動方法だけが変わる
workerの定期検索ではなく、DBの変更ログやストリームが中継処理を起動します。
トランザクションログ / managed CDC / DynamoDB Streams -> Lambda -
送信箱そのものではない
EventBridge、Kinesis、Lambda、DynamoDB Streams は送信先または中継方式の候補です。送信予定をDBに残す設計そのものではありません。
サービス名は構成候補であり、送信箱の代替名ではない -
残る判断は同じ
変更通知で起動する場合も、送信結果更新、重複排除、順序ずれ、pending / retry の滞留、失敗回数の扱いは別に設計します。
この画面では、起動方式の差分だけを示します
10送信箱を採用する前に再判定する
送信箱で減らせる問題と、送信箱でも残る問題を分けます。
送信箱は、データベース更新と外部送信の片方だけが成功する状態を減らす方式です。ただし、重複、順序ずれ、長時間の滞留、受信側の重複処理は別に設計します。
業務データの保存と送信予定の保存を同じDBトランザクションに含め、外部へ送る予定を失いにくくします。
送信失敗、再送、重複、順序ずれ、pending / retry の滞留、受信側での重複処理は残ります。
-
同じメッセージが複数回送られても、業務上の二重処理を避けられますか?
避けられない場合は、送信箱だけで進めると、二重通知、二重請求、二重在庫引当などの不整合が残ります。
at least once / duplicate message -
送信順が前後しても、業務状態を壊さないようにできますか?
順序が業務判断の根拠になる場合は、message key、sequence、受信側の並べ替え、順序保証のある基盤を別に検討します。
message ordering / sequence -
未送信や再試行待ちが残った時に、検知して対応できますか?
pending / retry が残り続ける状態を監視できない場合、送信箱は失敗を保存するだけで、業務上の回復につながりません。
pending / retry滞留 / monitoring -
失敗回数の上限、待ち時間、退避先を決められますか?
無制限に再試行すると、障害中の外部サービスや自システムへ負荷を戻します。上限、backoff、failed退避、手動再送を決めます。
再試行上限 / バックオフ / 退避キュー -
受信側で、処理済みメッセージを判定できますか?
送信側だけで重複処理は防げません。受信側がmessageIdなどで処理済みを判定し、同じメッセージを再処理しない設計が必要です。
受信側の冪等性 / 処理済みメッセージ