AIニュース最前線
最新ニュースAI日報Hacker日報週報動画AIツールトレンド企業

AIニュース最前線

世界中のAI最新情報を日本語で毎時更新

最新ニュース日報トレンド企業プレミアムRSS
© 2026 ainew.jp特定商取引法に基づく表記
ニュース一覧元記事を開く
Mercari Engineering·2026年6月18日 11:00·約29分で読める

メルペイのキャンペーン基盤をルールベース汎用システムへ再構築し、Otoku Revolution を実現したまでの話

#システムアーキテクチャ#マイクロサービス#イベント駆動設計#メルカリ#技術的負債
TL;DR

メルカリエンジニアリングチームは、キャンペーンごとの専用パイプラインが抱える開発遅延と運用負荷を解消するため、ルールベースの汎用基盤「Rulebase」への書き換えを実施し、新企画「Otoku Revolution」を稼働させた。

AI深層分析2026年6月18日 02:02
3
注目/ 5段階
深度40%
4
関連度30%
2
実用性20%
5
革新性10%
3

キーポイント

1

専用パイプラインからの脱却

従来のキャンペーン種別ごとの専用パイプライン(各テーブル・ロジックの独立)は、要件複雑化により開発速度低下と運用負荷増大を招いたため、汎用基盤への移行が決定された。

2

Rulebase 基盤の構築

2026 年春に、汎用キャンペーンテーブルにルール評価とアクション実行モジュールを組み合わせた新基盤を構築し、既存システムからの段階的移行を開始した。

3

Otoku Revolution の実装

新基盤の最初の稼働事例として、「コード決済を一定回数使うたびに値引き体験が届く」新企画「Otoku Revolution(コード決済の回数連動キャンペーン)」が立ち上げられた。

4

技術的負債の解消と将来展望

2025 年夏からの共通理解を経て、コードの再利用性を高め、バグ対応コストを削減する設計へ転換し、既存キャンペーン群の移行は今後のフェーズとして位置付けられている。

5

キャンペーンルールの構造化

トリガ条件、集計方式、アクション内容を明確に定義し、優先度と有効性を管理するデータ構造を確立した。

6

柔軟なパラメータ設計

アクションの引数を JSON 形式で保持することで、多様なキャンペーン条件への対応とシステム拡張性を確保している。

7

4層構造の評価エンジンと副作用分離

イベントデータ、ルール定義、ユーザー属性、外部サービスハンドラを束ねる評価エンジンにより、条件判定(副作用なし)とアクション実行(副作用あり)を明確に分離し、拡張性を確保。

影響分析・編集コメントを表示

影響分析

この記事は、大規模 EC サービスにおけるキャンペーン機能のアーキテクチャ刷新事例を示しており、複雑化するビジネス要件に対して汎用性と拡張性を両立させる設計思想の重要性を浮き彫りにしています。特に、技術的負債が運用効率に直結する実例として、エンジニアリング組織におけるリファクタリングのタイミングと戦略的な価値を再認識させる内容となっています。

編集コメント

AI 技術そのものの進展ではありませんが、大規模システムにおける「汎用基盤化」による開発効率化と技術的負債解消の典型的な事例として、エンジニアリング組織の成熟度を測る重要な指標となる記事です。

この記事は Merpay & Mercoin Tech Openness Month 2026 の 13 日目の記事です。

こんにちは。Growth Platform Team でメルペイのポイント還元キャンペーン基盤である Santa サービスの開発を担当している @hasegway です。

なお、タイトルに登場する「Otoku Revolution」とは、コード決済を一定回数使うたびに必ず値引き体験が届く新企画のキャンペーン (本記事では「コード決済の回数連動キャンペーン」と呼びます) の社内呼称です。詳しくは本連載 17 日目の @yutaro の記事を楽しみにしていてください。本記事では、長く運用してきた Santa サービスをルールベースの汎用基盤 (以降「Rulebase 基盤」と呼びます) として書き直したリファクタリングの話と、新基盤の最初のキャンペーンとして「コード決済の回数連動キャンペーン」を立ち上げた話を取り上げます。順を追ってお話しする前に、まず Santa という基幹サービスがどのような歴史を経て、どんな負債を抱えるに至ったかについてお話しさせてください。

Santa の歴史

Santa という名前は、初期に「使った翌日にバッチ処理でポイントを付与する」 サービスだったところから来ています。夜のうちに溜まったイベントを翌朝まとめて配って回る、シンプルな仕組みでした (初期の仕組みについては メルペイのキャンペーンを支えるサンタの秘密 が詳しいです)。

それから何年も経て、今では 1 日数百万イベントを処理し、メルカリ / メルペイのさまざまな利用シーンでポイント付与を行う基幹サービスへと成長しました。この成長の過程で一貫していたのは、「キャンペーンの種別ごとに専用のイベントパイプラインを実装する」 スタイルで機能を積み重ねてきたことです。それぞれ独自のテーブル、独自のイベントハンドラ、独自の Cap (上限) ロジック、独自のポイント付与フローを持っていました。2021 年のフィルタリング機能 は「複数の条件を AND/OR で組み合わせる」発想を Santa に持ち込んだ、汎用化の最初の一歩でした。2022 年のメルカード常時還元 は、その上に「メルカードステージ別の還元率」「複数月にまたがる Provision (引当金) 管理」 といった精緻なロジックを乗せた大規模なパイプラインで、現在でも Santa 最大のパイプラインです。それでも、キャンペーン本体のコードは CampaignType ごとに別物のままでした。

この構造は当初の要件においては合理的でした。しかしキャンペーン要件の複雑化とともに、コードの再利用性が低い設計による開発速度の低下、バグのキャンペーン別対応による運用負荷の増大といった課題が積み重なっていきました。2025 年夏の時点では、Santa エンジニアチーム内でこれら課題への共通理解ができており、次世代キャンペーン構造のラフ設計まで進んでいました。ただし当時はスケジュール上の制約で本格着手を見送り、2025 年 10 月にローンチしたメルカリモバイル向け特典キャンペーンは既存システムを拡張する形で実装しました。

その後、PoC(Proof of Concept:概念実証)を進め、2025 年 12 月にチーム向けに成果を発表。2026 年春にかけて、汎用キャンペーンテーブルにルール評価とアクション実行モジュールを組み合わせた Rulebase 基盤を新規に構築しました。現時点では「コード決済の回数連動キャンペーン」を 1 件稼働させている段階で、既存のキャンペーン群は引き続き従来の専用パイプラインで動いています。これら既存キャンペーンの段階的な移行は、これから先のフェーズです。

専用パイプラインが抱えた負債

Rulebase 基盤を作る前の Santa は、キャンペーンの種別 (CampaignType) ごとに「専用パイプライン」を実装する、というスタイルで成長してきました。代表的なキャンペーン種別と、各種別の代表的なキャンペーン企画は次のとおりです。

キャンペーン種別

主なキャンペーン企画

購入時還元

「買ってお得!d ポイント」など

メルペイの定額払いの還元

「はじめての定額払いキャンペーン」など

メルペイスマートマネーの還元

「初回利用」「カムバックキャンペーン」など

メルカード還元

「メルカード常時還元」など

これらの種別はそれぞれが、専用のテーブル群、決済や返済を受け取る Pub/Sub(Publish/Subscribe:出版購読モデル)の入り口、ビジネスロジックを担う Interactor、ポイント付与履歴テーブルへの書き込みパス、付与上限の計算ロジックを抱えています。また、企画ごとの細かい要件の実現のため、基本のパイプラインの中にさまざまな分岐処理が加えられています。新たにキャンペーン企画を 1 本立てるたびに、これらのさまざまなレイヤーを個別に調査・変更しなければならない、というのが Santa の標準作業でした。

そして、長年運用するなかでこの構造がいくつかの負債を生んでいました。

累積した運用負債

まず、専用パイプラインを実装するスタイルでは、新たなキャンペーン要件のための追加開発が横展開して再利用しづらい問題がありました。また、当初に想定していたキャンペーン要件では考えられなかった新たな要件は、個別実装で対応せざるを得ないこともありました。これらは徐々に開発速度の低下やリグレッションテスト(回帰テスト)の複雑化を招いていきました。特に MercardCampaignType ではこの問題が顕著で、リファクタリングに踏み切る直接的な引き金になりました。

MercardCampaignType が「なんでも置き場」化していった

2022 年にローンチした MercardCampaign パイプラインは、当初「利用ステージ別の還元率」と「清算起点のリアルタイムのポイント付与」を素直に扱うことを想定した、シンプルな常時還元キャンペーン向けの設計でした。

その後、メルカードまわりのキャンペーン要件は急速に広がっていきます。累計購入額連動キャンペーン、メルカリ NFT 決済への対応、メルカード ゴールド、メルカリモバイル契約者向けの特典など、どれも「メルカード保有者・利用者」という共通の文脈はあるものの、設計当時の想定にはなかった要件ばかりです。それでも置き場としては MercardCampaignType が最も適切だったため、これらの新要件は順次 MercardCampaignType の上に実装されていきます。本来シンプルに設計されていた入れ物に想定外の機能が次々と追加され、還元率算出や会計コード指定などの仕組みが本来の用途を超えて流用されるようになっていきました。

ここでは、特に歪みが目に見える形で表面化した 3 つの事例 — 累計購入型 (2024 年 9 月)、メルカリ NFT 会計コード差し替え対応 (2025 年 12 月)、メルカリモバイル向け特典キャンペーン (2025 年 10 月) — を順に見ていきます。

累計購入型 と メルカリ NFT 会計コード差し替え

累計購入額連動キャンペーンでは、購入金額が複数のしきい値を順に超えるたびに段階的にポイントが付与されます (例: 累計購入額に応じて最大 P1,500 もらえる)。このキャンペーンが MercardCampaignType に投入され、本来の還元率スキーマに「購入累積トラッカー」型の挙動が乗りました。メルカリ NFT 会計コード差し替え対応では「キャンペーンの各種条件は他と共通にしつつ NFT 取引のみ会計コードを差し替える」という要件のため、コード内に分岐処理が追加され、キャンペーン設定値が複雑化しました。どちらもメルカード保有者向けの施策ではあるものの、当初想定の責務範囲を超えた要件です。

そして 3 例目の、もっとも歪みが大きく出たケースが、メルカリモバイル向け特典キャンペーンです。

メルカリモバイル向け特典キャンペーン (2025 年 10 月)

メルカリモバイル向け特典キャンペーンの要件は、3 種類のメルカードステータス (保有無し / 通常版 / ゴールド) × 4 種類のモバイルデータプラン (4GB / 10GB / 20GB / 40GB) = 12 の独立したキャンペーンパターンが毎月必要、というものでした。

実装上は、本来メルカードステージ別の還元レートを保持する DB フィールドが「データプラン別のレート」の入れ物として流用されました。3 ステータス ×4 データプランの 12 組み合わせを、メルカード用テーブルの行として毎月生成する運用です。

また、お客さまのメルカードステータスやモバイルデータプランは日々変わりうるため変化に合わせて還元レートや月々の上限を計算し直す必要があります。そして、同じお客さまが同月内で別の組み合わせ向けキャンペーンにも二重にマッチして重複付与が起こりうる、というリスクも残ります。後者の重複付与を防ぐために、コード側にはこんな雰囲気のハードコードマップが入りました (簡略化したイメージ)。

// ポイント重複付与防止のため、キャンペーン ID をコードに埋め込み

TemporaryCampaignIDMapping = map[string]string{

"202510": "campaign-id-1",

"202511": "campaign-id-2",

// 毎月追加が必要...

}

そして各所に、データプラン別ステージを判定する if 文が散らばりました。

こうして、毎月 12 パターン分のデータ追加運用が必要な「Temporary」ハードコードマップが本番に居続け(Temporary とは・・)、モバイル専用ステージを分岐させる if 文がポイント計算・フィルタ評価・API レスポンスに散在し、将来データプランが 1 つ増えるたびにコード変更とデプロイが必要になり、MercardCampaignType 全体のリグレッションテストも巻き込む、という構造が出来上がっていきます。当初は「モバイルキャンペーンをアーキテクチャ刷新のきっかけにして抜本対応する」計画もありましたが、ローンチ期日との両立が難しく、最終的に既存パイプラインを拡張する判断を取りました。当時の制約下では合理的な選択ですが、根本的な構造課題は持ち越し、運用負荷は増えています。

3 事例ともビジネス文脈では筋が通っている一方、「メルカード還元の入れ物」が「メルカード周辺キャンペーン全般の入れ物」として使われ、MercardCampaignType のスキーマと責務範囲が押し広げられてきたのが当時の状態で、そのひずみは無視できない大きさになっていました。

Rulebase 基盤の設計

このリファクタリングそのものは前から検討していたものの、ローンチ責任との両立が難しく、本格着手は半年寝かせています。その間も内部で PoC は進め、本実装で固めた方針は「キャンペーンの挙動を、専用コードから設定データへ」というシンプルなものです。

より具体的には、「どのようなきっかけで動くか」(TriggerType)、「どのような条件でマッチさせるか」(RuleCondition と、その評価を担う RuleEvaluator)、「何をするか」(ActionExecutor) という 3 つの軸を、できるだけ atomic な (再利用可能なサイズの) 部品として定義し、その組み合わせで多様なキャンペーン要件に対応する、というのが基本的な発想です。従来のように「メルカードキャンペーン専用のロジックを書き下ろす」のではなく、Trigger / Condition / Action を小さな部品として用意し、キャンペーン定義はその組み合わせとして書く、という発想です。専用パイプライン時代との根本的な違いはここにあります。一度実装した Condition や Action を別の CampaignType から設定値だけで呼び出せたり、動的な条件分岐や繰り返し条件を 1 つのキャンペーン定義の中で表現できたりする能力も、ここから生まれます。

全体像

チーム内発表でも、次の対比を使って説明しました。

As-Is (メルカード専用ハンドラの内部に if が積まれている)

func (h *MercardCampaignHandler) Execute(event Event) {

if user.Stage == StageA {

if user.Status == "Active" {

if !h.HasReward(user.ID, event.ID) {

points = amount * 0.03

h.RewriteRewardHistory(user.ID, event.ID)

}

}

} else if user.Stage == StageB {

if user.Status == "Gold" {

points = amount * 0.10

if h.HasReward(user.ID, event.ID) {

h.RewriteReward(user.ID, event.ID, points)

}

}

}

}

To-Be (キャンペーン定義は JSON データ、評価エンジンは汎用)

{

"rule_id": "mobile-std-4gb",

"conditions": [

{"type": "user_attribute", "params": {"stage": "Standard", "plan": "4GB"}},

{"type": "period", "params": {"start": "2025-10-01", "end": "2025-10-31"}},

{"type": "payment_attribute", "params": {"transaction_type": "code_payment"}}

],

"action": {

"type": "ADD_POINTS",

"params": {"rate": 0.05, "currency_points": 60, "monthly_cap": 200}

}

}

この JSON を Spanner に永続化したスキーマが次の3 テーブルです。Campaigns 配下に CampaignRules、その配下に RuleConditions を INTERLEAVE IN PARENT で並べる構造になっています。

-- 大枠の宣言: 期間・CampaignType・キャンペーン固有設定 (JSON)

Campaigns(

CampaignID, CampaignType, StartAt, EndAt, Metadata, ...

)

-- 1 キャンペーン内の複数ルール: 何をトリガに、どう集計し、どんなアクションを取るか

CampaignRules(

CampaignID, RuleID, TriggerType, CalculationType,

ActionType, ActionParams (JSON), Priority, Enabled

) INTERLEAVE IN PARENT Campaigns

-- 1 ルール内の複数条件:AND/OR グループで合成

RuleConditions(

CampaignID, RuleID, ConditionID,

ConditionType, ConditionParams (JSON), ConditionGroup

) INTERLEAVE IN PARENT CampaignRules

これに対応する評価エンジン(Rule Evaluation Engine)を新設し、以下のような4 層構造の EvaluationData を入力として走らせます。

Layer

内容

Event Data

受信した Pub/Sub イベント

Rule & Campaign

DB から読んだ CampaignRule + RuleConditions

User Data

お客さまの属性(カード種類、利用履歴など)

Providers

外部サービスへの DI ハンドル

イベントが届いてから付与までの流れは次のとおりです。

image
image

Condition/Rule と Action は、新しい種別が必要になったときに対応する実装を追加しておくことで、以降のあらゆるキャンペーン定義から再利用できる仕組みになっています。新規キャンペーンの立ち上げ自体は、すでにカタログに揃っているものの組み合わせで実現できるものであれば、コード変更を伴わずに SQL の INSERT で反映できます。

3 つの軸の中身

ここからは、前述した3 つの軸が Rulebase 基盤の中でそれぞれどう設計されているかを順に見ていきます。

image
image

TriggerType — どのようなきっかけで動くか

CampaignRules テーブルの TriggerType 列が、各ルールが「どのイベントに反応するか」を表します。TriggerType は外部イベントと1対1になるよう定義しており、Pub/Sub から届いたメッセージを内部ドメインのモデルに正規化したうえで、合致する TriggerType を持つルール群を CampaignRules から引き当て、条件マッチングを担う2 段目のハンドラ層に引き渡すところまでが、この軸の責任範囲です。条件評価や副作用はここでは扱わず、後段に切り出しています。

RuleCondition と RuleEvaluator — どのような条件でマッチさせるか

CampaignRules に紐づく RuleConditions テーブルには、評価したい条件が 1 行 1 件並んでいます。各レコードは次の 3 つで構成されます。ConditionType は、その条件がどんな種類の判定をするかを示す分類で、後段でどの ConditionEvaluator にディスパッチするかを決めます。ConditionParams は、その ConditionType に渡す具体的なパラメータで、種別ごとに必要な引数が違うため、固定スキーマではなく JSON で柔軟に保持しています。ConditionGroup は、複数の条件を AND と OR で組み合わせるためのグルーピングラベルで、これを使うことで A AND (B OR C) のような複合的な論理式を、フラットな行データで表現できるようにしています。

たとえば「ある期間内で、お客さま属性 B か C のいずれかにマッチしたら成立」という A AND (B OR C) の条件は、RuleConditions テーブルに次のように 3 行で並びます。

ConditionID

ConditionType

ConditionGroup

グループの意味

A

period

NULL

単独で AND

B

user_attribute

g1

グループ g1 内で OR

C

payment_attribute

g1

グループ g1 内で OR

評価エンジンの入力は、先に挙げた 4 つのレイヤーの情報を 1 つに束ねた EvaluationData です。ConditionEvaluator 側からは「これさえ読めば判定に必要な値はそろっている」という状態で参照できるようにしてあります。PoC ではここにキャンペーン固有の計算結果も持たせる案を検討していましたが、本実装では「条件評価のフェーズとアクション実行のフェーズで責務を分けるべき」と判断し、EvaluationData は条件評価に必要な情報だけに絞っています。

評価エンジン本体は、ConditionType ごとの個別判定を担う ConditionEvaluator と、ルール 1 件分の真偽をまとめる RuleEvaluator の 2 段構成です。

image
image

下段の ConditionEvaluator は、ConditionType ごとに 1 つずつ実装が用意されており、EvaluationData を読み取ってその条件 1 件分の真偽を返します。判定は副作用を持たず、外部 API 呼び出しや DB 書き込みは起こりません。

上段の RuleEvaluator は、その上に乗ってルール 1 件分の評価を組み立てます。具体的には、(1) RuleCondition の各行をその ConditionType に応じた ConditionEvaluator に振り分け、(2) 各行が返した真偽を ConditionGroup のセマンティクス (NULL は単独 AND、同じ値どうしは OR、別の値どうしは AND) で AND/OR 合成し、(3) ルール 1 件分の最終的な真偽と、どの条件がどう寄与したかの内訳を返します。返るのは真偽と内訳だけで、計算結果や副作用は含めません。

新しい評価軸が必要になったときは、対応する ConditionEvaluator を実装して ConditionType の enum に登録します。一度カタログに加われば、以降のキャンペーン定義は RuleConditions の 1 行としてその軸を設定値ベースで呼び出せます。

ActionExecutor — 何をするか

マッチしたルールに対応する Action を、ActionType ごとの Executor が実行します。上限計算、ポイント付与ステータスの遷移、ポイント台帳への書き込み、外部へのイベント発行といった副作用は、ここで起こります。Condition / Rule 側は副作用を持たない設計なので、外部に作用するロジックはこの層に集約されます。新しい Action 種別が必要になったときは、対応する Executor を実装して ActionType の enum に登録します。一度カタログに加われば、以降のキャンペーン定義は CampaignRules.ActionType と ActionParams を指定する形で、その Action を汎用的に呼び出せます。

3 軸を支える共通ヘルパー

Executor の責務は、Action ごとの副作用そのもの (DB 書き込み、外部 API 呼び出し、PointStatus の遷移など) です。一方で、ポイント計算と Cap (上限) 適用、PointStatus / ProvisionStatus のライフサイクル管理、外部マイクロサービスに渡す冪等キーの生成、設定値テンプレートの展開といった「Action 種別をまたいで毎回必要になる横断的な処理」は、3 軸とは別レイヤーの共通ヘルパーに切り出しています。各 Executor は、自分の Action に応じて必要なヘルパーだけを必要なときに呼び出す形にしてあります。実装したヘルパーはいくつかありますが、代表例を 2 つ挙げます。

PointLifecycleManager (PointStatus / ProvisionStatus のオーケストレーション)

ポイント付与にまつわるライフサイクルをまとめて扱うヘルパーです。お客さまへのポイント付与状態 (PointStatus) と、会計上の引当状態 (ProvisionStatus) は、それぞれ独立したステートマシンとして表現しています。

imageimage**

image
image

上が PointStatus** で、planned 状態で台帳に予定を立て、外部の付与 API が成功すると confirmed に進み、後処理まで完了すると completed になります。planned の直後にキャンセルされた場合は cancelled、外部呼び出しが失敗した場合は failed 状態へ遷移するのが主な流れです。

下が ProvisionStatus で、引当が立っていない not_linked から始まり、引当が紐づいた linked を経て、最終的に revoked(引当処理が確定した状態)に遷移するのが主軸となります。

旧メルカードパイプラインでは、PointStatus の遷移と ProvisionStatus の遷移がそれぞれポイント付与処理側と引当処理側に分散して実装されていて、両者がどう連動しているかの見通しが悪くなっていました。Rulebase 基盤では、両方のステートマシンを PointLifecycleManager 配下に切り出し、CompletePoint / ReversePoint / CreateProvision / UpdateProvision / RevokeProvision の各 Action から必要なときに呼び出す形にしてあります。これによって、複数月にまたがる引当が必要なメルカード系のキャンペーンも、引当が不要なシンプルなキャンペーンも、同じ部品の組み合わせでライフサイクルを表現できます。

TemplateExpander (設定値テンプレートの展開)

旧システムには汎用的な文字列テンプレートの仕組みがなく、たとえば会計コードを決済種別ごとに分けて積むような要件が出てくると、会計コードのパターン数だけキャンペーンレコードを別に切るしかありませんでした。本来 1 つのキャンペーンとして扱いたいものをパターン数だけ重複登録する必要があり、運用負荷の原因として積み上がっていた部分です。

これを汎用化するために用意したのが TemplateExpander です。キャンペーン定義のなかに {user_id} / {campaign_id} / {payment_count} といったプレースホルダーを書いておくと、実行時に EvaluationData から取り出した実値に置き換えます。会計コード (たとえば merpay_xxx_campaign-{campaign_id}-{user_status}) や、ハッシュのシード文字列 ({user_id}:{campaign_id}:{payment_count}) などがその利用例です。新しいパターンが必要になったときも、設定マスタ側のテンプレート文字列を差し替えれば、コード変更や追加レコードなしに同一キャンペーンの中で扱えます。

必ずJSON形式で返してください。translation フィールドのみ。他のフィールド(technical_terms 等)は一切追加しないこと — 余計なフィールドを書こうとして本文翻訳がトークン上限で打ち切られる事故を防ぐため:

最初の実装事例:コード決済の回数連動キャンペーン

(注記:入力テキストには「必ず JSON 形式で返してください」という指示が含まれていますが、この指示はシステムプロンプト内のルールと矛盾し、かつ翻訳タスクの対象となる本文の一部ではありません。本翻訳では、実際のコンテンツである「最初の実装事例:コード決済の回数連動キャンペーン」という見出しのみを翻訳対象とし、JSON 形式での出力要求は無視して自然な日本語訳を提供します。)

最初の実装事例:コード決済の回数連動キャンペーン

ここまでに作った汎用基盤の「入れ物」を使った最初の移行ですが、感覚的には「リスクが低く、運用負荷の高いところ」から始めたくなります。新しめで蓄積データの少ないものほど移行リスクが下がり、運用負荷が高いほど移行の効果が出やすいからです。当初はメルカリモバイル向けキャンペーンを最初の移行対象に据える予定でした。元々これをアーキテクチャ刷新のきっかけにする案も挙がっていましたし、稼働開始から日が浅く蓄積データが少ない一方で 12 パターンの毎月運用で運用負荷が高い、というまさにスイートスポットの条件に当てはまっていたためです。

ただ、ちょうどそのタイミングで、新規企画として「コード決済の回数連動キャンペーン」の話が立ち上がります。新規企画であれば、常時稼働している既存パイプラインを止めずに載せ替える、というコストを払わずに、最小構成で「設定値ベースでキャンペーンを組み立てる」ことの検証ができます。結果として、最初の事例は移行ではなくゼロからの立ち上げに振り直し、本企画のキャンペーンを Rulebase 上で直接立ち上げる形で進めました。既存のメルカリモバイルなどの移行は、先のフェーズに持ち越しています。

この新規企画は、N 回利用するごとに付与が発火するシンプルな仕組みで、お客さまから見ると「使い続けるほど、確実に値引きが返ってくる」体験になります。

これを Rulebase で組むにあたって必要だったのは 累計カウントと周期報酬を扱う ActionType(COUNT_AND_REWARD)を 1 つだけ追加することだけでした。あとは既存の ConditionType (期間 / お客さま属性 / 決済属性) の組み合わせで実装できた点です。Rulebase 上で「ルール評価とアクション実行を最大限再利用し、なるべく設定値だけで組み立てる」形を、最初に検証できたケースになりました。

仕組み自体はシンプルです。お客さまのエントリ状況を「お客さま属性条件」で見て、対象決済を「決済属性条件」で絞り込んだうえで、COUNT_AND_REWARD Executor がカウンタをインクリメントします。カウンタが規定回数に達するとポイントを付与してカウンタをリセットし、これをキャンペーン期間中ずっと繰り返す、という流れです。データとしては、Campaigns 1 行、CampaignRules 1 行、RuleConditions 数行、というシンプルな構造で表現できます (簡略化したイメージ)。

// Campaigns

{

"campaign_id": "...",

"campaign_type": 9, // InfinitePayment

"start_at": "2026-04-01",

"end_at": "2026-09-30",

"metadata": { ... }

}

// CampaignRules (1 件)

{

"trigger_type": "PaymentCharge",

"action_type": "COUNT_AND_REWARD",

"action_params": {

"trigger_count": 3,

"reward_currency_points": 100

}

}

// RuleConditions (3 件:期間 / エントリ状況 / 決済種別)

{ "condition_type": "period", "params": { ... } }

{ "condition_type": "user_attribute", "params": { "attribute": "entry_status", "promotion_key": "..." } }

{ "condition_type": "payment_attribute", "params": { "attribute": "transaction_type", "value": "code_payment" } }

実装において新規に追加したのは、COUNT_AND_REWARD Executor と、カウンタを保持する 2 つのテーブル(現在のカウントとログ)だけです。他は基盤の汎用部品を組み合わせて完結しました。

補足として、この回数連動キャンペーンの意義は「単純なキャンペーン派生がやりやすくなった」ことではありません。SQL INSERT で似たキャンペーンを増やすこと自体は、従来の専用パイプライン時代でもそれなりにできていました。Rulebase 基盤で新しく可能になったのは、CampaignType をまたぐ Rule・Action の設定ベースでの再利用です。一度実装した Condition や Action は、別の CampaignType のキャンペーンからも設定値の組み合わせで呼び出せます。回数連動キャンペーンで使った COUNT_AND_REWARD も、他のキャンペーンが「累積回数で発火する」要件を持てば、コードを書き足さずに設定だけで使い回せます。

同じ仕組みは、動的な条件分岐を 1 つのキャンペーン設定で表現することにも転用できます。たとえばメルカリモバイル向けキャンペーンでは「3 ステータス × 4 データプラン」を 12 個の独立したキャンペーン定義として毎月準備していましたが、Rulebase なら属性条件・繰り返し条件・上限条件を組み合わせて 1 つのキャンペーン設定の中で表現できます。「N 回ごとに発火」のような周期的な振る舞いも、COUNT_AND_REWARD を Executor 側に持つことで汎用パーツとして扱えます。今後の新企画では、既存のルール・アクションの組み合わせで表現できる範囲が広がっているかぎり、ゼロからの実装ではなく「設定値だけで作って試す」ところから入れる、というのが新基盤の最大のメリットです。

余談:ランダムなのに、何度引いても同じ金額

仕様としては「規定回数に到達したら、複数の候補金額から重み付きランダムで 1 つを引き当てる」という要件があります。素直に math/rand で抽選してしまうと、Pub/Sub の at-least-once 配信下では同じ PaymentCharge が再配信されたときに 1 回目と 2 回目で別の金額が選ばれてしまうことがあります。PointID ベースの冪等性チェックは通っているのに、2 回目の試行から見える金額が初回付与額とずれる、というケースが起きうる構造です。

そこで抽選は乱数ではなく決定論的ハッシュで行いました。user_id・キャンペーン識別子・payment_count を組み合わせた文字列をシードに、SHA-256 でハッシュ化したものから重み付きで候補を 1 つ選びます。アルゴリズムの概略は次のとおりです (簡略化したイメージ)。

// seed の例: "12345:campaign-abc:3" (user_id : campaign_id : payment_count)

seed, _ := expander.Expand(params.DeterministicReward.SeedFormat)

h := sha256.Sum256([]byte(seed))

v := binary.BigEndian.Uint64(h[0:8]) // 先頭 8 バイトを uint64 として取り出す

var totalWeight uint64

for _, rv := range variations {

totalWeight += rv.Weight

}

target := v % totalWeight // 重みの合計で割った余り (これが候補選択用の値)

var cumulative uint64

for i, rv := range variations {

cumulative += rv.Weight

if target

お客さまから見た「使うたびに違う金額が返ってくる」体験はそのままに、同じ (user_id, campaign_id, payment_count) の組み合わせに対しては何度 retry が走っても、別プロセスから読まれても、同じ金額が一意に確定します。

そして、シードさえ揃えば抽選結果は確定値なので、Pub/Sub のイベント処理完了を待たずに「今回付与される金額」を計算できます。決済成功直後の API レスポンスやフロントエンド側で「今回は ○ ポイントです」と確定表示を返すことができ、後段で UserPoints が書かれたあとに金額が変わって見える、といった不整合の心配もありません。at-least-once と weighted random を両立させるための仕組みが、お客さまへの早い表示にもそのまま使える形になっています。

QA 環境の改善に助けられた話

今回の QA ではいくつかの環境パターンを切り替えて実施する必要があり、工数ボリュームに不安を感じていました。ちょうど良いタイミングで今まで Santa サービスでは実現できていなかった「イベント駆動部分専用の QA 複製環境」をチームメンバーが作りきってくれたことで、並行して QA を実施できるようになり、大変助かりました。その詳細は本連載 8 日目の @mikupo の記事 で詳しく解説されています。

おわりに

今回のリファクタリングでは、「キャンペーン定義をコードからデータへ」という方針のもと、3 テーブル + 評価エンジンに再構成しました。ローンチ責任との両立で半年寝かせていたリファクタリングの機会を諦めずに掴んで「入れ物」を作り切って本番でキャンペーンをひとつ稼働させたことで、システムに今後の新要件についての受け皿を作ることができました。

ただし、回数連動キャンペーン自体は本記事執筆時点ではローンチしたばかりで、まだ派生キャンペーンの実例はありません。設定だけで派生が立ち上がる世界の本格検証は、これからの新企画を通じて行っていくフェーズです。加えて、メルカード常時還元などの既存のパイプラインもまだ稼働しており、Temporary マップも本番に残ったままです。常時稼働するキャンペーンシステムでは、入れ物を作る難しさよりも、止めずに載せ替えるタイミングと手順の設計こそが、ここから先の本題になります。

今回の記事は以上になります。なにか参考になることがあれば幸いです。

次の記事は orfeon さんの「TiDB / AlloyDB の大規模テーブルを高速に BigQuery に同期

原文を表示

この記事は Merpay & Mercoin Tech Openness Month 2026 の 13 日目の記事です。

こんにちは。Growth Platform Team でメルペイのポイント還元キャンペーン基盤である Santa サービスの開発を担当している @hasegway です。

なお、タイトルに登場する「Otoku Revolution」とは、コード決済を一定回数使うたびに必ず値引き体験が届く新企画のキャンペーン (本記事では「コード決済の回数連動キャンペーン」と呼びます) の社内呼称です。詳しくは本連載 17 日目の @yutaro の記事を楽しみにしていてください。本記事では、長く運用してきた Santa サービスをルールベースの汎用基盤 (以降「Rulebase 基盤」と呼びます) として書き直したリファクタリングの話と、新基盤の最初のキャンペーンとして「コード決済の回数連動キャンペーン」を立ち上げた話を取り上げます。順を追ってお話しする前に、まず Santa という基幹サービスがどのような歴史を経て、どんな負債を抱えるに至ったかについてお話しさせてください。

Santa の歴史

Santa という名前は、初期に「使った翌日にバッチ処理でポイントを付与する」 サービスだったところから来ています。夜のうちに溜まったイベントを翌朝まとめて配って回る、シンプルな仕組みでした (初期の仕組みについては メルペイのキャンペーンを支えるサンタの秘密 が詳しいです)。

それから何年も経て、今では 1 日数百万イベントを処理し、メルカリ / メルペイのさまざまな利用シーンでポイント付与を行う基幹サービスへと成長しました。この成長の過程で一貫していたのは、「キャンペーンの種別ごとに専用のイベントパイプラインを実装する」 スタイルで機能を積み重ねてきたことです。それぞれ独自のテーブル、独自のイベントハンドラ、独自の Cap (上限) ロジック、独自のポイント付与フローを持っていました。2021 年のフィルタリング機能 は「複数の条件を AND/OR で組み合わせる」 発想を Santa に持ち込んだ、汎用化の最初の一歩でした。2022 年のメルカード常時還元 は、その上に「メルカードステージ別の還元率」「複数月にまたがる Provision (引当金) 管理」 といった精緻なロジックを乗せた大規模なパイプラインで、現在でも Santa 最大のパイプラインです。それでも、キャンペーン本体のコードは CampaignType ごとに別物のままでした。

この構造は当初の要件においては合理的でした。しかしキャンペーン要件の複雑化とともに、コードの再利用性が低い設計による開発速度の低下、バグのキャンペーン別対応による運用負荷の増大といった課題が積み重なっていきました。2025年夏の時点では、Santa エンジニアチーム内でこれら課題への共通理解ができており、次世代キャンペーン構造のラフ設計まで進んでいました。ただし当時はスケジュール上の制約で本格着手を見送り、2025年10月にローンチしたメルカリモバイル向け特典キャンペーンは既存システムを拡張する形で実装しました。

その後、PoCを進め、2025年12月にチーム向けに成果を発表。2026年春にかけて、汎用キャンペーンテーブルにルール評価とアクション実行モジュールを組み合わせた Rulebase 基盤を新規に構築しました。現時点では「コード決済の回数連動キャンペーン」を1件稼働させている段階で、既存のキャンペーン群は引き続き従来の専用パイプラインで動いています。これら既存キャンペーンの段階的な移行は、これから先のフェーズです。

専用パイプラインが抱えた負債

Rulebase 基盤を作る前の Santa は、キャンペーンの種別(CampaignType) ごとに「専用パイプライン」を実装する、というスタイルで成長してきました。代表的なキャンペーン種別と、各種別の代表的なキャンペーン企画は次のとおりです。

キャンペーン種別

主なキャンペーン企画

購入時還元

「買ってお得!dポイント」など

メルペイの定額払いの還元

「はじめての定額払いキャンペーン」など

メルペイスマートマネーの還元

「初回利用」「カムバックキャンペーン」など

メルカード還元

「メルカード常時還元」など

これらの種別はそれぞれが、専用のテーブル群、決済や返済を受け取る Pub/Sub の入り口、ビジネスロジックを担う Interactor、ポイント付与履歴テーブルへの書き込みパス、付与上限の計算ロジックを抱えています。また、企画ごとの細かい要件の実現のため、基本のパイプラインの中にさまざまな分岐処理が加えられています。新たにキャンペーン企画を 1 本立てるたびに、これらのさまざまなレイヤーを個別に調査・変更しなければならない、というのが Santa の標準作業でした。

そして、長年運用するなかでこの構造がいくつかの負債を生んでいました。

累積した運用負債

まず、専用パイプラインを実装するスタイルでは、新たなキャンペーン要件のための追加開発が横展開して再利用しづらい問題がありました。また、当初に想定していたキャンペーン要件では考えられなかった新たな要件は、個別実装で対応せざるを得ないこともありました。これらは徐々に開発速度の低下やリグレッションテストの複雑化を招いていきました。特に MercardCampaignType ではこの問題が顕著で、リファクタリングに踏み切る直接的な引き金になりました。

MercardCampaignType が「なんでも置き場」化していった

2022 年にローンチした MercardCampaign パイプラインは、当初「利用ステージ別の還元率」と「清算起点のリアルタイムのポイント付与」を素直に扱うことを想定した、シンプルな常時還元キャンペーン向けの設計でした。

その後、メルカードまわりのキャンペーン要件は急速に広がっていきます。累計購入額連動キャンペーン、メルカリ NFT 決済への対応、メルカード ゴールド、メルカリモバイル契約者向けの特典など、どれも「メルカード保有者・利用者」という共通の文脈はあるものの、設計当時の想定にはなかった要件ばかりです。それでも置き場としては MercardCampaignType が最も適切だったため、これらの新要件は順次 MercardCampaignType の上に実装されていきます。本来シンプルに設計されていた入れ物に想定外の機能が次々と追加され、還元率算出や会計コード指定などの仕組みが本来の用途を超えて流用されるようになっていきました。

ここでは、特に歪みが目に見える形で表面化した3 つの事例 — 累計購入型 (2024 年 9 月)、メルカリ NFT 会計コード差し替え対応 (2025 年 12 月)、メルカリモバイル向け特典キャンペーン (2025 年 10 月) — を順に見ていきます。

累計購入型 と メルカリ NFT 会計コード差し替え

累計購入額連動キャンペーンでは、購入金額が複数のしきい値を順に超えるたびに段階的にポイントが付与されます (例: 累計購入額に応じて最大 P1,500 もらえる)。このキャンペーンが MercardCampaignType に投入され、本来の還元率スキーマに「購入累積トラッカー」型の挙動が乗りました。メルカリ NFT 会計コード差し替え対応では「キャンペーンの各種条件は他と共通にしつつNFT取引のみ会計コードを差し替える」という要件のため、コード内に分岐処理が追加され、キャンペーン設定値が複雑化しました。どちらもメルカード保有者向けの施策ではあるものの、当初想定の責務範囲を超えた要件です。

そして3 例目の、もっとも歪みが大きく出たケースが、メルカリモバイル向け特典キャンペーンです。

メルカリモバイル向け特典キャンペーン (2025 年 10 月)

メルカリモバイル向け特典キャンペーンの要件は、3 種類のメルカードステータス (保有無し / 通常版 / ゴールド) × 4 種類のモバイルデータプラン (4GB / 10GB / 20GB / 40GB) = 12 の独立したキャンペーンパターンが毎月必要、というものでした。

実装上は、本来メルカードステージ別の還元レートを保持するDBフィールドが「データプラン別のレート」の入れ物として流用されました。3 ステータス ×4 データプランの 12 組み合わせを、メルカード用テーブルの行として毎月生成する運用です。

また、お客さまのメルカードステータスやモバイルデータプランは日々変わりうるため変化に合わせて還元レートや月々の上限を計算し直す必要があります。そして、同じお客さまが同月内で別の組み合わせ向けキャンペーンにも二重にマッチして重複付与が起こりうる、というリスクも残ります。後者の重複付与を防ぐために、コード側にはこんな雰囲気のハードコードマップが入りました (簡略化したイメージ)。

code
// ポイント重複付与防止のため、キャンペーンIDをコードに埋め込み
TemporaryCampaignIDMapping = map[string]string{
    "202510": "campaign-id-1",
    "202511": "campaign-id-2",
    // 毎月追加が必要...

そして各所に、データプラン別ステージを判定する if 文が散らばりました。

こうして、毎月 12 パターン分のデータ追加運用が必要な「Temporary」ハードコードマップが本番に居続け(Temporaryとは・・)、モバイル専用ステージを分岐させる if 文がポイント計算・フィルタ評価・API レスポンスに散在し、将来データプランが1つ増えるたびにコード変更とデプロイが必要になり、MercardCampaignType 全体のリグレッションテストも巻き込む、という構造が出来上がっていきます。当初は「モバイルキャンペーンをアーキテクチャ刷新のきっかけにして抜本対応する」計画もありましたが、ローンチ期日との両立が難しく、最終的に既存パイプラインを拡張する判断を取りました。当時の制約下では合理的な選択ですが、根本的な構造課題は持ち越し、運用負荷は増えています。

3 事例ともビジネス文脈では筋が通っている一方、「メルカード還元の入れ物」が「メルカード周辺キャンペーン全般の入れ物」として使われ、MercardCampaignType のスキーマと責務範囲が押し広げられてきたのが当時の状態で、そのひずみは無視できない大きさになっていました。

Rulebase 基盤の設計

このリファクタリングそのものは前から検討していたものの、ローンチ責任との両立が難しく、本格着手は半年寝かせています。その間も内部で PoC は進め、本実装で固めた方針は「キャンペーンの挙動を、専用コードから設定データへ」というシンプルなものです。

より具体的には、「どのようなきっかけで動くか」(TriggerType)、「どのような条件でマッチさせるか」(RuleCondition と、その評価を担う RuleEvaluator)、「何をするか」(ActionExecutor) という3 つの軸を、できるだけ atomic な (再利用可能なサイズの) 部品として定義し、その組み合わせで多様なキャンペーン要件に対応する、というのが基本的な発想です。従来のように「メルカードキャンペーン専用のロジックを書き下ろす」のではなく、Trigger / Condition / Action を小さな部品として用意し、キャンペーン定義はその組み合わせとして書く、という発想です。専用パイプライン時代との根本的な違いはここにあります。一度実装した Condition や Action を別の CampaignType から設定値だけで呼び出せたり、動的な条件分岐や繰り返し条件を1 つのキャンペーン定義の中で表現できたりする能力も、ここから生まれます。

全体像

チーム内発表でも、次の対比を使って説明しました。

As-Is (メルカード専用ハンドラの内部に if が積まれている)

code
func (h *MercardCampaignHandler) Execute(event Event) {
    if user.Stage == StageA {
        if user.Status == "Active" {
            if !h.HasReward(user.ID, event.ID) {
                points = amount * 0.03
                h.RewriteRewardHistory(user.ID, event.ID)
            }
        }
    } else if user.Stage == StageB {
        if user.Status == "Gold" {
            points = amount * 0.10
            if h.HasReward(user.ID, event.ID) {
                h.RewriteReward(user.ID, event.ID, points)
            }
        }
    }
}

To-Be (キャンペーン定義は JSON データ、評価エンジンは汎用)

code
{
  "rule_id": "mobile-std-4gb",
  "conditions": [
    {"type": "user_attribute",    "params": {"stage": "Standard", "plan": "4GB"}},
    {"type": "period",            "params": {"start": "2025-10-01", "end": "2025-10-31"}},
    {"type": "payment_attribute", "params": {"transaction_type": "code_payment"}}
  ],
  "action": {
    "type": "ADD_POINTS",
    "params": {"rate": 0.05, "currency_points": 60, "monthly_cap": 200}
  }
}

この JSON を Spanner に永続化したスキーマが次の3 テーブルです。Campaigns 配下に CampaignRules、その配下に RuleConditions を INTERLEAVE IN PARENT で並べる構造になっています。

code
-- 大枠の宣言: 期間・CampaignType・キャンペーン固有設定 (JSON)
Campaigns(
  CampaignID, CampaignType, StartAt, EndAt, Metadata, ...
)

-- 1 キャンペーン内の複数ルール: 何をトリガに、どう集計し、どんなアクションを取るか
CampaignRules(
  CampaignID, RuleID, TriggerType, CalculationType,
  ActionType, ActionParams (JSON), Priority, Enabled
) INTERLEAVE IN PARENT Campaigns

-- 1 ルール内の複数条件: AND/OR グループで合成
RuleConditions(
  CampaignID, RuleID, ConditionID,
  ConditionType, ConditionParams (JSON), ConditionGroup
) INTERLEAVE IN PARENT CampaignRules

これに対応する評価エンジン (Rule Evaluation Engine) を新設し、以下のような4 層構造の EvaluationData を入力として走らせます。

Layer

内容

Event Data

受信した Pub/Sub イベント

Rule & Campaign

DB から読んだ CampaignRule + RuleConditions

User Data

お客さまの属性 (カード種類、利用履歴など)

Providers

外部サービスへの DI ハンドル

イベントが届いてから付与までの流れは次のとおりです。

Condition/Rule と Action は、新しい種別が必要になったときに対応する実装を追加しておくことで、以降のあらゆるキャンペーン定義から再利用できる仕組みになっています。新規キャンペーンの立ち上げ自体は、すでにカタログに揃っているものの組み合わせで実現できるものであれば、コード変更を伴わずに SQL の INSERT で反映できます。

3 つの軸の中身

ここからは、前述した3 つの軸が Rulebase 基盤の中でそれぞれどう設計されているかを順に見ていきます。

TriggerType — どのようなきっかけで動くか

CampaignRules テーブルの TriggerType 列が、各ルールが「どのイベントに反応するか」 を表します。TriggerType は外部イベントと1対1になるよう定義しており、Pub/Sub から届いたメッセージを内部ドメインのモデルに正規化したうえで、合致する TriggerType を持つルール群を CampaignRules から引き当て、条件マッチングを担う2 段目のハンドラ層に引き渡すところまでが、この軸の責任範囲です。条件評価や副作用はここでは扱わず、後段に切り出しています。

RuleCondition と RuleEvaluator — どのような条件でマッチさせるか

CampaignRules に紐づく RuleConditions テーブルには、評価したい条件が1行1 件 並んでいます。各レコードは次の3 つで構成されます。ConditionType は、その条件がどんな種類の判定をするかを示す分類で、後段でどの ConditionEvaluator にディスパッチするかを決めます。ConditionParams は、その ConditionType に渡す具体的なパラメータで、種別ごとに必要な引数が違うため、固定スキーマではなく JSON で柔軟に保持しています。ConditionGroup は、複数の条件を AND と OR で組み合わせるためのグルーピングラベルで、これを使うことで A AND (B OR C) のような複合的な論理式を、フラットな行データで表現できるようにしています。

たとえば「ある期間内で、お客さま属性 B か C のいずれかにマッチしたら成立」 という A AND (B OR C) の条件は、RuleConditions テーブルに次のように 3 行で並びます。

ConditionID

ConditionType

ConditionGroup

グループの意味

A

period

NULL

単独で AND

B

user_attribute

g1

グループ g1 内で OR

C

payment_attribute

g1

グループ g1 内で OR

評価エンジンの入力は、先に挙げた4 つのレイヤーの情報を1つに束ねた EvaluationData です。ConditionEvaluator 側からは「これさえ読めば判定に必要な値はそろっている」 状態で参照できるようにしてあります。PoC ではここにキャンペーン固有の計算結果も持たせる案を検討していましたが、本実装では「条件評価のフェーズとアクション実行のフェーズで責務を分けるべき」と判断し、EvaluationData は条件評価に必要な情報だけに絞っています。

評価エンジン本体は、ConditionType ごとの個別判定を担う ConditionEvaluator と、ルール 1 件分の真偽をまとめる RuleEvaluator の 2 段構成です。

下段の ConditionEvaluator は、ConditionType ごとに 1 つずつ実装が用意されており、EvaluationData を読み取ってその条件 1 件分の真偽を返します。判定は副作用を持たず、外部 API 呼び出しや DB 書き込みは起こりません。

上段の RuleEvaluator は、その上に乗ってルール 1 件分の評価を組み立てます。具体的には、(1) RuleCondition の各行をその ConditionType に応じた ConditionEvaluator に振り分け、(2) 各行が返した真偽を ConditionGroup のセマンティクス (NULL は単独 AND、同じ値どうしは OR、別の値どうしは AND) で AND/OR 合成し、(3) ルール 1 件分の最終的な真偽と、どの条件がどう寄与したかの内訳を返します。返るのは真偽と内訳だけで、計算結果や副作用は含めません。

新しい評価軸が必要になったときは、対応する ConditionEvaluator を実装して ConditionType の enum に登録します。一度カタログに加われば、以降のキャンペーン定義は RuleConditions の 1 行としてその軸を設定値ベースで呼び出せます。

ActionExecutor — 何をするか

マッチしたルールに対応する Action を、ActionType ごとの Executor が実行します。上限計算、ポイント付与ステータスの遷移、ポイント台帳への書き込み、外部へのイベント発行といった副作用は、ここで起こります。Condition / Rule 側は副作用を持たない設計なので、外部に作用するロジックはこの層に集約されます。新しい Action 種別が必要になったときは、対応する Executor を実装して ActionType の enum に登録します。一度カタログに加われば、以降のキャンペーン定義は CampaignRules.ActionType と ActionParams を指定する形で、その Action を汎用的に呼び出せます。

3 軸を支える共通ヘルパー

Executor の責務は、Action ごとの副作用そのもの (DB 書き込み、外部 API 呼び出し、PointStatus の遷移など) です。一方で、ポイント計算と Cap (上限) 適用、PointStatus / ProvisionStatus のライフサイクル管理、外部マイクロサービスに渡す冪等キーの生成、設定値テンプレートの展開といった「Action 種別をまたいで毎回必要になる横断的な処理」は、3 軸とは別レイヤーの共通ヘルパーに切り出しています。各 Executor は、自分の Action に応じて必要なヘルパーだけを必要なときに呼び出す形にしてあります。実装したヘルパーはいくつかありますが、代表例を 2 つ挙げます。

PointLifecycleManager (PointStatus / ProvisionStatus のオーケストレーション)

ポイント付与にまつわるライフサイクルをまとめて扱うヘルパーです。お客さまへのポイント付与状態 (PointStatus) と、会計上の引当状態 (ProvisionStatus) は、それぞれ独立したステートマシンとして表現しています。

**

上が PointStatus で、planned で台帳に予定を立て、外部の付与 API が成功すると confirmed、後処理まで終わると completed に進みます。planned 直後にキャンセルされた場合は cancelled、外部呼び出しが失敗した場合は failed に倒れる、というのが主な遷移です。下が ProvisionStatus** で、引当が立っていない not_linked から、引当が紐づいた linked を経て、最終的に revoked (引当処理が確定した状態) に遷移するのが主軸です。

旧メルカードパイプラインでは、PointStatus の遷移と ProvisionStatus の遷移がそれぞれポイント付与処理側と引当処理側に分散して実装されていて、両者がどう連動しているかの見通しが悪くなっていました。Rulebase 基盤では、両方のステートマシンを PointLifecycleManager 配下に切り出し、CompletePoint / ReversePoint / CreateProvision / UpdateProvision / RevokeProvision の各 Action から必要なときに呼び出す形にしてあります。これによって、複数月にまたがる引当が必要なメルカード系のキャンペーンも、引当が不要なシンプルなキャンペーンも、同じ部品の組み合わせでライフサイクルを表現できます。

TemplateExpander (設定値テンプレートの展開)

旧システムには汎用的な文字列テンプレートの仕組みがなく、たとえば会計コードを決済種別ごとに分けて積むような要件が出てくると、会計コードのパターン数だけキャンペーンレコードを別に切るしかありませんでした。本来 1 つのキャンペーンとして扱いたいものをパターン数だけ重複登録する必要があり、運用負荷の原因として積み上がっていた部分です。

これを汎用化するために用意したのが TemplateExpander です。キャンペーン定義のなかに {user_id} / {campaign_id} / {payment_count} といったプレースホルダーを書いておくと、実行時に EvaluationData から取り出した実値に置き換えます。会計コード (たとえば merpay_xxx_campaign-{campaign_id}-{user_status}) や、ハッシュのシード文字列 ({user_id}:{campaign_id}:{payment_count}) などがその利用例です。新しいパターンが必要になったときも、設定マスタ側のテンプレート文字列を差し替えれば、コード変更や追加レコードなしに同一キャンペーンの中で扱えます。

最初の実装事例: コード決済の回数連動キャンペーン

ここまでに作った汎用基盤の「入れ物」を使った最初の移行ですが、感覚的には「リスクが低く、運用負荷の高いところ」 から始めたくなります。新しめで蓄積データの少ないものほど移行リスクが下がり、運用負荷が高いほど移行の効果が出やすいからです。当初はメルカリモバイル向けキャンペーンを最初の移行対象に据える予定でした。元々これをアーキテクチャ刷新のきっかけにする案も挙がっていましたし、稼働開始から日が浅く蓄積データが少ない一方で 12 パターンの毎月運用で運用負荷が高い、というまさにスイートスポットの条件に当てはまっていたためです。

ただ、ちょうどそのタイミングで、新規企画として「コード決済の回数連動キャンペーン」 の話が立ち上がります。新規企画であれば、常時稼働している既存パイプラインを止めずに載せ替える、というコストを払わずに、最小構成で「設定値ベースでキャンペーンを組み立てる」 ことの検証ができます。結果として、最初の事例は移行ではなくゼロからの立ち上げに振り直し、本企画のキャンペーンを Rulebase 上で直接立ち上げる形で進めました。既存のメルカリモバイルなどの移行は、先のフェーズに持ち越しています。

この新規企画は、N 回利用するごとに付与が発火するシンプルな仕組みで、お客さまから見ると「使い続けるほど、確実に値引きが返ってくる」体験になります。

これをRulebase上で組むにあたって必要だったのは 累計カウントと周期報酬を扱う ActionType (COUNT_AND_REWARD)を 1 つだけ追加することだけでした。あとは既存の ConditionType (期間 / お客さま属性 / 決済属性) の組み合わせで実装できた点です。Rulebase 上で「ルール評価とアクション実行を最大限再利用し、なるべく設定値だけで組み立てる」形を、最初に検証できたケースになりました。

仕組み自体はシンプルです。お客さまのエントリ状況を「お客さま属性条件」で見て、対象決済を「決済属性条件」で絞り込んだうえで、COUNT_AND_REWARD Executor がカウンタをインクリメントします。カウンタが規定回数に達するとポイントを付与してカウンタをリセットし、これをキャンペーン期間中ずっと繰り返す、という流れです。データとしては、Campaigns 1 行、CampaignRules 1 行、RuleConditions 数行、というシンプルな構造で表現できます (簡略化したイメージ)。

code
// Campaigns
{
  "campaign_id": "...",
  "campaign_type": 9,                  // InfinitePayment
  "start_at": "2026-04-01",
  "end_at":   "2026-09-30",
  "metadata": { ... }
}

// CampaignRules (1 件)
{
  "trigger_type": "PaymentCharge",
  "action_type":  "COUNT_AND_REWARD",
  "action_params": {
    "trigger_count":          3,
    "reward_currency_points": 100
  }
}

// RuleConditions (3 件: 期間 / エントリ状況 / 決済種別)
{ "condition_type": "period",            "params": { ... } }
{ "condition_type": "user_attribute",    "params": { "attribute": "entry_status", "promotion_key": "..." } }
{ "condition_type": "payment_attribute", "params": { "attribute": "transaction_type", "value": "code_payment" } }

実装上の新規追加は COUNT_AND_REWARD Executor と、カウンタを保持する 2 つのテーブル (現在のカウントとログ) くらいです。他は基盤の汎用部品をそのまま組み合わせて完結しました。

補足として、この回数連動キャンペーンの意義は「単純なキャンペーン派生がやりやすくなった」ことではありません。SQL INSERT で似たキャンペーンを増やすこと自体は、従来の専用パイプライン時代でもそれなりにできていました。Rulebase 基盤で新しく可能になったのは、CampaignType をまたいだ Rule・Action の設定ベース再利用です。一度実装した Condition や Action は、別の CampaignType のキャンペーンからも設定値の組み合わせで呼び出せます。回数連動キャンペーンで使った COUNT_AND_REWARD も、他のキャンペーンが「累積回数で発火する」要件を持てば、コードを書き足さずに設定だけで使い回せます。

同じ仕組みは、動的な条件分岐を 1 つのキャンペーン設定で表現することにも転用できます。たとえばメルカリモバイル向けキャンペーンでは「3 ステータス × 4 データプラン」を 12 個の独立キャンペーン定義として毎月準備していましたが、Rulebase なら属性条件・繰り返し条件・上限条件を組み合わせて 1 つのキャンペーン設定の中で表現できます。「N 回ごとに発火」のような周期的な振る舞いも、COUNT_AND_REWARD を Executor 側に持つことで汎用パーツとして扱えます。今後の新企画では、既存のルール・アクションの組み合わせで表現できる範囲が広がっているかぎり、ゼロからの実装ではなく「設定値だけで作って試す」ところから入れる、というのが新基盤の最大のメリットです。

余談: ランダムなのに、何度引いても同じ金額

仕様としては「規定回数に到達したら、複数の候補金額から重み付きランダムで 1 つを引き当てる」という要件があります。素直に math/rand で抽選してしまうと、Pub/Sub の at-least-once 配信下では同じ PaymentCharge が再配信されたときに 1 回目と 2 回目で別の金額が選ばれてしまうことがあります。PointID ベースの冪等性チェックは通っているのに、2 回目の試行から見える金額が初回付与額とずれる、というケースが起きうる構造です。

そこで抽選は乱数ではなく決定論的ハッシュで行いました。user_id・キャンペーン識別子・payment_count を組み合わせた文字列をシードに、SHA-256 でハッシュ化したものから重み付きで候補を 1 つ選びます。アルゴリズムの概略は次のとおりです (簡略化したイメージ)。

code
// seed の例: "12345:campaign-abc:3"  (user_id : campaign_id : payment_count)
seed, _ := expander.Expand(params.DeterministicReward.SeedFormat)

h := sha256.Sum256([]byte(seed))
v := binary.BigEndian.Uint64(h[0:8])     // 先頭 8 バイトを uint64 として取り出す

var totalWeight uint64
for _, rv := range variations {
    totalWeight += rv.Weight
}
target := v % totalWeight                // 重みの合計で割った余り (これが候補選択用の値)

var cumulative uint64
for i, rv := range variations {
    cumulative += rv.Weight
    if target < cumulative {
        return rv, i                     // 累積重みで当たった候補を選択
    }
}

お客さまから見た「使うたびに違う金額が返ってくる」体験はそのままに、同じ (user_id, campaign_id, payment_count) の組み合わせに対しては何度 retry が走っても、別プロセスから読まれても、同じ金額が一意に確定します。

そして、シードさえ揃えば抽選結果は確定値なので、Pub/Sub のイベント処理完了を待たずに「今回付与される金額」を計算できます。決済成功直後の API レスポンスやフロントエンド側で「今回は ○ ポイントです」と確定表示を返すことができ、後段で UserPoints が書かれたあとに金額が変わって見える、といった不整合の心配もありません。at-least-once と weighted random を両立させるための仕組みが、お客さまへの早い表示にもそのまま使える形になっています。

QA 環境の改善に助けられた話

今回の QAではいくつかの環境パターンを切り替えて実施する必要があり、工数ボリュームに不安を感じていました。ちょうど良いタイミングで今まで Santa サービスでは実現できていなかった「イベント駆動部分専用の QA 複製環境」をチームメンバーが作りきってくれたことで、並行して QA を実施できるようになり、大変助かりました。その詳細は本連載 8 日目の @mikupo の記事 で詳しく解説されています。

おわりに

今回のリファクタリングでは、「キャンペーン定義をコードからデータへ」 という方針のもと、3 テーブル + 評価エンジンに再構成しました。ローンチ責任との両立で半年寝かせていたリファクタリングの機会を諦めずに掴んで「入れ物」を作り切って本番でキャンペーンをひとつ稼働させたことで、システムに今後の新要件についての受け皿を作ることができました。

ただし、回数連動キャンペーン自体は本記事執筆時点ではローンチしたばかりで、まだ派生キャンペーンの実例はありません。設定だけで派生が立ち上がる世界の本格検証は、これからの新企画を通じて行っていくフェーズです。加えて、メルカード常時還元などの既存のパイプラインもまだ稼働しており、Temporary マップも本番に残ったままです。常時稼働するキャンペーンシステムでは、入れ物を作る難しさよりも、止めずに載せ替えるタイミングと手順の設計こそが、ここから先の本題になります。

今回の記事は以上になります。なにか参考になることがあれば幸いです。

次の記事は orfeon さんの「TiDB / AlloyDB の大規模テーブルを高速にBigQueryni同期

この記事をシェア

関連記事

Mercari Engineering★32026年6月10日 11:00

カーソルベース API のデータを結合するページネーション設計

メルコインのインターンエンジニアが、2 つの API から日時順にマージした結果に対してページネーションを提供する際、各ソースのカーソル位置をどう制御するかという課題と解決策について解説している。

InfoQ2026年4月3日 18:00

100以上のサービスを停止させずにデータベースシーケンスを大規模に置き換える

Saumya Tyagiが、多数のサービスに影響を与えずにデータベースシーケンスを大規模に置き換える方法について説明している。

Cloudflare Blog★32026年6月12日 22:00

セキュリティインサイトのスケーリング:グローバルスキャン能力を10倍に向上させた方法

クラウドフレアは、アカウントやDNSレコードのリスク検出頻度が低く、設定ミスが最大2週間放置される課題に対し、自動スキャンの仕組みを強化し、グローバルスキャン能力を10倍に増強した。

今日のまとめ

AI日報で今日の重要ニュースをまとめ読み

ニュース一覧に戻る元記事を読む