TECH PLAY

株式会社メルカリ

株式会社メルカリ の技術ブログ

288

はじめに こんにちは。メルペイの @yutaro です。 「 Merpay & Mercoin Tech Openness Month 2026 」17日目として、メルペイの決済をもっと楽しく、繰り返し使いたくなる体験にするための取り組み(Otoku Revolution)について紹介します。 私は普段、Payment Platform の Balance Team で、メルペイ残高やポイントの状態管理、債権管理、会計連携、法定帳簿(法律上必要な帳簿)等の開発・運用をおこなっています。 この記事でシェアしたいのは、新鮮な体験を作るときほど、早い段階で「決済では何が起きるか」「返金や失効ではどう扱うか」「お金の流れはどうなるか」のような具体的な部分のイメージも煮詰めておくと、実装も追加施策も速く進められる、という実例です。 Otoku Revolution とは Otoku Revolution は、一言でいうと「決済するほど次の決済がおトクになる」体験を、メルペイの既存の決済基盤の上で実現するプロジェクトです。 実際の施策は、コード決済するたびにスタンプが貯まり、3回決済すると値引き特典を受け取り、次回決済時に自動で値引きされる新しい還元プログラムになりました。 (実際の決済後の画面) 決済する、スタンプがたまる、値引き特典を受け取る、受け取った特典が次回の決済で自動的に使われる。というような流れで、クーポンを選んだり、後日ポイントを受け取ったりするのではなく、次の決済で自然におトクになる体験を目指しました。 2026年5月中旬から、メルペイをご利用のお客さまの約半数を対象に試験的な導入を進めています。 決済基盤の上でこの体験(即時値引き)を作るには、キャンペーン条件、値引きを実現するリワード、決済処理、履歴、会計上の扱いを矛盾なくつなぐ必要があります。 構想初期には、周辺リポジトリをすべてcloneし、Claude Codeに読ませながら、関係しそうなサービスとデータの流れを図に起こしていました。 次のスクリーンショットは、そのときの初期アーキテクチャ図です。細部はその後整理していますが、議論のたたき台としては十分に現実に近く、どのサービスで何を扱うべきかを早い段階でそろえる助けになりました。 ここからは、この還元プログラムを実現するために値引きポイント DISCOUNT_POINT をなぜ選んだのかを紹介します。 なぜ DISCOUNT_POINT を作ったのか 最初の大きな判断は、この特典をどのような形で実現するかでした。 検討した選択肢は、大きく3つありました。 ポイントバック: 既存のポイント付与の仕組みで実現しやすい。一方、即時ではなく後日ポイントを受け取る体験になる クーポン: 特典として理解しやすい。一方、お客さまが選んで使うものになりやすく、既存の仕組みで即時反映を実現しにくかった 値引き専用の残高(DISCOUNT POINT): 完全に新しい即時値引き専用の残高を作成し、対象の決済で最優先で使われるようにする 3つ比較した結果、即時値引きという体験を実現するためには、値引き専用の残高を作成する必要があるという判断になりました。 これは通常のポイントとは別の、値引きにだけ使えるお客さま向けの残高です。 内部的には残高のように扱えますが、お客さま向けにはすぐに次の決済に使えるクーポンのように振る舞います。次回の決済の値引きを実現したいので、決済金額が値引き額より小さい場合は、使い切れなかった分を失効させます。 DISCOUNT_POINT をスピーディーに実装するために役立ったのが、約1年半前から稼働している新しい残高管理システムである Balance V2 です。 Balance V2 では、残高の種類を後から柔軟に追加できる設計になっており、種類ごとに使われ方や会計連携を宣言的に定義できます。また、残高の付与や消費、失効に伴う変動があった場合に自動で会計連携されるようになっています。 このプロジェクトではその汎用性を活かし、施策専用のサービスを立ち上げるのではなく、 DISCOUNT_POINT という新しい残高の一種として即時値引き特典を扱う設計にしました。 結果的に、全く新しい種類のクーポンであるはずの即時値引き特典を、決済基盤上では残高の延長線上で扱い実装できるようになりました。 DISCOUNT_POINT の全体像 DISCOUNT_POINT は、決済完了後にスタンプが3回溜まっていれば付与されます。付与後、次の決済前に利用可能な残高として参照され、決済手段に最優先で使われる残高として組み込まれ、通常の残高やポイント・メルペイのクレジットより先に使われます。 例えば、 DISCOUNT_POINT を100円分もっているお客さまが300円分の商品を購入しようとすると、100円分が優先的に DISCOUNT_POINT から消費され、他の残高等の決済手段から200円分が消費されます。 このように、お客さま目線では200円分の負担で300円分の決済ができた。という値引き体験を実現できています。 このような体験を実現するために、このプロジェクトではサービスの役割を分けました。冒頭の初期アーキテクチャ図は細かい実装寄りの図なので、ここでは大きく「決済前」「決済時」「決済後」の3つに整理しています。実際の構成にはさらに細かい処理や例外がありますが、大きな役割だけに絞ると次のようになります。括弧内は、社内で使っているサービス名です。 service 主な役割 特典付与サービス (santa-service) スタンプの管理、付与額の決定 支払い方法設定サービス (payment-config-service) お客さまの設定や状況ごとの決済手段を決定 決済サービス(payment-service) 渡された決済手段どおりに支払いを実施する 残高管理サービス(balance-service) DISCOUNT_POINT の残高管理、消費、失効、会計連携 このように役割を分けると、santa-serviceは条件判定、balance-serviceは残高の増減、payment-serviceは渡された内訳どおりに決済を成立させることに集中できます。 お客さまにはひとつの施策に見えるものを、裏側ではスタンプ、決済に使う残高やポイント、履歴表示、会計上の記録に分けて考えられるようになり、それぞれの影響範囲について議論や意思決定がしやすくなりました。 実際のキャンペーンの条件や例外の多くは、santa-serviceで扱っています。@hasegway さんの「 メルペイのキャンペーン基盤をルールベース汎用システムに書き直し、Otoku Revolutionするまでの話 」 では、このような複雑なキャンペーンをルール組み合わせとして扱えるようにした Rulebase 移行について紹介されています。 次の章から、付与、利用、返金・失効、会計 についての詳細をそれぞれ紹介していきます。 付与: 還元で決済を止めない Otoku Revolution、は決済完了後のスタンプ更新から始まります。 決済が完了すると、payment-serviceから非同期のeventが発行されます。santa-serviceはそのeventを受け取り、キャンペーンの対象のユーザーかどうかを確認してスタンプの付与を行います。ここで、スタンプが3回分に到達した場合は特典額を決め、balance-serviceに DISCOUNT_POINT の付与を行います。 ここで大事だったのは、 スタンプ更新や値引きポイント付与を、決済そのものの成功条件にしないこと です。 この還元プログラムの体験としては「決済後に即時でスタンプ・ DISCOUNT_POINT の付与が行われる」が理想です。一方で、スタンプ更新や DISCOUNT_POINT の付与までを決済処理の中で完了させようとすると、キャンペーン側の遅延や障害が決済成功率に影響してしまいます。 決済基盤では、決済そのものをキャンペーン側の都合で不安定にしないことが最優先です。そのため、決済完了後にあとからスタンプを更新し、条件達成時に DISCOUNT_POINT を付与する設計にしました。 利用: 最優先で使われるポイントとしてのDISCOUNT POINT 付与された DISCOUNT_POINT は、次の決済で自動的に使われます。 ここで設計上大事だったのは、payment-serviceの役割を広げすぎないことです。 直感的には、payment-serviceが残高を見て「 DISCOUNT_POINT があるなら使う」と決める設計も考えられます。この形にすると、値引きの判断を1か所に集めやすいという良さがあります。 しかし、payment-serviceは単に外部向けの決済を担当しているだけではなく、社内のありとあらゆるお金の流れに関与しており、できるだけ DISCOUNT_POINT に関係する専用のロジックは入りこまないようにしたいです。 そのため、決済前に支払い方法と金額を組み立てる payment-config-serviceが、支払い方法と DISCOUNT_POINT 残高を見て、 DISCOUNT_POINT を優先的に消費する決済内訳を作る設計にしました。payment-serviceはその内訳どおりに決済を進め、残高管理サービスが実際の残高消費を担います。 このような責務分担にすることで、コード決済等の決済手段ごとに値引きを適用するかどうかのルールを段階的決めることができるようにもなります。 返金・失効: クーポンとして振る舞うように設計 DISCOUNT_POINT は、通常のポイントのように貯め続けるものではなく、「スタンプがたまった次の決済で使える特典」です。 返金時や、決済金額が値引きポイントより小さいときも、不正対策やユーザービリティの観点でクーポンに近い挙動になるようにしました。 返金・失効では、主に次のように扱います。 返金時に、スタンプの数は戻さない 返金時に、使用済みの値引きポイントは再付与しない 決済金額より値引きポイントが大きい場合、使い切れなかった分は失効させる 例えば、1,000円の決済で100円分の値引きポイントを使ったあとに返金された場合でも、通常の返金処理の中でスタンプや値引きポイントの状態までは巻き戻しません。巻き戻す設計にすると、「再付与した値引きポイントをもう一度使えるのか」「スタンプも取り消すのか」「会計上は何を取り消すのか」を追加で扱う必要があり、スタンプカードとしてのルールも複雑になります。 また、500円分の値引きポイントを持っているお客さまが300円の決済をした場合は、300円分を消費し、残り200円は失効します。 DISCOUNT_POINT は残高として管理しつつ、次の決済だけで使われる特典に近い性質を持たせたかったためです。 このように整理することで、クーポンのような挙動のポイントである DISCOUNT_POINT を実現しています。 会計: お金の流れを先にデザイン DISCOUNT_POINT を新しい残高として扱う以上、アプリに表示して決済で使えるだけでは不十分です。付与された時点、使われた時点、失効した時点で会計上どう記録されるのかを説明できないまま進めると、後から設計を大きく見直すことになります。 そこで、まだ設計が固まりきる前に、プロジェクト全体の MoneyFlow、つまりお金がどう動くかを図にして、経理チームに相談しに行きました。 この図では、 DISCOUNT_POINT の付与・消費・失効が、会計上の記録につながることを描いています。全体ではなく一部だけを載せています。このプロジェクトでは早い段階からお金の流れを具体的な図にして議論していました。 実装が本格化する前に、お金の流れと会計上の意味を一緒に確認できたことは、後から振り返っても大きかったと思います。MoneyFlow そのものについては、@komatsu さんの 「 決済プラットフォームと経理を繋ぐ MoneyFlow 」に詳しくまとまっています。 Otoku Revolutionではこのように整理したうえで、 DISCOUNT_POINT の付与・消費・失効をbalance-serviceで扱い、会計連携までつなげて、会計レポートを作成できるようにしました。 リリース後、事前付与施策で効いた設計 リリース後には、多くのお客さまにこの還元プログラムを知ってもらうための施策も必要になりました。 そこで、対象のお客さまにあらかじめ DISCOUNT_POINT を付与する施策を追加で開始することにしました。キャンペーン開始直後のタイトなスケジュールの中、スピーディーに付与実施までもっていくことができました。 このような柔軟な追加施策を実施できたのは、単に実装が早かったからではありません。 DISCOUNT_POINT が残高管理サービスで管理される残高として扱われ、付与、消費、失効、会計上の記録の意味がすでに整理されていたことが効きました。 追加施策でも、付与する残高の種類、決済時の使われ方、使い切れなかった分の扱い、履歴での見え方、会計上の記録を新しく考え直す必要はありませんでした。「誰に、いくら、どの条件で付与するか」に集中できたことが、速度を出せたポイントかなと思います。 振り返り Otoku Revolutionでは、最初から「値引きポイントをどう見せるか」だけでなく、「決済では何が起きるか」「返金ではどう扱うか」「会計ではどう記録するか」をまとめて確認できたことが大きかったです。そのおかげで、 DISCOUNT_POINT を残高として付与し、決済で優先的に使うといアイディアで最後まで手戻りなくリリースまで実現できました。 個人的には、プロジェクトの構想段階から体験面の議論に混ぜてもらいながら、エンジニアとして実現性や整理が必要になりそうな点を少し先回りして確認しにいけたことは、私にとっても学びの多い進め方でした。体験を決めたあとに会計や法務、返金のルールに合わせて作り直すのではなく、最初からそれらを含めて設計できたことは大きかったと思います。普段 Balance Team で扱っている残高・会計・帳簿の知識が、お客さまに届く体験につながったことも印象に残っています。 決済基盤の仕事をしていると正しさや安全性を守る場面が多いですが、その知識の延長線上で新しい体験を実現できたことは、素直にうれしかったです。 次の記事は @kobaryo さんの「任意の単位での債権の色付けと与信の分割を実現する Debt View」です。私がこのプロジェクトに多くの時間を割いていた間、Balance Team の日々の業務を @kobaryo さんに大きく支えてもらっていました。次の記事もぜひ読んでみてください。
本記事は「 Merpay & Mercoin Tech Openness Month 2026 」の17日目の記事です。 この記事は新しいロールであるPdE (Product Engineer)というClient / Backend の境界をまたぐ「越境開発」ロールの取り組みについて、@anzai, @victoria Li, @ninnin, @panoramaの4名でお送りします。記事本文は、そのうちの1人である anzai が、自分の体験を一人称で振り返る形で書いています。 はじめに 「Client エンジニアが Backend を書き、Backend エンジニアが Client を書く」——そんな体制は実際に成立するのか。Q4 に試した小さな実験を紹介します。 ひとことでまとめると、Android エンジニアが Postgres のデータベース(DB)設計とサーバー実装を書き、Backend エンジニアが iOS/Android の画面を作りました。やってみて成立はしたものの、楽な道ではありません。何が効いて、どこで詰まったのか。本記事では「越境開発」というテーマに絞って、その実際を共有します。 越境開発の背景 私は普段メルペイでClient(Android / iOS)のチームを見ている Engineering Manager です。今回はManagerとしてではなく、自分の手でコードを書く一員として KYC(本人確認)領域のAI pod (小規模な開発チーム)に参加しました。 このチームでは、Product Manager(PdM)を置かず、エンジニアが1人で1プロジェクトを仕様策定からデリバリーまで一気通貫に持つ(1 Person 1 Release)、という体制を試しています。 そしてこの体制を回そうとすると、避けて通れない課題が1つ出てきます。それは「1人で End-to-End(E2E)に持つなら、Clientも Backend も自分で書くことになる」ということです。 KYC のプロジェクトは、ほとんどの場合 iOS / Android のClientと、サーバー側の API・DB の両方に手を入れます。従来はここをクライアント担当とサーバー担当で分けていました。1人1プロジェクトにするなら、その境界をまたぐ必要があります。E2Eで開発する中で「Client エンジニアが Backend も、Backend エンジニアが Client も開発できるか」を、実プロジェクトで検証することにしました。 いくつかのプロジェクトで Client-Backend を一気通貫で開発した結果、今期はどちらも成立しました。Client エンジニアがサーバーの DB と API を書いてリリースし、Backend エンジニアがモバイルの画面を出す。これ自体が、まず確かめたかったことです。 以下では、私がどう開発を進めたかを時系列で振り返り、そのあとで、どこで詰まったかを共有します。 AI を活用した越境開発の進め方 私はClientエンジニアです。今回担当したプロジェクトで最終的に必要としていたアウトプットは、具体的には2つでした。1つはお客さまに見せる UI/UX、もう1つは、その後の業務報告に使うレポートです。この2つの具体的なアウトプットから逆算して、「では、その裏側でどんな仕組みを実装しなければならないか」を決めていく必要がありました。そしてその仕組みの大部分が、私にとって初めてのサーバーサイド(Go / Postgres)です。 そこでまず、既存の実装を AI Agent に読み込ませ、「この2つのアウトプットを満たすには、どんな設計・実装が必要か」を AI に検討してもらうところから始めました。要件は手元にあるので、あとはそれを初めての言語と環境にどう落とすか。 この記事のここから先は、その道のりを時系列でたどります。 AI の説明もコードも、最初はわからない 最初にぶつかったのは、コードを書く以前の問題でした。AI Agent が返してくる説明そのものが、何を言っているのかわからないのです。 たとえば「Postgres を起動して、goose で既存の migration を流して、psql で確認して」と AI に言われても、最初は何を指しているのか、まったく理解できませんでした。goose とは何か、psql で何を確かめればいいのか ── そのひとつひとつが、Client 開発しかしてこなかった私には初めての言葉です。AI は正しいことを言っているのかもしれないけれど、こちらにそれを受け取る前提知識がない、という状態からのスタートでした。 ここでやったのは、ひたすら AI に問い返すことです。「それはどういう意味か」「図にして説明してくれ」と、いろいろなパターンで噛み砕いてもらう。そして自分が納得できたら、その理解を「こういうことですよね」と、今度は別のBackendエンジニアに確認しに行く。これを繰り返すことで、環境がどう動いていて、何をすればテストが回るのか、開発の前提条件をまず頭に入れていきました。 前提が見えてきたところで、いよいよ実装です。さきほどの要件をもとに「設計して実装してみて」と AI Agent にお願いし、コードを書いてもらいます。すると今度は、出てきたコードそのものが読めません。 なぜここで defer を呼んでいるのか。なぜ cancel() しなければならないのか。ここで context を受け取っているのは、つまりどういうことなのか。Go のイディオムが、ことごとくわからないのです。 そこで最初は、作ってもらったコードを一行一行、すべて AI に解説してもらいました。 context のキャンセルでリソースを解放しないと goroutine が漏れる、だからこの位置で defer cancel() する。そうした説明を1つずつ受けながら、文法書ではなく目の前の実コードを教材にして読み解いていきます。 なお、DB についてはまったくのゼロからではありませんでした。モバイルでも端末上の SQLite でテーブルを設計し、クエリを書く機会はあります。正規化やインデックスといった基礎概念そのものは持っていたので、サーバーの Postgres は「知っている概念を別のコンテキストに翻訳する」作業に近く、そこは助けになりました。 チャンクごとの並走型にたどり着く ただ、「全部書いてもらってから一行ずつ解説」では、どうしても効率が良くありません。これを変えたのが、チームのふりかえりでした。 私たちは週に1回レトロスペクティブを開き、チームの進め方を少しずつ改善していました。その中で、メンバーの一人が「チャンク(意味のある塊)ごとの並走型」というやり方を持ち込みます。AI に一気に全部を生成させるのではなく、意味のあるコードブロック単位で実装させる。そのブロックが「なぜ必要なのか」を自分が理解できればそのまま進め、理解できなければきちんと問い返す。「ここは A と B のやり方がありそうだけれど、なぜ A を選んだのか」と都度たずねていくことで、コードの一行ずつの意図を理解しながら前に進みます。 このやり方だと、巨大な生成物を上から順に読み下す必要がなくなり、開発の流れを「意味のある順序」で理解できます。機械的な行順ではなく、設計の意図に沿った順序で頭に入っていく感覚です。質問と回答はログとして残し、読み返せば「今日は何を学んだか」の復習にもなりました。 選んだ理由を残し、AI のコードを自分で理解する この進め方には、思わぬ副産物もありました。「いくつかの選択肢のなかで、なぜこの A を選んだのか」を開発の過程ですでに言語化しているので、それをそのまま Pull Request(PR)の説明に書けるのです。結果として、レビューはそれほど詰まることなく得られました。初めての領域でも、選択の根拠を添えられればレビュアーは判断しやすいですし、「ここまで考えた上でのPRなんだな」という信頼も得られます。ビギナーのPRを読むことはコードオーナーにとって大変な負担ですので、このような越境開発において信頼を作るための調査は必要な工程だったと思います。 ここで1つ、立ち止まって考えるべき論点があります。AI が書いたコードを、人間がわざわざ読んで理解する必要はあるのか。生成して、テストが通って、動けばよいのではないか。これはこれから重要になる議論だと思います。 私の現時点の答えは「理解すべき」です。 これからの生産性は、書いたコード量ではなく、意思決定の回数と質でほぼ決まっていくと考えています。そして意思決定の質と回数を上げるには、ドメインの理解が必要です。「このバックエンドの設計は、これで本当に正しいのか」を判断する場面で、いまのところ最終的な判断は人間がしています。その判断を下せるだけの理解を持っていないと、意思決定の回数も質も上がっていきません。だからこそ、AI が書いたコードでも中身を理解しておく価値がある、というのが今の立場です。 またAIの仕組み的に、永遠にそのPRに対して改善ポイントを考え出すことができます。たとえば、あるPRについて1回目のセッションでAIに聞くと A・B・C の3点を直すよう言われ、その修正結果を別のセッションで聞くと、今度は E・F・G の3点を直すよう言われる、ということが起こります。それを全部反映すると、最終的にはオーバーエンジニアリングした膨大なPRになりがちなので、現状は「どこまでが適切か」を人が判断する必要があるでしょう。 もしこの「理解する」のに2〜3年かかる長期投資なら、学ぶ理由はもっと厳しく問われるでしょう。ですが実際はそうではありませんでした。いまの AI Agent によるオンボーディングは本当にしやすく、いわば優秀な指導役が隣について、いつでもペアプロに付き合ってくれるような状況です。この環境であれば、3ヶ月もあれば、いま開発している領域の一通りの知識は得られるという手応えがありました。もちろん、もともとシニアエンジニアとしての経験がある、という条件付きではあります。投資回収が数年ではなく数ヶ月の単位に縮んでいるのなら、理解する側を選ぶのが合理的だと考えています。 設計の最終確認はシニアエンジニアと とはいえ、AI に聞けばそのまま採用、とはしませんでした。象徴的だったのがインデックスの設計です。 「このカラムにインデックスを張るべきか」を、AI の提案を鵜呑みにするのではなく、なぜ必要なのか/不要なのかを自分で根拠を持って調べました。どんなクエリパターンで引かれるのか、カーディナリティはどうか、読み取りが速くなるぶん書き込みコストとのトレードオフはどうなるのか。こうした観点を1つずつ確かめて、「この設計にはこういう理由がある」と説明できる状態にします。 それでも、最後はシニアエンジニアと一緒にレビューしました。正直に言えば、これは自分の自信のなさを埋めるためでもありました。AI が生成したものが本当に正しいのか、その最終的な判断が、私一人ではどうしてもつかなかったからです。 このレビューには、不安を消す以上の価値がありました。シニアエンジニアが、考え得るパターンをいくつも挙げてくれたうえで「では、この方法でいきましょう」と意思決定でき、いくつかの改善点も見つかりました。よりベターな やり方にたどり着くうえで、人と一緒に見てもらう工程はやはり必要だったように思います。こちらは Backend エンジニアが Client 開発をする場合も同様でした。 AI で下書きと理解を加速し、自分で根拠を固め、最終的には人とレビューする。この3段構えが、初めての領域で品質を担保し、かつ自分が安心して前に進むうえで、現実的なやり方でした。AI は出発点と伴走者としては非常に強力でしたが、設計の最終的な妥当性を見極め、選択肢を広げてくれるのは、まだ人間のレビューだった、というのが実感です。 なお、今回書いた DB 設計は2テーブルだけのシンプルな構成で、本格的な分散やシャーディングの考慮までは要りませんでした。テーブルが増え、複雑なインデックス設計が絡む規模で、AI とこの3段構えがどこまで通用するのかは、まだ検証できていません。「シンプルだったから回った」可能性は十分にあります。 最後に補足すると、こうして進められた背景には、組織図上で「越境させた」のではなく、同じチームの中で互いの領域をペアプロで教え合える環境があったことが大きいです。AI に聞いてもわからないこと、そもそも何を聞けばいいかわからないことは、隣にいる詳しいメンバーにその場で聞く。AI と人、両方に頼れる相手がいたことが、越境のコストを最も下げました。 つまずいたポイントと対策 ここまでは「どう乗り越えたか」を中心に書いてきましたが、実際には数多くの詰まりどころがありました。そしてその多くは設計や実装の難しさではなく、環境・ツール・運用、つまりコードを書く以外の全部にありました。Client しか書いてこなかった人間が Backend に入ると、ここでことごとく足を取られます。代表的なものを課題ごとに紹介します。 現状の開発スタイルがわからない 最初の壁は、開発スタイルそのものの違いでした。 Clientエンジニアの感覚として、まずローカルでビルドして動作を確認しようとしました。ところが、このコードをもともとメンテナンスしていたチームは、開発環境(dev)にまずデプロイしてから動かす、というスタイルを取っていました。つまり、ローカルの docker compose は、実のところほとんど使われていなかったのです。 私はそれを知らないまま、ローカルの docker compose を立ち上げようとして、「Postgres のバージョンが合わない」「環境変数が無効だ」と、動かない環境を前に四苦八苦しました。結果ローカルでテストできるようになったので良かったのですが、リポジトリ固有のこうした暗黙知は、AI ではどうにもならない部分です。 enum を一つ増やしたら、芋づる式にバージョンが上がる 次につまずいたのは、依存関係の更新でした。やりたかったのは、protobuf の定義に enum を一つ増やし、新しく追加された定数を参照することだけです。ところが、その定義を取り込もうと go mod tidy を実行したとたん、直接は触っていないはずのライブラリのバージョンが一斉に上がってしまいました。gRPC まわりから、最終的には Go 本体の要求バージョンまで動いてしまう。たった一つの enum のために、なぜここまで広がるのか、最初はまったく見当がつきませんでした。 やっかいだったのは、その「なぜ」に対して、もっともらしい説明がいくつも出てきて、しかもそれが間違っていたことです。AI に理由を聞くと、それらしい仮説、たとえば「余計な更新を巻き込んでいるだけだから、最小限に戻せる」といった答えを返してきます。ですが、その通りに戻そうとしても差分は収まりませんでした。 ひたすらAIと問答し、ようやく分かりました。原因は依存が構造的に噛み合っていたことでした。新しい生成ライブラリが新しい gRPC 系を要求し、それがさらに別の基盤ライブラリを引き、最終的に Go 本体の下限まで押し上げている構造だったのです。本当にそうなのかの依存関係のグラフを図示し、それでも自信が持てなかったのでPR Reviewでシニアエンジニアにレビューいただきました。 ローカルでのテストと Lint DB アクセス層(DAO: Data Access Object)のテストをローカルで回そうとすると、ただ実行するだけでは通りませんでした。テスト用の Postgres を専用ポートで立て、 PGPORT を明示的に指定する、といった、チームでは当たり前すぎて誰もドキュメントに書かない作法を、1つずつ踏みながら知っていきます。 たとえば DAO テストは、 5556 のような専用ポートでテスト用の Postgres を立て、 PGPORT でそこを向けて初めて通ります。これを知らないと、テストはローカルの既定のデータベースに接続しようとして、原因の見えないまま失敗し続けます。どこにも書かれていないこの一手にたどり着くまで、エラーメッセージとにらめっこする時間が続きました。 Lint も同様でした。ローカルで走らせると、設定のバージョン差(v1 系と v2 系の非互換)でうまく通らず、CI(Continuous Integration)に任せるという割り切りに落ち着きました。「ローカルで全部緑にしてから push する」という前提が、まず崩れます。 E2E 開発のマシン要件 1人で Client も Backend も1台で回すと、マシンへの負荷が一気に上がります。実際、Android ビルド+エミュレータ、iOS ビルド+シミュレータ、Docker コンテナ3つを同時に動かして、メモリを90GB使う場面がありました。スワップなしで E2E 開発をするなら、96GB は欲しいところです。 特にBackendのEngineerは元々高いスペックのPCを持っておらず、Client開発でまず最初のローカルビルドを試す段階で躓いてしまいました。ここからローカルビルドに耐えるPCを支給してもらうまでClient開発に着手できないということが起きてしまっていました。 越境して E2E を1人で持つということは、ツールチェーンも全部抱えるということでもあります。ハードウェアは目立ちませんが、無視できない条件でした。チーム内でも「越境する人ほどマシン要件が上がる」という前提を共有し、開発機の選定では多めのメモリを見込んでおく必要がある、という話になりました。 振り返って これらの課題に共通していたのは、詰まったポイントのほとんどが、設計や実装の難しさではなく、環境・ツール・運用の暗黙知だったことです。コードを書く力そのものよりも、その手前のところで足を取られていた、というのが実感でした。 ここで効いてくるのが、もともと人間のオンボーディングでも重要だった条件です。「DX(Developer Experience)が整っている」「オンボーディング資料や開発ドキュメントが整備されている」という、人にとっての整備状況が、そのまま AI にとっての整備状況でもありました。ドキュメントが薄く暗黙知に頼っている領域では、人も AI も同じように迷います。 今のところは、やってみて初めてわかる問題を踏んでは1つずつ潰していく、いわば「悲鳴駆動」で進めるしかなく、チームを立ち上げてから数ヶ月は生産性を上げるのが難しいのが正直なところです。逆に言えば、ここをドキュメントとセットアップスクリプトであらかじめ潰しておけば、越境のコストは大きく下げられる、という改善の的もはっきり見えました。次に越境に挑むなら、最初に着手するのはこの整備だろうと考えています。 まとめ KYC で試した範囲では、Client ⇄ Backend の越境開発は成立しました。Client エンジニアがサーバーを書き、Backend エンジニアが iOS/Android を出せています。 それを支えたのは、いくつかの条件でした。まず、小規模な開発チームとして一体で動き、ペアプロやオンボーディングセッションをしながら互いの領域を学べたこと。次に、AI Agent が初見の言語やコードを一文ずつ理解する伴走者になってくれたこと。ただし、設計の最終的な妥当性を見極め、そして開発者の不安を払拭するのは、いまも人間のシニアレビューでした。環境構築やツールのバージョン、ローカルテストといった暗黙知を埋めるには、オンボーディング環境とドキュメントが要ります。一方で、選択の根拠を開発の過程で言語化し、それを残しておけば、専門外の領域でもレビューは回りました。そして最後に、これらすべてを1台で回すには、メモリ96GB級の開発機が現実的に必要でした。 そして、これらが揃っていても、最初の数ヶ月は生産性が落ちます。越境は成長痛とセットでやってくる、というのが一番の学びでした。逆に言えば、その成長痛をチームとして引き受ける覚悟と、AI を含めたオンボーディングの仕組みがあれば、「クライアントエンジニア」「サーバーエンジニア」という肩書きの境界は、思っていたより動かせます。 次は、もっと複雑な DB 設計を含むプロジェクトで、AI がどこまで越境を支えられるのかを確かめていきます。エンジニア一人ひとりが領域をまたいで動けるようになることは、巡り巡って、より早く・より確かな価値をお客さまに届けることにつながると考えています。これからもこういった挑戦を続けていきたいと思います。 次の記事は yutaroさんです。引き続きお楽しみください。
こんにちは。Data Ingestion チームでData Engineerをしている @orfeon です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の14日目の記事です。 はじめに Data Ingestion(旧Data Platform)チームでは、多数のマイクロサービスが管理する データベース・テーブル から、大量のデータを継続的にDWH(データウェアハウス)へ同期する必要があります。同期対象には数億〜 数百億件 に達する大規模なテーブルも含まれ、これらをいかに速く・安全に・一貫性を保ったまま抽出するかが、DWHの鮮度や安定性にとって大事になります。 これまで  Cloud Spanner  からのデータ取得では、Spannerの分散DB特有の機能(後述)を活用することで、大規模テーブルでも高いスループットでの取得を実現できていました。 一方、社内にはTiDBやAlloyDBといったSpanner以外のデータベースも多く利用されており、その中には数百億件以上に達するテーブルもあります。 これらのテーブルは従来、主キーなどで シーク方式 で取得していましたが、単一コネクションでの シーケンシャルなデータ取得 になるため、大規模テーブルでは取得に非常に時間がかかっていました。 そこで今回、Spannerと同じように、 それぞれのDBに特有の機能を活用して並列取得などでスループットを上げる よう工夫しました。 具体的には、 TiDB  と  AlloyDB  の大規模テーブルをDWHへ同期する仕組みを  Cloud Dataflow(Apache Beam)  上に構築しました。 本記事では、その中核となる2つのSourceモジュール   TiDBSource  と  PostgresSource   について、高いスループットを実現するための工夫を解説します。 なぜ汎用JDBCではなく専用モジュールなのか Beam/Dataflowには汎用的な  JdbcIO  が既に存在します。 しかし汎用JDBCは「 SELECT を実行して結果を1行ずつ読む」という標準的な経路をたどるため、大規模テーブルでは以下のボトルネックが発生します。 1行ごとのSQL処理オーバーヘッド : 通常のクエリ実行では、サーバ側でのタプルのテキスト/プロトコル変換などが行ごとに発生する。 並列化の難しさ : テーブルを並列に読むには「どこで分割するか」を決める必要があるが、 OFFSET ベースの分割はオフセットが大きくなるほど遅くなり、フルスキャンを誘発する。 一貫性の確保 : 並列に複数コネクションから読む場合、各コネクションが別々の時点を読むと整合性が崩れる。 そこで今回のモジュールでは、 それぞれのデータベースが持つネイティブなバルク転送機構と物理的なデータ配置情報を活用 し、汎用JDBCのボトルネックを回避する設計にしました。 加えて運用上の大きなメリットとして  分割キー(フィルタ条件)の自動抽出  があります。 マイクロサービスごとに膨大なテーブルを扱う環境では、テーブル1つひとつに対して「どのカラムで分割するか」を人手で指定するのは現実的ではありません。 両モジュールはテーブルのメタデータから 主キー(PK)や暗黙の行ID、物理ブロック位置を自動で見つけ出し 、分割範囲の絞り込み条件を組み立てます。 利用者は接続先とテーブル名を指定するだけで、同じ設定が多数のテーブルに横展開することができます。 なぜCloud Spannerでは高いスループットでデータ取得が可能なのか 今回の設計の発想は、既にうまくいっていたSpannerからの取得方法を、TiDBやAlloyDBにも持ち込むことにありました。 そこでまずSpannerが大規模テーブルでも高いスループットを出せている理由を説明します。 Spannerは分散データベースとして、以下の機能を組み合わせています。 PartitionQuery / PartitionRead(Splitベースの自動分割) : Spannerはデータを内部的に  Split (キー範囲+負荷ベース)へ分割して保持しています。 PartitionQuery  はこのSplit境界に基づいてクエリを複数のパーティションに自動分割します。クライアントはキー範囲などSplitの内部構造を意識する必要がありません。 BatchReadOnlyTransaction(スナップショット一貫性) : 全パーティションの読み取りが、 TimestampBound  で指定した同一スナップショットを参照することを保証します。ロックを取らずに一貫した読み取りができます。 Partition Tokenの分散・並列実行 : 分割結果は シリアライズ可能なPartition Token として返されるため、複数プロセス・複数マシン、そして Beam Worker に配布してそのまま並列実行できます。Apache Beamの  SpannerIO  も内部でこの仕組みを使っています。 Partition Tokenによる自動バージョン保持 : Tokenが有効な間は対象バージョンがGCされないことが保証されるため、クライアント側で明示的なバージョン保護(SafePoint管理)が不要です。 Data Boost(Spanner固有) : Google管理の独立した計算リソースで読み取るオプションで、 本番ワークロードへの影響をほぼゼロ にしつつ弾力的にスケールできます。 これらは「 物理的なデータ配置に沿った自動分割 」「 スナップショット一貫性 」「 分割単位の分散ワーカーへの配布と並列実行 」という構図で成り立っています。Spannerではこれらが高度に抽象化されたAPIとして提供されていますが、 TiDBやAlloyDB(PostgreSQL)にもそれに近いDB固有の機能が存在します 。 このSpannerの機能とTiDBやPostgreSQLの機能は以下のように対応します。 Spanner TiDB(dumpling相当) AlloyDB(PostgreSQL) PartitionQuery(Split境界で自動分割) TABLESAMPLE REGIONS() (TiKV Region境界) ctid 物理ブロック範囲( pg_relation_size ) BatchReadOnlyTransaction(スナップショット) tidb_snapshot MVCC(Multi-Version Concurrency Control) + TSO(Timestamp Oracle) バッチ読み取り( ctid スナップショットのズレは許容) Partition Tokenの分散実行 Range条件の分散実行(本記事の設計) Range条件の分散実行(本記事の設計) Partition Tokenによる自動GC保護 tidb_gc_life_time  の引き上げで代替 (該当なし) SpannerのSpannerIOで提供されている「分割 → 配布 → 並列スナップショット読み取り」を、TiDB/AlloyDBではDB固有の機能を組み合わせて自前で構築する、というのが本記事のモジュールの狙いです。 以降その共通の仕組みと各DB向けの実装を見ていきます。 共通アーキテクチャ 両モジュールに共通する基本戦略は次の3ステップです。  TiDBSource / PostgresSource はCloud Dataflow バッチジョブとして実行され、以下3つのステップで役割が分かれます。 テーブルの範囲分割 : 1本のコネクションでメタデータだけを取得し、テーブルを物理的な分割単位(Range)のリストに列挙する 再分配 : 分割単位をPCollectionの「種」として撒き、 Reshuffle でワーカーに再分配する 並列読み込み : 各ワーカーが担当Rangeをネイティブのバルク転送機構で並列に読み取る 以降、TiDBとPostgreSQLそれぞれについて、この3ステップの中身を掘り下げます。まずTiDBから、この3つのステップがどのように実装されるかを見ていきます。 TiDBテーブルからのデータ抽出 TiDB公式ツール dumpling に学ぶ TiDBには  dumpling  という高速なエクスポートツールが公式に提供されています。 TiDBSource の設計は、このdumplingが高スループットを実現している仕組みを参考にしています。 まずはdumpling側の要点を整理します。 テーブルのチャンク分割と並列読み取り dumplingは、1テーブルを丸ごと1クエリで読むのではなく、テーブルをチャンク(範囲)に分割し、各チャンクを独立したSELECTクエリとして並列実行します。 チャンク分割は3段階のフォールバック構造になっています。 戦略 方式 概要 A(最優先) TiKV Regionベース分割 TABLESAMPLE REGIONS()  でRegion境界をチャンク境界にする B(フォールバック) 数値インデックスベース分割 数値型PK/インデックスのMIN/MAXから均等分割 C(最終) テーブル全体ダンプ 分割可能なフィールドがない場合は1クエリ 特に重要なのが戦略Aです。 TiDBではデータがTiKV上で Region(デフォルト96MB単位) に分散配置されます。 dumplingはこのRegion境界をそのままチャンク境界として利用するため、各チャンクが異なるTiKVノードへの読み取りリクエストに分散され、クラスタ全体のI/O帯域を引き出せます。 dumplingの並列実行の仕組み: Producer-Consumer 分割したチャンクを並列に読み出すために、dumplingは内部で Producer-Consumer という構造をとります。登場人物は次の3つです(いずれもdumplingの実装に出てくる用語です)。 Producer(プロデューサ) : テーブルをチャンクに分割し、「このチャンクを読め」というタスクを作り続ける係。dumplingではメインのgoroutineが担当します。先ほどのRegion境界などをもとにタスクを生成します。 Writer(ライター) : 生成されたタスクを受け取り、実際にSELECTを発行してデータを読み出す係。 --threads で指定した数だけ並列に動き、それぞれが独立したDB接続を持ちます。タスクを消費するConsumer側にあたります。 infiniteChan(無制限チャネル) : ProducerとWriterの間をつなぐ、容量に上限のないキュー(待ち行列)。Writerの処理が詰まってもProducerがブロックされず、生成済みのタスクをいくらでも貯めておけます。 このように、タスクを作成する人(Producer)とタスクを実行する人(Writer)を分離し、その間を待ち行列(infiniteChan)でつなぐことで、分割と読み取りを互いに待たせずに並列で回す基本構造です。後述のTiDBSourceは、この役割分担をそのままDataflowの分散モデルに置き換えています。 Snapshot読み取り dumplingはTiDBのMVCC (Multi-Version Concurrency Control)機構を利用し、特定のTSO(Timestamp Oracle)時点の  スナップショット  から一貫したデータを読み取ります。 ロック不要 :  FLUSH TABLES WITH READ LOCK  のような排他ロックが不要で、書き込みをブロックしない。 一貫性保証 : 全Writerが同一時点のデータを読むため整合性が保たれる。 高スループット : ロック競合がないため並列度を上げられる。 加えてdumplingは、長時間のダンプ中にTiDBのGC(Garbage Collection)がスナップショット時点の古いバージョンを回収しないよう、PD(Placement Driver)に対してGC SafePointを登録します。 TiDBの機能を活用したDataflowでの実装 TiDBSource は、dumplingのこれらのアイデアを Apache Beam / Dataflowのモデルに移植 したものです。dumplingがgoroutineで実現していた並列性を、Dataflowの分散ワーカーによる並列性に置き換えています。対応関係は次の通りです。 dumpling TiDBSource (Dataflow) Producerがチャンクタスクを生成 パイプライン構築時に Range のリストを生成 infiniteChan  + 複数Writer goroutine Reshuffle  +  ParDo による分散ワーカー並列処理 各Writerが独立DB接続でSELECT 各ワーカーが @Setup で独自コネクションを確立 TSOスナップショット読み取り TSOを一度取得し全ワーカーに配布 ステップ1: 分割キーの決定とRangeの列挙 パイプラインの初期起動時に1本のコネクションを張り、出力スキーマの確定・スナップショットTSOの取得・テーブルの分割を行います。 ここではメタデータと境界値だけを読み、実データのスキャンは行いません。 分割キーの自動解決  は次の優先順位で行われます。利用者がカラムを指定しなくても、テーブルのメタデータから自動的に決定されます。 利用者が  splitField  を明示指定していればそれを使う なければ 単一カラムの主キー(PK) それもなければ 暗黙の行ID  _tidb_rowid (クラスタードキーを持たないテーブル向け) _tidb_rowid  は、明示的な主キーを持たないテーブルでTiDBが内部的に振る暗黙の行IDです。 これを分割キーに使えるため、主キー設計に依存せず、どんなテーブルでも分割の足がかりを得られます。 Rangeの列挙  は、先述のdumplingの戦略A→B→Cと同じ3段階フォールバックで行います。 戦略Aは、次のSQLでTiKVのRegion境界を取り出します。 SELECT `pk` FROM table TABLESAMPLE REGIONS() ORDER BY `pk` TABLESAMPLE REGIONS() は各Regionの先頭行を返すため、結果の各値が「次のチャンクの下限」になります。 境界値の列 b[1], b[2], …, b[n] から、隣り合う境界で挟まれた半開区間を生成します。 取りこぼしを防ぐため、最初の区間は下側を、最後の区間は上側を開いておきます。 chunk[ -∞, b[1] ), chunk[ b[1], b[2] ), …, chunk[ b[n], +∞ ) TABLESAMPLE REGIONS() はTiDB v5.0以降の構文です。 非TiDBのMySQLや古いTiDBではこのクエリが失敗するため、自動的に戦略B(数値MIN/MAX均等分割)へフォールバックします。 戦略Bは、SELECT MIN(pk), MAX(pk) で取得した範囲を、推定行数とチャンクあたりの目標行数 splitSize から決めた個数で等分します。 chunks = ⌈ 推定行数 / splitSize ⌉ step = (max − min) / chunks + 1 区間 = [min, min+step), [min+step, min+2·step), … , [ …, max] (stepの計算では厳密な切り上げ ⌈(max−min)/chunks⌉ ではなく+1 としています。半開区間 [cutoff, cutoff+step) で走査するため、割り切れるケースでもmax が最終チャンクに確実に含まれるようstep を 1 大きく取っており、実際のチャンク数は chunks 以下になります) ステップ2: Rangeの再分配(Reshuffle) 範囲が決まったら次にワーカーに範囲ごとの処理を並列にさせる必要があります。列挙した Range のリストを並列実行するよう明示的に指定するためにPCollection化した Range の後に、 Reshuffle.viaRandomKey()  を挟みます。  Reshuffle  には2つの重要な役割があります。 fusionの分断 : Dataflowは連続する処理を一つの処理Stageとして結合してしまうことがあります。これがないとDataflowは Create と後続の読み取り ParDo を融合し、Rangeが1ワーカーに偏って並列性が出ません。 ランダムキーによる再分配 : 全Rangeが利用可能なワーカーへ均等にばらまかれ、クラスタ全体に読み取り負荷が分散されます。 これがdumplingの「 infiniteChan から複数Writerがタスクを取り出す」構造に相当します。 ステップ3: 各ワーカーでのスナップショット並列読み取り 各ワーカーは処理開始時( @Setup )に自前のコネクションを確立し、パイプライン構築時に取得・配布されたTSOで  tidb_snapshot  をセットします。  全ワーカーが同一TSOを使うことで、分散読み取りでも単一時点の一貫したスナップショットになります 。 TSOの取得は、一貫スナップショットを開始してその時点の論理時刻を読むだけで完了します(トランザクション自体はすぐロールバックします)。 START TRANSACTION WITH CONSISTENT SNAPSHOT;SELECT @@tidb_current_ts; -- ← この値(TSO)を全ワーカーに配布 各ワーカー側では、読み取り前にセッション変数を設定します。 SET @@tidb_snapshot = '<配布されたTSO>'; -- ロックなしで同一MVCC版を読む SET @@session.tidb_enable_paging = ON; -- 大量スキャン時のメモリ使用量を抑制 tidb_enable_paging  はCoprocessorリクエストのメモリ使用量を抑える設定です(TiDB v6.2.0以降はデフォルト有効。変数を知らないDBではスキップ)。 実際の読み取りでは、担当Rangeを分割キーへの 値の範囲条件 に変換してSELECTに組み込みます。 SELECT * FROM table WHERE `pk` >= 1000 AND `pk` < 2000 ここで重要なのは、この条件が 主キー(インデックス)に対する値の比較 である点です。 データベースはこの行値比較を使ってインデックスを シーク し、該当範囲の先頭へ直接ジャンプして必要な行だけを読みます。  OFFSET のように先頭から数え直す必要がないため、どのチャンクも一定コストで読み出せます。 また、読み取りはMySQL Connector/Jの 行単位ストリーミングモード ( fetchSize = Integer.MIN_VALUE )で行います。 これは結果セット全体をワーカー側メモリにバッファせず1行ずつ取り出す特別な設定で、巨大チャンクでもワーカーのメモリ消費が一定に保たれます。 dumplingがgo-sql-driver/mysqlのストリーミング動作で実現しているのと同じ効果を、JDBC側で引き出している形です。 制約: GC SafePoint dumplingはPDクライアント経由でGC SafePointを登録しますが、これは JDBCコネクションからは到達できません。 そのためTiDBSourceではSafePoint登録を行わず、長時間の読み取りに対しては運用側でクラスタの tidb_gc_life_time を引き上げ、読み取り中にGCがスナップショットのバージョンを回収しないようにする運用方針としています。 AlloyDB(PostgreSQL)テーブルからのデータ抽出 AlloyDBとPostgreSQL互換性 AlloyDBは、Google CloudがPostgreSQL互換で提供するフルマネージドのデータベースサービスです。  ワイヤプロトコルもSQLの方言もPostgreSQLと互換 であるため、クライアントから見ればPostgreSQLそのものとして扱え、標準のPostgreSQL JDBCドライバがそのまま使えます。 さらに  ctid (物理行位置)や  COPY 、 pg_relation_size  といったPostgreSQLの内部機能・システムカタログも利用できます。 したがって、AlloyDBからのデータ抽出は  PostgreSQLの機能を前提に設計できます 。 以下では PostgresSource がPostgreSQLのどの機能を使っているかを説明しますが、その内容は基本的にAlloyDBにも当てはまります。 通常のJDBCクエリ取得との違い PostgresSource も「物理的な分割 → 並列読み取り」という骨格はTiDBと同じですが、データ転送経路とテーブル分割の方式 がPostgreSQL固有の機能に最適化されています。 まず、通常のJDBC経由のクエリ取得との違いを整理します。 観点 通常のJDBC ( SELECT ) PostgresSource ( COPY ... TO STDOUT BINARY ) 転送経路 拡張クエリプロトコル COPYプロトコル(バルク転送専用) 行ごとの処理 パース/プラン/結果整形が走りうる クエリは1回プラン。あとはタプルを連続送出 データ形式 テキスト or バイナリのフィールド単位 バイナリのタプルストリームを直接デコード 並列分割 OFFSET / LIMIT 等(大きいほど低速) ctid 物理ブロック範囲(フルスキャン不要) PostgreSQLの COPY は、もともと大量データのインポート/エクスポートのために用意された専用経路で、通常のクエリ実行に伴う1行ごとのオーバーヘッドを回避できます。 PostgresSource はJDBCドライバの CopyManager API(PGCopyInputStream)を使い、COPY (SELECT …) TO STDOUT (FORMAT BINARY) のバイナリ出力ストリームを受け取って、Avroのレコードへ直接デコードします。 中間のテキスト変換を挟まないぶん、CPU負荷とアロケーションを抑えられます。 PostgreSQLの機能を活用したDataflowでの実装 ステップ1: ctidブロック範囲の列挙 PostgreSQLの全タプルには ctid(物理的な行ロケーション = (ブロック番号, ブロック内オフセット))が付与されています。 PostgresSource はテーブルを 物理ブロック範囲 で分割し、各範囲を TID range scan で読みます。 分割の計画には実データのスキャンが一切不要です。 ブロック数は、テーブルの実ディスクサイズをブロックサイズで割って求めます。 SELECT pg_relation_size('table'::regclass) / current_setting('block_size')::bigint pg_relation_size  は実ディスクサイズを返すため、統計情報の  pg_class.relpages  推定値よりも正確で、しかもstat呼び出し1回で済みます。 1ブロックあたりの行密度は推定行数( pg_class.reltuples )から求め、目標行数  splitSize  に対応するブロック幅を機械的に算出します。  [0, blockCount)  をこの幅で割っていくだけなので、 フルスキャンも OFFSET も不要 です。 行密度 = 推定行数 / ブロック数1範囲のブロック幅 = max(1, round( splitSize / 行密度 )) 範囲 = [0, w), [w, 2w), … , [kw, blockCount) ※最後の範囲は上限を開く 最後の範囲を上限なし(オープンエンド)にしておくことで、分割を計画した後に追記された行も読み取れます。各範囲は ctid の半開区間条件に変換されます。 WHERE ctid >= '(0,0)'::tid AND ctid < '(3,0)'::tid この条件も、TiDBの主キー範囲と同様に物理位置の値による範囲比較です。 PostgreSQL 14以降ではこれが TID range scan として処理され、該当ブロックへ直接シークして必要な範囲だけを読みます。 注意点 :  ctid  は物理的な行位置なので、読み取り中にINSERT/UPDATE(別ページへの移動)/VACUUMが起きると、同じ行を二重に読んだり取りこぼしたりする可能性があります。同期中に更新されないテーブルに対して使うか、バッチ読み取りで一般的なスナップショットのズレを許容する前提で利用します。また、TID range scanはPostgreSQL 14以降のサポートで、それ以前のバージョンでは各範囲がシーケンシャルスキャンにフォールバックします。 ステップ2: Rangeの再分配 TiDBと同様、RangeのリストをCreateで撒き、Reshuffle.viaRandomKey()でワーカーへ再分配します。fusion分断と負荷分散の狙いは同じです。 ステップ3: 各ワーカーでのバイナリCOPY並列読み取り 各ワーカーは、担当Rangeのctid条件を組み込んだSELECTを COPY (…) TO STDOUT (FORMAT BINARY) でラップし、バイナリストリームを受け取ります。 COPY (SELECT * FROM table WHERE ctid >= '(0,0)'::tid AND ctid < '(3,0)'::tid)TO STDOUT (FORMAT BINARY) 返ってくるのはPostgreSQLのCOPYバイナリフォーマットです。 PostgresSource はこれを直接パースします。 先頭の固定シグネチャ( PGCOPY\n\377\r\n\0 )とヘッダを読み飛ばし、以降はタプルを1件ずつ読みます。 各タプルは「フィールド数」に続いて、フィールドごとに「長さ( -1 はNULL)+値のバイト列」が並ぶ構造で、終端は番兵値(フィールド数 =  -1 )で示されます。 値のデコードは、PostgreSQLのバイナリ表現をAvroの値へ型ごとに変換します。たとえば次のような処理です。 整数・浮動小数: ビッグエンディアンでそのまま読む numeric : base-10000 のdigit配列から十進数を復元 date  /  timestamp : PostgreSQLエポック(2000-01-01)基準の値をUnixエポック基準へ補正 uuid  /  json  /  jsonb  /  bytea : それぞれの専用処理 これらをドライバのテキスト変換を介さず自前でデコードすることで、転送経路を最短化しています。 なお、IAM認証(Cloud SQL / AlloyDB)にも対応しており、 user 未指定時はワーカーのサービスアカウントをDBユーザとして使い、接続URLに  enableIamAuth=true  を自動付与します。 検証用の12つのカラムを持つ6億件のダミーテーブルデータをAvroファイルとしてGCSに出力するタスクで、6コア並列で8分で処理完了するようになりました。 制約: なぜTiDBSourceのようにワーカー間の一貫性を担保できないのか TiDBSource では tidb_snapshot によって全ワーカーが同一時点を読み、ワーカー間の一貫性を担保していました。一方の PostgresSource では、各 ctid レンジが それぞれ独立したトランザクションで読まれる ため、レンジ間(別接続・別時刻)での一貫性は保証されません。読み取り中にINSERT/UPDATE( ctid が別ページへ移動)/VACUUMが起きると、レンジをまたいで行の重複や欠落が起こりえます。 PostgreSQLにも一見すると同等の仕組みがあります。 pg_dump の並列モードは、 pg_export_snapshot() でスナップショットをエクスポートし、各ワーカーが SET TRANSACTION SNAPSHOT でそれを取り込むことで、ワーカー間の一貫性を担保しています。 PostgresSource で同じことを今回実現できなかった理由は スナップショットの「寿命」の違い にあります。 TiDB( tidb_snapshot ) PostgreSQL( pg_export_snapshot() ) 実体 TSO(論理タイムスタンプ=ただの数値) エクスポート元トランザクションに紐づくスナップショットID 寿命 永続的 (GCされるまで。トランザクション非依存) 一時的 (エクスポート元トランザクションが開いている間だけ有効) 共有方法 数値を渡し、各セッションが独立に SET 元トランザクションが生存中に各セッションが SET TRANSACTION SNAPSHOT で取り込む PostgreSQLのエクスポートされたスナップショットは、 それをエクスポートしたトランザクションが終了するまでしかインポートできません 。 pg_dump の並列モードが成立するのは、単一プロセスのリーダーがダンプ全体の間ずっとエクスポート元トランザクションを開いたまま保持し、自身でワーカーを起動するからです。 PostgresSource が動くCloud Dataflowの実行モデルでは、元トランザクションを 並列読み取りの全期間にわたって保持する場所を確保するのが難しかったのです 。TiDBSourceではランチャーでTSO(数値)がトランザクションと無関係に有効だったためワーカーに配るだけで済みました。 そのため PostgresSource では各レンジ独立読み取りを前提とし、「同期中に更新されないテーブルに対して使う/バッチ読み取りで一般的なスナップショットのズレを許容する」という運用方針にしています。 まとめ: 高スループットを支える設計要素 両モジュールでは、 「テーブルの物理的なデータ配置に沿って分割し、その分割単位を分散ワーカーで並列に読み取る」  という共通する設計思想で実装しました。 以下  SpannerSource (SpannerのPartitionQuery等を活用したモジュール)も加えた各設計要素の比較表です。すでにいずれかのDBに親しんでいる人は別のDBの機能と比較することで関心・理解が深まるかもしれません。 要素 SpannerSource TiDBSource PostgresSource 物理分割の基準 Split境界( PartitionQuery ) TiKV Region境界( TABLESAMPLE REGIONS() ) ctid 物理ブロック範囲( pg_relation_size ) 分割キーの自動抽出 Spannerが自動分割(指定不要) PK /  _tidb_rowid  を自動解決 ctid (全テーブル共通) 分割計画のコスト API呼び出しのみ(フルスキャン不要) 境界キーの列挙のみ(フルスキャン不要) stat呼び出しのみ(フルスキャン・ OFFSET 不要) 範囲の読み取り Partition Tokenを実行 PK値の範囲比較でインデックスをシーク ctid の範囲比較でTID range scan 転送機構 gRPCストリーミング( executeStreamingSql ) ストリーミングResultSet( fetchSize=MIN_VALUE ) バイナリCOPY( COPY ... TO STDOUT BINARY ) 一貫性 BatchReadOnlyTransaction ( TimestampBound ) tidb_snapshot によるMVCCスナップショット バッチ読み取り( ctid スナップショットのズレは許容) 並列化 Partition Tokenを Reshuffle  +  ParDo で分散 Reshuffle  +  ParDo (dumplingのWriter並列に相当) Reshuffle  +  ParDo バージョン保護 Partition Tokenで自動保持 tidb_gc_life_time  の引き上げで代替 (該当なし) フォールバック Partition Query → 通常Query Region → 数値MIN/MAX → 全体 TID range scan → シーケンシャルスキャン(PG14未満) 汎用JDBCに対する優位性は、(1) 分割キーを自動抽出でき、分割計画も安価なので並列度を素直に上げられること、(2) 各ワーカーの転送がネイティブ機構でCPU/メモリ効率が高いこと、(3) DB固有のスナップショット機構で一貫性を担保できること(TiDB) の3点に整理できます。これらはもともとSpannerSourceがSpannerの機能で実現していた特性を、今回 TiDB / PostgreSQL 固有の機能を組み合わせて再現したものになります。 おわりに 多数のマイクロサービスが抱える大量のテーブルをDWHへ同期するという要件に対し、汎用的なJDBC経路ではなく、TiDBとPostgreSQL(AlloyDB)それぞれの  分散ストレージアーキテクチャや物理データ配置を活かした専用Sourceモジュール  をCloud Dataflow上に実装しました。これは、すでにSpannerからの取得で高スループットを実現できていた「分割 → 配布 → 並列スナップショット読み取り」というパターンを、他のDBにも横展開した取り組みと言えます。 今回は各DBが公式ツール( dumpling  / pg_dump )で利用されている高速化のノウハウを参考にさせてもらい、Dataflowの分散実行モデルに取り込みました。「分割キーの自動抽出」「物理配置に沿った安価な分割」「ネイティブなバルク転送」「DB固有のスナップショット」という要素の組み合わせが、大規模テーブルでも高いスループットと一貫性を両立させる助けになりました。 次の記事は cyan さんの「Scaling Myself: How I Run 22 Claude Code Sessions for DS4 Migration」です。引き続きお楽しみください。
p.fontbold{ font-weight: bold; } p.codeboxbefore{ margin-bottom:-70px; } @media screen and (max-width: 575px) { p.codeboxbefore{ margin-bottom:-13.4vw; } } この記事は 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 組み合わせを、メルカード用テーブルの行として毎月生成する運用です。 また、お客さまのメルカードステータスやモバイルデータプランは日々変わりうるため変化に合わせて還元レートや月々の上限を計算し直す必要があります。そして、同じお客さまが同月内で別の組み合わせ向けキャンペーンにも二重にマッチして重複付与が起こりうる、というリスクも残ります。後者の重複付与を防ぐために、コード側にはこんな雰囲気のハードコードマップが入りました (簡略化したイメージ)。 // ポイント重複付与防止のため、キャンペーン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 ハンドル イベントが届いてから付与までの流れは次のとおりです。 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 数行、というシンプルな構造で表現できます (簡略化したイメージ)。 // 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 < 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同期するための工夫」です。引き続きお楽しみください。
DBRE(DataBase Reliability Engineering)チームの@coyamaとIDP(IDentity Platform)チームの@taskです。 メルカリでは2026年5月14日から2026年5月15日まで開催されたクラウドネイティブ会議にスポンサーとしてブース出展しました。2日間で125名にクイズへ参加いただき、想定以上に“認証”や“マイクロサービス規模”といった話題で多くの議論が生まれました。本記事では、ブースの展示内容やクイズやアンケートの結果に加えて、来場者とのやり取りや感想を紹介します。 メルカリブースの展示 メルカリの技術面の取り組みを参加者に認識いただくために、有志のエンジニアが中心となりブースを設計しました。メルカリのブースでは主に以下を展示しました。 メルカリの技術スタックのクイズ/アンケート メルカリのアーキテクチャ変遷やKubernetesクラスタでのパケットキャプチャに関するポスター AI SecurityのLLM Key Serverに関するスライド クイズ/アンケートに回答いただいた方には、メルカリのストラップやボールペン、マスキングテープをプレゼントしました。メルカリのストラップは、参加者から「メルカリらしいノベルティ」というコメントもあり好評でした。 メルカリのアーキテクチャ変遷のポスターでは、以下の転換点について展示をしていました。 データセンターからパブリッククラウドへの移行 アプリケーションのKubernetesへのデプロイ モノリシックアーキテクチャからマイクロサービスアーキテクチャへの移行 マイクロサービスアーキテクチャの課題とモジュラーモノリスの取り組み 「モノリシックアーキテクチャからマイクロサービスアーキテクチャに移行する際に、どうシステムを分割すべきか」や「マイクロサービスアーキテクチャの課題は何か」といった議論ができました。また、他社でもメルカリと類似した課題に直面していることを改めて認識しました。 ブースの展示内容に関連した資料は以下に公開されています。 SRE視点で振り返るメルカリのアーキテクチャ変遷と普遍的な考え – Speaker Deck It’s Not Always the Network (But Here’s How to Prove It): Kubernetes Packet Capture for SREs | USENIX LLM Key ServerによるLLM APIへの安全で便利なアクセス提供 | メルカリエンジニアリング メルカリのテックスタックに関するクイズ 今回のクイズは、メルカリが日々向き合っているサービス全体やテックスタックについて興味を持ってもらうべく、全4問で構成しました。特定のディープなインフラ技術に寄るのではなく、広めのプラットフォームエンジニアリングに関する話題について広くカバーしつつ、ユーザが実際に目にするプロダクト寄りの問題も含めてみました。 その結果、広めのテックスタックをカバーした中〜高程度の問題セットになりました。 そして、クラウドネイティブ会議中にて2日間で125名の回答をいただき、分布は以下の通りでした。中央値は2点で、4点満点を取ったのはわずか9名のみだったため、なかなか手応えのあるクイズになっていたと思います。 また、それぞれの回答の分布は以下の通りでした。 出題した問題の中で、特に注目を集めたのが以下のログイン認証方法に関する問題です。 メルカリで利用されているログイン認証方法の上位3つを使用頻度の高い順にあげてください。 What are the top three login authentication methods used in Mercari, ranked from most to least used? Google+SMS > Password+SMS > Facebook Password+SMS > Passkeys > Google+SMS Password+SMS > Apple > Passkeys Passkeys > Password+SMS > Email Link これはクイズ作成時の情報なのですが、この回答について「一般的な Web サービスをイメージすると、パスワードやソーシャルログインが上位を占めると思いがちなので意外だった」「パスキーの普及率が意外と高いのが面白い」といったコメントが多かったです。普通はこうだろうという先入観で答えると間違えてしまう問題だったので、メルカリが技術で攻めた良い事例なのかもしれません。 また、こちらのログイン認証方法について、メルカリは 2023 年からパスキーログインの普及率拡大を推進しているため、最新のメトリクスによると Passkeys > Password+SMS > Google+SMS に順位が変わっています。 メルカリ、すべてのログインに 生体認証「パスキー」を導入 | 株式会社メルカリ また、他にも管理されているマイクロサービス数に関する問題についてもコメントをいただくことが多かったです。 メルカリの microservices-terraform リポジトリでは、多数のサービス群がInfrastructure-as-Codeとして管理されています。その管理下にあるサービスの総数はいくつでしょうか? The microservices-terraform repo manages Infrastructure-as-Code for a large portfolio of services. What is the total number of services managed in this repo? 100 – 250 services 250 – 500 services 501 – 1000 services more than 1000 services 特に、「想定よりも多くのマイクロサービスを抱えているのに驚いた」「多くのマイクロサービスを抱える上での新たな課題は何か」と言ったコメントが多かったです。こちらの変遷については SRE視点で振り返るメルカリのアーキテクチャ変遷と普遍的な考え – Speaker Deck でも触れられている通りで、こちらの資料を肴にブースにおけるディスカッションが起きたのが面白い点でした。 いずれにせよ、どのクイズにおいても新しいディスカッションや雑談のネタになったため、このようなメルカリ独自のトピックにおける広めのクイズ設定は狙い通りでした。 Cloud Native Kaigiでのブース出展を終えて 今回のクラウドネイティブ会議では、本当に多くの方にメルカリブースへお立ち寄りいただきました。セッションの合間の休憩時間にはブース前が熱気に包まれる場面もあり、運営メンバー一同、クラウドネイティブ・コミュニティの圧倒的な熱量を直接肌で感じることができました。 (coyama) メルカリからもプラットフォームエンジニアリングに関わる複数のチームのメンバーが参加していました。そのため、参加者から寄せられた幅広い質問に対応できる体制で、2日間を終えることができました。例えば、DBREに関する質問では、「メルカリのテックブログでTiDB Auto Scalerの記事を読んで参考になった」という声をきっかけに、トラフィックパターンについて議論することができました。また、セキュリティに関する質問については、セキュリティエンジニアに回答してもらうことで、うまく役割分担ができていました。複数のチームからメンバーが集まることでプラットフォームエンジニアリングやSREに関する幅広いコンテンツを提供できたのは良かったと思います。 (task) ブース運営を通して特に印象的だったのは、今回の「テックスタッククイズ」が単なるエンタメで終わらず、 そこからディープな技術的ディスカッションへと発展したこと です。 例えば、正答率が低かったTerraformのMonorepoに関する問題をきっかけに、「実際、数百のサービスが相乗りしている状態で、CIの実行速度やデプロイの安全性はどう担保しているんですか?」「うちの会社でもプラットフォーム基盤をどう切り出すかで悩んでいて……」といった、現場のエンジニアならではのリアルな悩みや知見の交換が、ブースのあちこちで自然発生していました。 問題の難易度に「うわっ、ガチなやつだ…(笑)」と頭を抱えつつも楽しんでくださる参加者の皆さんの姿や、「メルカリのプラットフォームって、ここまでプロダクトの体験(パスキー等)に踏み込んで作り込まれてるんですね!」という驚きの声は、日々大規模なシステム基盤と向き合っている私たちにとって、何よりのモチベーションになりました。 社外の優秀なエンジニアの方々と直接お話しし、フィードバックをいただけたことは、チームにとっても非常に大きな刺激となりました。 改めて、ブースに足を運んでくださった皆様、そして難問クイズに挑戦してくださった125名の猛者の皆様、本当にありがとうございました! メルカリでは、今回出題したような「プラットフォームからプロダクトまで」を跨ぐような大規模で複雑な技術課題に、一緒に立ち向かってくれる仲間を絶賛募集しています。 「今回のクイズの内容、もっと詳しく聞いてみたい!」「自分ならもっと良いアーキテクチャにできる!」と興味を持ってくださった方、ぜひ以下のリンクからカジュアルにお話ししましょう!お待ちしています! 株式会社メルカリ 採用情報 7/8(水)14:00- Mercari AI Career Fes 2026 – connpass
はじめに こんにちは。メルペイQAチームでQA Engineerをしている  @um(うめ)  です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の12日目の記事です。 コードレビューは品質を守る重要なプロセスですが、人間が行うレビューには限界があります。観点の漏れ、見落とし、初めて触るコードベースへの不慣れ、これらはチームが大きくなるほど顕在化する課題です。 私たちのチームでは、こうした課題に対処するため、Claude CodeのSkillsを活用したAIレビューの仕組みを導入しました。 この記事では、 /review-pr と /improve-review-pr という2つのコマンドを組み合わせた自己改善サイクルについて紹介します。 /review-pr:AIによるPRレビュー 概要 Claude Code には標準で /review というレビュースキルが組み込まれていますが、所属チームではこれとは別に、チーム固有の観点を盛り込んだレビュースキル /review-pr を独自に作成・運用しています。汎用的な観点だけでは拾いきれないポイントを、チームのコードベース/アーキテクチャに合わせて追加・調整できることが、独自運用を選んだ最大の理由です。 /review-pr は、PRの変更内容をAIが自動分析し、不具合リスクを多角的にレビューするスキルです。 /review-pr <PR番号> というコマンド一発で実行でき、以下の6つの汎用的観点を自動チェックします。 汎用的観点 チェック内容 コードの正確性 ロジックの誤り、nil参照、型の不一致、境界値での挙動など エッジケース・異常系 エラーハンドリングの漏れなど 後方互換性 API変更による既存の呼び出し元への影響 機能的影響 機能の喪失、拡張性への制約 テストの十分性 PRにおけるテストの追加・更新有無 Unit Testの網羅性 既存テストも含めた正常系・異常系・境界値のカバレッジ Extra観点:コードベース固有のチェック項目 上記6つは汎用的な観点ですが、 /review-pr スキルにはこれに加えて「Extra観点」と呼ぶセクションも存在します。このセクションには、チームのコードベース固有のアーキテクチャや設計パターンに基づいたチェック項目が蓄積されています。 具体的な内容はリポジトリの特性に依存するため詳細には触れませんが、汎用的なレビュー観点では検出しにくい「そのプロジェクトならではの見落としパターン」を防ぐための項目が並んでいます。このExtra観点こそが、後述する /improve-review-pr によって継続的に育てられていく部分です。 しかし、この仕組みを形骸化せず運用し続けるためには課題がありました。 課題:スキルは使い続けても賢くならない 当初の仕組みのままだと、過去の学び(見落としパターン)が仕組み側に蓄積されません。どれだけ /review-pr スキルを使い続けても、新しい不具合のパターンを自動で学習することはありません。 ある日、AIレビューをかいくぐる形で不具合が混入し、チームが修正PRを作成する必要が生じたとします。同じようなパターンの不具合が再発しても、スキルのファイルを手動で更新しない限り、AIは同じ見落としを繰り返します。 これは「人間が毎回ルールを追記しなければならない」という運用負荷を生みます。私たちがこの課題を解決するために作ったのが /improve-review-pr コマンドです。 /improve-review-pr:修正PRを糧にスキルを育てる 概要 /improve-review-pr <修正PR番号> は、対応漏れが発覚した修正PRを分析し、 /review-pr スキルにExtra観点として再発防止のチェック項目を追加するコマンドです。 修正PRが発生するたびに「なぜ元のレビューで見落としたか」を分析し、同じパターンの見落としを二度と起こさないようスキルを更新します。ユーザーの承認を経てから SKILL.md を更新する設計で、チームの知見を継続的に蓄積できます。 仕組み /improve-review-pr <修正PR番号> を実行すると、以下のステップが走ります。 Step 1:修正PRの情報取得 gh コマンドで修正PRのタイトル・説明・変更ファイル・差分を取得します。 Step 2:元PRの特定(任意) 修正PRの説明文から「この修正が対応した元のPR」を自動抽出します。特定できない場合はユーザーに確認し、不明な場合もそのまま分析を続行します。 Step 3:対応漏れの分析 「なぜこの変更が元PRのレビューで見落とされたか」を分析します。元PRが判明している場合は「元PRのどのファイル変更が、修正の必要性を示唆していたか」まで深掘りします。 Step 4:パターンの抽象化と分類 個別の事例を汎用的なチェックパターンに変換します。以下の基準で追加先を判断します。 分類 条件 追加先 リポジトリ固有 コードベース固有のアーキテクチャに起因 リポジトリ固有チェックとして追加 汎用 一般的なレビュー観点で防げる 既存の観点セクションに追記 既存チェックの強化 類似するチェックが既に存在する 既存項目を拡張 Step 5:重複チェックと提案 /review-pr のSKILL.mdを読み込み、類似チェックの有無を確認した上で改善提案します。承認を得てから更新します。 Step 6:SKILL.mdの更新 ユーザーの承認後、スキルファイルを更新し、変更箇所をサマリとして表示します。 この2つのコマンドを組み合わせることで、以下の自己改善サイクルが回り始めます。 自己改善サイクルの全体像 先週マージされたPRから候補を検出するため、週次でGitHub Actionsが実行され、候補が存在する場合、Slackに通知します。 チームメンバーはその通知を見て /improve-review-pr を実行することで、レビュースキルに改善が1つ積み上がります。 「修正PRを都度見逃さずスキルに反映する」という運用負荷を自動化によって低減した、継続的なフィードバックループです。 プロセス設計のポイント:Human-in-the-Loop 完全自動化も技術的には可能ですが、あえて「ユーザーの確認・承認を経てから更新する」設計にしています。理由は2つあります。 1.誤ったパターンの混入を防ぐ AIが導出したパターンが常に最適な状態とは限りません。 チェック観点がもう一段階抽象化が必要だったり、チームの開発方針に対して妥当ではない場合もあります。人間が内容を確認することで、このような不適切なチェック項目が追加されてしまうリスクを低減しています。 2.チームの知識として定着させる 承認プロセスを通じることで、「なぜこのチェック項目が追加されたか」をチームメンバーが意識する機会が生まれます。 単なるルールの羅列ではなく、背景のある知識として蓄積されていきます。 まとめ Claude Codeのスキル機能を活用することで、AIレビューを「固定されたツール」ではなく、チームの知見を蓄積し続ける仕組みとして運用できるようになりました。今後さらに多くの修正PRを経てスキルが充実していくことを期待しています。 同様の仕組みに興味がある方の参考になれば幸いです。 次の記事は hasegway さんです。引き続きお楽しみください。
はじめに こんにちは。Merpay の Payment & Customer Platform で会計システムを開発・運用する Accounting チームで Backend Engineer をしている @mewuto です。本記事は「 Merpay & Mercoin Tech Openness Month 2026 」の11日目の記事です。 マネージドサービスの世代移行では、コードを変えていなくても、デフォルト値の違いだけでシステムの振る舞いが変わることがあります。ある月末の早朝、Cloud Functions の世代移行が引き金となり、会計イベント基盤で Cloud Pub/Sub のメッセージが大量に滞留して、新規の会計データを Cloud Spanner に登録できないインシデントが発生しました。 直接の引き金は、Cloud Functions を 1st gen から 2nd gen(Cloud Run ベース)へ移行した際に --max-instances を明示しておらず、Cloud Functions のスケール上限が「無制限」(1st genのデフォルト)から「100」(2nd genのデフォルト)へ下がっていたことでした。しかし、障害がここまで大きくなったのは、この引き金だけが原因ではありません。本記事では、1000万件規模に達したこの滞留がどのように構成されていたのかを解き、Cloud Run と Spanner、そして監視の各面で私たちが講じた対策を紹介します。 1. 月末ピークを支える会計イベント基盤 まず、私たちが運用する会計システムの全体像を説明します。会計システムは、社内の各マイクロサービスが発行する「会計イベント」を集約し、Cloud Spanner に記録する基盤です。この会計データは送信元サービスとの突合(リコンサイル)を経て、加盟店への精算や月次の会計締めといった後続処理の前提となるため、取りこぼしや遅延がそのまま業務影響に直結します。 会計システムは、次のような非同期パイプラインでイベントを受け取ります。送信元から Spanner までは、おおむね一方向の流れです。 この構成には、今回の障害を理解するうえで重要な特性が2つあります。 1つ目は、すべてが非同期で動く点です。送信元マイクロサービスはイベントを発行したら応答を待たず、配信・リトライ・整合性の担保はすべて Pub/Sub 以降のパイプラインが引き受けます。特に Pub/Sub から Cloud Run へは push 型サブスクリプションで配信されるため、消費側である Cloud Run とその書き込み先である Spanner の処理能力が、そのままパイプライン全体のスループット上限になります。 2つ目は、負荷が月末に集中する点です。業務特性上、月末・月初の締めに合わせてトランザクションが一気に押し寄せ、平常時の数十倍のスパイクが発生します。 なお、Pub/Sub に届いたメッセージは、この push 経路とは別の pull 型サブスクリプションを通じて Dataflow でも読み取られ、並行して Cloud Storage(GCS)に保存されています。本処理の滞留に巻き込まれないこのGCS上のデータを使って後の復旧を行いました。 2. 1000万件の滞留はどのように発生したか インシデントは、ある月末最終営業日の早朝に発生しました。6時ごろから Pub/Sub のメッセージが滞留しはじめ、Spanner への新規登録が滞り、新規の会計データを受け付けられない状態が断続的に続きました。復旧と再発を繰り返し、収束までには丸一日以上を要しました。 データロスそのものは、先ほど触れた GCS上のデータから再投入することで回避できました。しかし会計システムでは、送信元サービスと DB の間でリコンサイルが完了したものだけが、加盟店精算や月次の会計締めといった後続処理の対象になります。Spanner に登録されなかったデータはリコンサイル未完了のまま残り、締め日と重なったことで、広範な業務影響につながりました。 この滞留が深刻なのは、一度始まるとインフラリソースの上限でスケールが頭打ちになり、処理が遅れるほど次の処理も遅れる悪循環に陥るからです。会計イベントが高い密度で連続して到着すると、maxInstances=100 で頭打ちになった Cloud Run はこれを処理しきれません。上限まで張り付いた Cloud Run が一斉に書き込むことでSpanner CPUが逼迫し、1件あたりの書き込み時間が延びます。すると、処理スループットがさらに落ち、滞留はさらに進んでしまいます。やがて滞留時間が eventMaxAge(メッセージを破棄するまでの最大保持期間)を超えると、メッセージは Spanner に登録されないまま破棄されてしまいます。 3. きっかけは 2nd gen 移行時のデフォルト値 滞留の原因を調べていくと、今回の障害の1ヶ月以上前に行った Cloud Functions の世代移行に行き着きました。私たちは関数を 1st gen から 2nd gen(Cloud Run ベース)へ移行しましたが、このときデプロイコマンドで --max-instances を明示しておらず、世代によってデフォルト値が変わることを、十分に意識できていませんでした。 この「明示しなかった」ことが、スケール上限を静かに引き下げていました。下の表のとおり、1st gen ではスケール上限が実質無制限だったのに対し、2nd gen のデフォルトは 100 です。 Version デフォルトの max instances 1st gen 無制限(プロジェクト Quota の範囲) 2nd gen 100 コードもデプロイスクリプトも変えていないのに、移行しただけで上限が実質無制限から 100 へ下がっていたことになります。平常時は 100 インスタンスで十分だったため、移行後しばらくは問題が表面化せず、私たちの体感としては、ある月末に突如として障害が発生したように見えました。しかし実際は、監視がなかったために気づけていなかっただけでした。インシデント後に振り返ると、上限に迫っていた月もあり、水面下ではすでに限界の兆候が出ていたのです。 4. 障害を大きくした複数の要因 --max-instances の指定漏れは引き金ではありましたが、それだけでこの障害を説明することはできません。調べてみると、2nd gen移行後に迎えた月末のピーク時でも条件はほとんど同じだったからです。maxInstances は 100、eventMaxAge も同じ 21分で、トラフィック総量も3時間で約17〜19GBとほぼ変わりませんでした。 それでも、そのときは障害は起きていませんでした。決定的に違ったのは、トラフィックの「連続性」です。合間に負荷が落ち着く「谷」があり、その短い谷の間に Spanner CPU と未 ack の蓄積がリセットされていました。ところが障害当日は、この谷がないままピークが約40分間続き、システムは回復の契機をついに得られませんでした。 整理すると、今回の障害は3つの要因が連鎖して成り立っていました。まず maxInstances=100 という処理上限が滞留を発生させ、次にトラフィックの連続性がその滞留を固定化し、最後に eventMaxAge によるメッセージのドロップ、という連鎖により実害となりました。いずれか1つでも条件が違えば被害の規模は抑えられたはずで、だからこそ私たちは対策を1箇所に絞らず、制御できる Cloud Run と Spanner の両方に講じることにしました。 5. Cloud Run の処理能力を引き上げる 最初に着手したのは、直接の引き金となった Cloud Run のスケール上限です。あわせて、1インスタンスあたりの処理効率も見直しました。本番に反映した設定値は次のとおりです。 パラメータ 変更前 変更後 max-instances 100(暗黙のデフォルト) 1000 min-instances 0(暗黙のデフォルト) 1 concurrency 1(暗黙のデフォルト) 10 cpu デフォルト 1 memory 512MB 1Gi ここでの本質は、値を大きくしたこと以上に、必要なパラメータをすべて明示したことにあります。今回の引き金は、暗黙のデフォルト値に依存していたことでした。そこで max-instances をはじめとするスケール関連のパラメータをデプロイ定義に明示し、世代やデフォルトの変化に左右されない状態にしました。あわせて、 min-instances を 1 にしてコールドスタートを避け、concurrency を 10 に引き上げ、それに見合うよう CPU とメモリも増強しています。なお max-instances は、書き込み先である Spanner への負荷も考えて、一度に上げきらず段階的に引き上げました。 concurrency を 1 から引き上げることができたのは、ハンドラ内の共有状態を見直し、複数リクエストを同時に処理しても安全だと確認できたためです。Spanner クライアントは内部にコネクションプールを持ち並行アクセスに耐えられます。また、その初期化は sync.Once によって一度だけ行われます。こうした前提を確かめたうえで同時処理数を増やし、必要なインスタンス数そのものを抑えました。 6. Spanner autoscaler の監視軸を Total CPU へ広げる Cloud Run の処理能力を上げると、今度は書き込み先である Spanner がボトルネックになります。Spanner には以前から autoscaler を導入していましたが、障害のさなか、CPU が高負荷であるにもかかわらず PU(Processing Unit。Spanner の計算容量の単位)はまったくスケールアップしていませんでした。autoscaler があるのにスケールしない、という一見不可解な状況でした。 原因は、autoscaler が見ていた指標にありました。Spanner の CPU 使用率には High / Medium / Low の優先度があり、その合算が Total CPU 使用率です。当時の autoscaler は High priority CPU 使用率だけを見ており、障害時は Total CPU が 100% に張り付く一方で、High priority 単体では閾値(60%)の超過が続かず、起動条件を満たしませんでした。この死角は月末ピークに限りません。たとえばリアルタイム性を求めない大量データの削除ジョブは、意図的に Low priority で実行するため、Total は高いのに High は低いという同じ状態を容易に作り出します。優先度別の CPU だけを見る監視は、こうしたケースを構造的に取りこぼすのです。 しかし、これは autoscaler の設定変更では解決できませんでした。当時、OSSである mercari/spanner-autoscaler には、Total CPU でスケールする機能そのものが存在しなかったからです。そこで、OSS をメンテナンスしている SREチームに相談し、High priority と Total を OR 条件で評価する dual CPU scaling mode を実装してもらい、 v0.8.0 として取り込みました。設定した閾値は次のとおりです。 パラメータ 変更前 変更後 Total CPU 閾値 (監視なし) 70% High priority CPU 閾値 60% 55% Total CPU 閾値は新設で、これが今回の障害への直接の対策です。値は Google Cloud の throughput 最適化推奨(〜70%)に合わせました。High priority CPU 閾値は、より早くスケールアップを起動するために 60% から 55% へ下げています。下げすぎると平常時のコストが増えるため、より低い 50% 等は避け、Google Cloud の推奨(regional で 65% 以下)に収まる 55% を選びました。これにより autoscaler は High priority(55%) と Total(70%) のいずれかが閾値を超えればスケールアップするようになり、「Total は逼迫しているのに High が閾値未満だからスケールしない」という障害時の挙動は原理的に起こらなくなりました。 また、スケール条件に加えて、運用面でも2つの調整を行いました。1つは、早朝5時から7時のスケールダウンを禁止することです。月末ピークの立ち上がりで、せっかく確保した PU が削られてしまうのを防ぎます。もう1つは、スケジュールによる事前の PU 確保です。月末早朝や大規模なリコンサイルが走る月など、負荷が読める期間にはあらかじめ PU を確保しておき、リアクティブなスケールアップだけに頼らない構えにしました。 7. 上限への接近を検知する監視を整える 今回の障害には、予兆を早期に捉える機会も、発生を即座に検知する機会も逃してしまったという課題があります。スケール上限に達してもそれを知らせるアラートがなく、水面下で限界に近づいていた兆候も見逃していました。そこでDatadog に2つの監視を追加しました。 1つは、Cloud Run のインスタンス数が上限の 40% / 60% に達した時点で警告・重大として通知する監視です。これにより、メッセージのドロップが始まる前に、上限への接近そのものを検知できます。もう1つは、Pub/Sub の未配信メッセージ数が閾値を超えたら通知する監視で、滞留という現象を直接とらえます。後者は誤検知を避けるため、当初は保守的な閾値から始め、定常状態の理解が進むにつれて段階的に下げていく方針にしています。 設定値を最適化するだけでは不十分です。これ自体は当たり前かもしれませんが、どれだけ良い値を入れても、その値への接近を検知できなければ、次の同じ障害は防げません。キャパシティの設計と、その接近を知る監視をセットで整える。この当たり前を当たり前に徹底することこそが大切だと、今回あらためて実感しました。特に、システムをゼロから作った当人ではなく、引き継いで運用していることのほうが多い現場では、こうした設定や前提を定期的に振り返り、整え直す文化を持つことが欠かせません。 8. まとめ 今回の障害は、1つのデプロイフラグを明示しなかったことから始まりました。しかし振り返れば、本当の教訓はもっと一般的なところにあります。 第一に、マネージドサービスの世代移行では、自分が変更していないデフォルト値こそが、負荷時に問題として表面化します。スケール上限・並行数・タイムアウトのような、平常時には効かないパラメータは、移行のたびに明示しておくべきでした。第二に、障害の原因は単一とは限りません。「直近の月は同じ条件で起きなかった」という事実がトラフィックの連続性という決定的な要因を浮かび上がらせたように、トリガー・増幅・被害化のそれぞれに目を向けることが再発防止には欠かせません。そして第三に、設定の最適化と、その状態に気づくための監視は、つねに一体で考える必要があります。 マネージドサービスの便利なデフォルト値は、平常時には何も語りません。だからこそ、ピーク時に初めて顔を出すその振る舞いを、移行のたびに確かめておく価値があります。この記事が、みなさんが運用するサービスの設定を今一度見直す、ひとつのきっかけになれば幸いです。 次の記事は um(うめ)さんです。引き続きお楽しみください。
こんにちは、メルペイiOSエンジニアのkubomiです。 この記事は Merpay & Mercoin Tech Openness Month 2026 の 10日目の記事です。 生成AIによって、エンジニアが短時間でプロトタイプをつくれる場面はかなり増えました。最近、小規模なプロジェクトで「初回ミーティングの前に、動くものをつくり切ってしまう」という進め方を試したところ、意思決定のスピードが劇的に変わりました。私はこのやり方を "Build First, Discuss Later(まずつくる、議論は後)”と呼んでいます。この記事では、その具体的な進め方と、実践を通じて私自身に起きたマインドセットの変化を紹介します。 よくある開発フローと、その課題 私たちの現場では、開発に取りかかる前に、まず関係者の認識をそろえておくのが一般的です。具体的には、最初にプロダクトマネージャー(PdM)が大まかな仕様を用意し、それをもとにキックオフミーティングを開いて詳細を議論します。議論を経てPdMが仕様を固め、エンジニアはその仕様をもとに見積もりを出して実装に入ります。 ただ、議論して仕様を固めたつもりでも、いざつくり始めると「あれ、ここの挙動どうするんだっけ?」という疑問が次々と出てくることがあります。そのたびにPdMへ確認したり、追加のミーティングを開いたりすると、少しずつコミュニケーションの往復が増えていきます。 "Build First, Discuss Later" という提案 そこで私が実践したのが、プロセスの順番をあえて逆転させる "Build First, Discuss Later" です。仕様が固まる前に、まず動くプロトタイプをつくってしまうという発想で、ミーティングはその動くものを土台に議論を進めます。 従来は「議論して仕様を固めてからつくる」流れでしたが、これを「先につくり、その動くものを見ながら議論する」へと入れ替えます。実際に触れる画面があると、抽象的な仕様書をめぐる議論よりもはるかに早く、関係者の認識がそろっていきます。 ただし、何にでもこの進め方を使うわけではありません。何日もかかるような大きな実装でこれをやると、方針が変わったときの手戻りが大きすぎます。私の場合は、数時間から1日以内でつくれるくらいの小規模な施策に限定しています。そのくらいの規模なら、悩んで待つより、とりあえずつくってしまったほうが圧倒的に速い、という実感があります。 ミーティングにプロトタイプを持ち込む3つのステップ 私は "Build First, Discuss Later" を、ミーティングの前・中・後という3つの場面に分けて実践しています。ここでは、アプリの画面にバナーを追加した事例を例に、それぞれの場面で意識していることを順に紹介します。 ミーティング前:自分のベスト案でつくり切る ミーティング前は、手に入る計画書や仕様書をAIと一緒に読み込み、「なぜつくるのか(Why)」「何をつくるのか(What)」を自分なりに解釈します。この段階で最も大事なのは、完璧な実装をつくることではなく、どこが曖昧なのかを目に見える形にすることだと考えています。実際、詳細が決まっていないことがほとんどですが、曖昧な点にぶつかっても立ち止まりません。PdMに質問する代わりに、いったん自分が考えるベストな案でつくり切り、迷ったポイントはミーティングのアジェンダに整理しておきます。 たとえば、バナー追加の事例では、リリースに必要な最小限の機能に絞って早くリリースするか、将来使い回せる再利用性を優先するか迷いながらも、まず最小限の機能で動くプロトタイプをつくりました。UIデザインがまだない場合も、既存の画面部品を組み合わせた仮の見た目で形にしました。 ミーティング中:動くものを見ながら論点を解消する ミーティング中は、その動くプロトタイプを見せながら議論し、可能な限りその場で論点を解消します。意見が分かれそうな箇所には、あらかじめA案とB案を用意し、「私はこういう理由でA案を推します」と推奨案まで添えておきます。バナーの例では、期日を踏まえて、リリースに必要な機能に絞った設計プランと、将来の再利用まで見据えた設計プランを提示し、PdMはその場で前者のプランに合意できました。細かな仕様も、プロトタイプを見ながらサクサクと決まっていきました。判断の材料がそろっているため、議論は驚くほど早く前に進みます。 ミーティング後:決まった内容をすぐ反映する ミーティング後は、決まった内容を仕様書に反映し、実装を微修正したうえで品質保証(QA)のテストに回します。大きなつくり直しが起きにくく、初回ミーティングの直後にはリリースが見えている、という状態になりました。バナーの例では、私がつくった仮の見た目をデザイナーが本番デザインへブラッシュアップし、実装側はそれを反映する微修正で済みました。 "Build First, Discuss Later" で起きた3つの変化 この進め方を試してみると、ミーティングの進み方やPdMとのやり取りがかなり変わりました。特に大きかった変化は、次の3つです。 1つ目は、ミーティングがほぼ1回で完結するようになったことです。うまくいけば、初回ミーティングが終わった時点で仕様も実装もほぼ固まっており、開発見積もりすら不要になることもあります。その後の往復も大きく減りました。 2つ目は、議論が速く、かつ正確になったことです。実際に動くものを見せながら「この画面の挙動はこれでよいですか」と確認できるため、言葉だけのやり取りで生じがちな認識のズレが起きにくくなりました。 3つ目は、PdMの負担が軽くなったことです。エンジニアが具体的な仕様の案まで持っていくので、PdMは方針を確認するだけで済みます。特にPdMが複数プロジェクトを兼務しているような状況では、その確認コストを減らせるだけでも大きな価値があります。 「待つエンジニア」から「提案するエンジニア」へ こうした変化は、単に開発プロセスやコミュニケーションを効率化しただけでなく、私自身のエンジニアとしてのマインドセットにも影響を与えました。 以前の私は、決まった要件を正しく実装することがエンジニアの主な役割だと思っていました。けれど、PdMが持つWhyと大まかなWhatを起点に、まず動くものをつくってみると、「これは要らないかもしれない」「こっちの方がお客さまに価値を届けられるのでは」といった議論を、自分から持ち込めるようになりました。 いちばん大きかったのは、「私はこれがいいと思う」というアイデアを持ってミーティングに参加できるようになったことです。ただ仕様を待つのではなく、要件定義の段階から意見を出し、仕様を決めていく側に少しずつ入っていけるようになりました。 そうやって関わっていると、「この機能は今いちばん自分が詳しい」というオーナーシップも自然と生まれてきます。自分の提案が仕様に反映され、動くものを通じてプロダクトの方向性が決まっていく。その過程に関われるようになって、プロダクトづくりが前より一層楽しくなりました。 生成AIによって「まずつくってみる」ハードルが下がったことで、エンジニアが上流の議論に入りやすくなったと感じています。プロトタイプをつくってミーティングに持ち込むことは、単に開発を速くするだけではなく、エンジニアがより主体的にプロダクトづくりに関わるためのきっかけにもなるのだと思います。 まとめ "Build First, Discuss Later" は、先に動くプロトタイプをつくり、それを見ながら議論することで、意思決定を速くする進め方です。みなさまも、「仕様待ちで開発が始められない」「仕様が曖昧で手戻りが多い」と感じたら、自分なりのプロトタイプを会議に持ち込んでみてください。会話が前に進むだけでなく、プロダクトづくりの楽しさも少し違って見えてくると思います。 次の記事は mewutoさんです。引き続きお楽しみください。
こんにちは。メルペイでソフトウェアエンジニアをしている @sapuri です。この記事は Merpay & Mercoin Tech Openness Month 2026 の 9日目の記事です。 はじめに 本記事は、2026年4月27日の Background Job Talk 〜 Temporal 活用と独自実装の舞台裏編〜 で発表した「内製ワークフローエンジンの設計とメルカリでの活用事例」を記事化したものです。 マイクロサービスアーキテクチャのような分散システムでは、複数のサービスにまたがる処理のデータ整合性をどう保つか、いわゆる分散トランザクションの扱いが大きな課題となります。 メルカリでは、この課題を Saga パターンによる結果整合性で解決するために、自社でワークフローエンジンを開発して運用しています。 このワークフローエンジンは、もともとメルコインの決済基盤における分散トランザクション管理のために開発したものです。メルペイの Payment Service で得た知見も取り入れながら設計し、現在はメルカリグループ内の複数のユースケースで利用が広がっています。 この記事では、内製に至った背景とワークフローエンジンの具体的な設計、社内での活用事例について紹介します。 分散トランザクション管理の課題と Saga パターン メルカリでは主にマイクロサービスアーキテクチャを採用しています。 そのため、お客さまがアプリで1つの操作をすると、そのリクエストは基本的に複数のサービスをまたいで処理されます。 例えば、メルカリアプリからビットコインを購入するときの決済リクエストでは、取引データの作成、メルコインの日本円残高の減算、メルペイのポイントの減算、ビットコイン残高の加算、取引データの更新といった複数の処理が関わります。 この決済リクエストは、1つのトランザクションとして扱う必要があります。つまり、一連の処理をすべて成功させるか、すべて失敗させるかのどちらかに寄せる必要があります。 しかし、各サービスがそれぞれデータベースを持っているため、単純にロールバックすることはできません。この点を考慮せずに実装すると、エラーのタイミングによってデータの不整合が発生します。 例えば、このような不整合が起こりえます。 決済が失敗したのにメルコインの日本円残高が減っている 残高は減ったがビットコイン残高が加算されない ビットコインと交換できているのに取引が完了扱いになっていない また、Two-Phase Commit のような分散トランザクションでは長期間リソースをロックするため、サービスの可用性が下がる可能性があります。 そのため、メルカリでは結果整合性のアプローチで、このような分散トランザクションを解決しています。 Saga この結果整合性を実現するためのアーキテクチャの1つとして、Saga というパターンがあります。 Saga は、トランザクションを複数の小さなトランザクションに分割して順次実行することで長時間のロックを不要にします。途中でリトライ不可能なエラーが出た場合は、成功済みの処理に対する補償トランザクションを逆順で実行します。 先ほどの暗号資産購入の例で、途中のビットコイン残高を増やす処理でリトライできないエラーが発生した場合を考えます。 この場合、この時点までに成功した処理を取り消す補償トランザクションを逆順に実行します。すでにメルコインの残高とメルペイのポイントが減らされているので、まずポイントを戻し、その次にメルコイン残高を戻し、最後に取引データを失敗として更新します。 このように実装することで、途中のどこで失敗しても結果整合性を保って処理を完了させることができます。 このあたりの話は以前の記事でも紹介しているので、興味のある方はそちらもぜひご覧ください。 メルコイン決済基盤における分散トランザクション管理 | メルカリエンジニアリング ワークフローエンジンの検討 実装の方針が決まったので、実際に Saga パターンを実装するためにワークフローエンジンの導入を検討しました。 主に検討したツールは、 GCP Workflows 、 Cadence 、 Temporal です。メルカリでは主に GCP を使ってサービスを構築しているため、まず GCP Workflows を検討しました。ただ、各処理を HTTP のエンドポイントとして実装する必要があり、ユニットテストがやりにくいという懸念がありました。また、YAML ではなく Go のコードでワークフローを記述したいという要望もありました。 Cadence と Temporal も検討しましたが、メルカリでは Cloud Spanner をメインに使っているため、Spanner に対応していなかったことから採用できませんでした。また、Temporal はシステムの規模が大きく、仕組みも比較的複雑なため、運用面にも不安がありました。 このように、既存ツールでは要件を満たせなかったため、自社でワークフローエンジンを開発することにしました。 開発時には、Cadence / Temporal のインターフェースの良さを取り入れつつ、メルペイの Payment Service ですでに実績があった「DB への実行状態の永続化 x インメモリキュー x Worker での実行管理」のアーキテクチャを再利用する方針にしました。また、Go 専用で必要な機能のみに絞ることで、数人の兼務メンテナーでも運用できる規模にしています。 ワークフローエンジンの設計 アーキテクチャ このワークフローエンジンは、アプリケーションサーバーと同じ Pod でデプロイされることを想定しています。 Go runtime で動作し、利用者は SDK として扱います。 アプリケーションは Manager というインターフェースを使ってワークフローエンジンを操作します。主に Register と Execute という2種類のインターフェースを使います。 アプリケーションはワークフローを普通の Go の関数として実装するので、その関数の内容を事前にワークフローエンジンに登録する必要があります。 manager.RegisterWorkflow() が呼び出されると、Manager は Registry というインメモリの領域に関数を格納します。 manager.Workflow().Execute() は、実際にワークフローを実行するインターフェースです。 呼び出されると、Manager は Engine Server という gRPC サーバーに対して Workflow や Activity を作成するリクエストを送ります。 Engine Server は関数名や引数、実行状態を DB に保存し、インメモリキューである Channel に WorkflowStarted イベントを publish します。 その後、Worker という goroutine が WorkflowStarted イベントを subscribe し、Registry から実行する関数を取得して、Go のリフレクションを使って実行します。 実行が完了すると Worker は Engine Server に完了を報告し、Engine Server は結果を保存して WorkflowCompleted イベントを publish します。 その後、Worker が WorkflowCompleted イベントを subscribe し、アプリケーションに関数の実行結果を返却します。 もしその結果がエラーだった場合は、後述する ErrorMarshaler というインターフェースで、その Workflow を完了させるかどうかを判定します。 ここまで説明したコンポーネントの役割を整理します。 Manager : SDK のエントリーポイント。アプリケーションは Workflow() 、 Activity() 、 RegisterWorkflows() などを呼び出します。 Engine Server : Create、Complete、List などの gRPC API を提供するサーバー。DB に Workflow や Activity の I/O と状態を保存します。 Channel : Workflow や Activity の状態遷移イベントのハブとなるインメモリキュー。 Workers : Workflow や Activity を実行する goroutine 群。Channel から状態遷移イベントを購読し、イベントの種別に応じた処理を実行します。 Registry : Register された関数をインメモリで保持します。 Recovery Worker : Engine Server に対して定期的に未完了の Workflow と Activity を List してリトライします。 コードサンプル アプリケーション側の実装イメージは次のようになります。 func (s *Service) createExchangeWorkflow(ctx context.Context, params *CreateExchangeParams) (*CreateExchangeResult, error) { saga := workflow.NewSaga(s.wm) if err := s.wm.Activity(s.authorizeBalance, params.Balance).ExecuteWait(ctx); err != nil { return nil, err } saga.AddCompensation(s.cancelBalance, params.Balance) if err := s.wm.Activity(s.authorizePoint, params.Point).ExecuteWait(ctx); err != nil { if !isCompletableError(err) { return nil, err } if cerr := saga.Execute(ctx, func(e execution.Execution) error { return e.Wait(ctx) }); cerr != nil { return nil, fmt.Errorf("failed to execute compensation activities: %w, orig_err: %v", cerr, err) } return nil, err } return &CreateExchangeResult{}, nil } まず、 createExchangeWorkflow という関数が定義されています。 この関数は、残高を確保する authorizeBalance という Activity と、ポイントを確保する authorizePoint という Activity を順に実行して結果を返す処理です。 特徴的なのは、それぞれの Activity を実行した直後に、この SDK が提供する Saga の AddCompensation インターフェースで補償トランザクションを登録している点です。 これにより、 authorizeBalance Activity が成功した後に authorizePoint Activity が失敗した場合は、 authorizeBalance を取り消す処理である cancelBalance という関数が補償トランザクションとして実行されます。 エラーハンドリング このワークフローエンジンでは、3種類のエラーを定義しています。 Completable Error : Workflow や Activity を失敗として完了させてよい、想定されたエラーです。例として、残高不足や利用制限があります。 Retryable Error: リトライ対象のエラーです。 Incompletable Error: Workflow を完了させずに停止し、Recovery Worker が後でリトライするエラーです。 Completable Error は、明示的に完了できるエラーだけを完了扱いにするための仕組みです。 クライアント側で ErrorMarshaler というインターフェースを実装したエラーとして定義される 該当しないエラーはすべて未完了として実行を停止し、Recovery Worker によってリトライされる 明示的に Completable Error を返さない限り Workflow は完了しない アプリケーションが意図していない異常な状態で Workflow が完了しない設計になる type ErrorMarshaler interface { MarshalCompletableError(error) ([]byte, error) UnmarshalCompletableError(marshaledErr []byte) error } 具体例として、ドメインのカスタムエラー型に Completable() というメソッドを定義し、それを使って ErrorMarshaler を実装します。 このようなカスタムエラー型を作っておくことで、ビジネスロジックで特定のエラーコードを含むエラーを返すと、ワークフローエンジンで完了可能なエラーとして処理されます。 type Error struct { code ErrorCode msg string } func (e *Error) Error() string { return e.msg } func (e *Error) Completable() bool { return e.code == ErrCodeCompletable } type workflowError struct { Code ErrorCode Msg string } type workflowErrorMarshaler struct{} func (workflowErrorMarshaler) MarshalCompletableError(err error) ([]byte, error) { var aerr *Error if !errors.As(err, &aerr) { return nil, err } if !aerr.Completable() { return nil, err } return json.Marshal(&workflowError{ Code: aerr.code, Msg: aerr.msg, }) } func (workflowErrorMarshaler) UnmarshalCompletableError(data []byte) error { var werr workflowError if err := json.Unmarshal(data, &werr); err != nil { return err } return &Error{ code: werr.Code, msg: werr.Msg, } } 活用事例 ここまで、独自に開発したワークフローエンジンの設計について紹介しました。 ここからは、決済以外のユースケースも含めて、社内での活用事例を3つ紹介します。 1. メルカリモバイル: 同期レスポンスと非同期処理の分離 例えば、メルカリモバイルの回線開通フローでは、Workflow と Child Workflow というサブのワークフローを定義し、同期レスポンスと非同期処理を分離しています。 具体的には、Workflow と Child Workflow で役割を分けています。Workflow は回線開通リクエストを DB に保存し、Child Workflow を fire-and-forget で起動してレスポンスを返します。 Child Workflow は、非同期で Pub/Sub イベントを発行します。失敗した場合は、Recovery Worker が Child Workflow を復旧します。 これにより、クライアントに即座にレスポンスを返しつつ、Pub/Sub 発行のような後続処理がバックグラウンドで実行されることを保証できます。 2. メルカリ グローバル EC 基盤: Saga によるチェックアウト管理 メルカリ グローバル EC 基盤では、Spanner ではなく PostgreSQL を採用しています。 ここでは、購買代行パートナーを経由して海外のお客さまが日本のメルカリの商品を購入する処理を例にします。 例えばチェックアウト確定フローでは、クーポン消費、注文作成、購買代行パートナーへの注文連携、注文確定通知送信の順に処理が進みます。 このチェックアウトの処理で、冒頭で紹介した暗号資産購入のユースケースと同様に Saga パターンを使ってトランザクションを管理しています。 途中で失敗した場合には、Saga による補償トランザクションでキャンセルします。 内製のワークフローエンジンを採用した理由には、社内にすでにある類似実装や運用基盤を活用したかったことがあります。また、メンテナーが社内にいるため直接サポートを受けられることや、必要な機能を柔軟に追加できて最適化しやすいことも大きな理由でした。 3. eKYC: Signal を使った long-running workflow eKYC によるお客さまの本人確認フローは、Workflow の中で数時間から数日の審査待ちが発生するという、いわゆる long-running workflow になっています。 これを実現するために、Temporal でも提供されている Signal という機能を実装しました。 Signal を使うことで、Workflow を中断し、外部から Workflow に情報を送って再開させるユースケースを実現できます。 長期フローを細切れのジョブとして分割せず、書類検証、審査待ち、承認または拒否という一連の流れを1本の Workflow として表現できます。 Signal を使ったアプリケーション側の実装イメージは次のようになります。 type ApprovalSignalParams struct { Approved bool RejectReason string } func (s *Service) approvalWorkflow(ctx context.Context, req *ApprovalRequest) (*ApprovalResult, error) { var result *ApprovalResult if err := s.wm.Activity(s.verifyDocument, req).ExecuteGet(ctx, &result); err != nil { return nil, err } var params ApprovalSignalParams if err := s.wm.Signal("approval").Receive(ctx, &params); err != nil { return nil, err } if !params.Approved { return nil, newCompletableError(params.RejectReason) } return result, nil } func (s *Service) handleApproval(ctx context.Context, workflowIdempotencyKey string, params ApprovalSignalParams) error { return s.wm.Signal("approval").Send(ctx, workflowIdempotencyKey, params) } approvalWorkflow という関数は、 verifyDocument という Activity を実行した後に、 approval という signal を待機します。 審査が終わり、外部から approval signal をこの Workflow に送信すると、 approvalWorkflow が途中から再開します。 まとめ 分散システムにおけるデータ整合性の課題に対して、メルカリでは Saga パターンによる結果整合性を採用し、それを支える仕組みとしてワークフローエンジンを内製しています。 この記事では、その背景と設計、社内での活用事例について紹介しました。 なお、この SDK を使った開発を支援するために、専用の静的解析ツールも開発して運用しています。このあたりの運用面についても発表で触れているので、興味のある方は Speaker Deck のスライドもぜひご覧ください。 次の記事は kubomiさんの「Build First, Discuss Later|初回ミーティングに動くプロトタイプを持ち込んだら、意思決定が爆速になった」です。引き続きお楽しみください。
こんにちは。今年4月に入社したメルペイ Loyalty & Santa(Growth Platform)チームでBackend Engineerをしている@mikupoです。この記事は「 Merpay&Mercoin Tech Openness Month 2026 」の8日目の記事です! はじめに 私が所属しているSantaチームは、メルカリ・メルペイにおけるポイント還元やキャンペーンの基盤となるシステムを開発・運用しています。Santaの処理はすべて非同期で、Pub/SubのPull型 subscriptionを中心に構成されています。 Santaの開発で課題になっていたのが、QAプロセスです。検証に使える開発環境(Dev環境)はチームに1つしかなく、複数の開発(Pull Request 以降、 PR) が重なるとQAを並列に進められませんでした。実装は終わっているのに、検証環境が空くのを待つ、そんな状況が発生していました。 PRごとに独立した環境を用意できれば、この待ち時間はなくせます。ただ、Santaでそれを実現するのは簡単ではありませんでした。Pull型のsubscriptionでは、複数のconsumerが同じsubscriptionに接続している場合、どのconsumerがどのmessageを受け取るかをpublisher側から指定できません。そのため、「このイベントはこのPR環境へ」と狙って届けることができません。 社内にはすでに、こうした課題に使えそうな仕組みもいくつかありました。ただ、それらを Santa に取り入れるには、本番の非同期処理の作りに大きく手を入れる必要がありました。今回達成したいのは QA の改善であり、そのために本番の非同期処理のかたちを大きく作り変えるのは避けたいと考えました。 本記事では、この制約のなかで「Pull型を保ったまま、PRごとに環境を分ける」 をどう実現したのかを紹介します。 Santaについてくわしく知りたい方は、「 メルペイのキャンペーンを支えるサンタの秘密 」をご確認ください。 PR単位の検証環境が必要になった背景 従来、Santaチームの検証に使えるDev環境は1つしかありませんでした。複数のPRを同じDev環境へ同時に反映すると、不具合が起きたときに原因となった変更を切り分けづらくなります。変更同士がconflictする場合は、そもそも並行してQAを進められませんでした。 このボトルネックを減らすための選択肢として、Santaでは Pull Request Replication Controller (PRRC)に注目しました。PRRCとは、GitHubのPRを起点としてDev環境とは別に、PRごとの検証環境(PRRC環境)を作成するためのKubernetes custom controllerを使った仕組みです。 PRRCによってPRごとの検証環境を作る道筋は見えました。次に検討したのが、社内で使われている Pub/Sub gRPC Pusher です。Pub/Sub messageをgRPC requestとして扱えれば、既存の Dynamic Service Routing と組み合わせてPRRC環境へルーティングできるように見えました しかし、SantaのPub/Sub処理はPull型subscriptionを前提に作られています。今回解決したかったのはQAプロセスのボトルネックであり、本番の非同期処理の仕組みを大きく変えることではありませんでした。一方で、gRPC Pusherをそのまま使うには、既存のPub/Sub handlerをgRPC endpointとして受けられる形に変える必要があります。そのため、QA環境の改善として取り組むには、本番環境への影響や変更範囲が大きくなりすぎると判断しました。 そのため、私たちは既存のPull型Pub/Subを保ったままPR単位の検証環境を実現するために、Santa側で満たすべき要件を整理しました。 Pull型Pub/Subを保ったままPR単位の検証環境を作るための要件 既存のgRPC Pusherをそのまま使うのではなく、SantaのPull型 subscriptionを保ったままPR単位の検証環境を実現するには、まず満たすべき要件を整理する必要がありました。PRRC環境を実際の検証に使える状態にするには、messageを意図した環境で受け取れることと、PRRC向けの文脈を後続処理へ引き継げることが重要でした。 ここでは、この2つの要件を順に説明します。 要件1:Dev環境とPRRC環境でmessageを分けて受け取れること 1つ目の要件は、Dev環境で確認したいmessageと、特定のPRRC環境で確認したいmessageを分けて受け取れることです。PRごとの検証環境を作れたとしても、それぞれの環境で確認したいmessageが混ざってしまうと、どのPRの変更による挙動なのかを切り分けづらくなります。 messageが混ざる原因は、Pull型subscriptionの受け取り先をpublisher側から指定できないことにあります。図1のように、PRRC環境のconsumerをDev環境と同じsubscriptionにつなぐと、複数のconsumerが同じsubscriptionからmessageを受け取る構成になります。そのため、Dev環境で確認したいmessageをPRRC環境が受け取ったり、PRRC環境で確認したいmessageをDev環境が受け取ったりする可能性があります。 このため、PR単位の検証環境として使うには、messageを環境ごとに分けて受け取れる仕組みが必要になります。 要件2:PRRC環境向けのルーティング情報を後続処理へ引き継げること 2つ目の要件は、messageがどのPRRC環境向けなのかを、後続の処理にも引き継げることです。Santaの処理はPub/Sub messageを受け取って終わりではなく、処理の途中で他のmicroserviceをgRPCで呼び出したり、別のPub/Sub messageをpublishしたりします。 そのため、SantaのDev環境とPRRC環境でmessageを分けて受け取れるだけでは不十分です。結合テストでは、Santaから呼び出すmicroservice側にもPRRC環境が用意されている場合があります。その場合、Santaからの呼び出しも通常のDev環境ではなく、対応するmicroserviceのPRRC環境へルーティングできる必要があります。 図2のように、このルーティングを実現するには、Santaが受け取ったmessageに付与されたルーティング情報を、後続のgRPC呼び出しやPub/Sub publishにも引き継ぐ必要があります。つまり、message自体にルーティング情報を持たせ、Santa内の処理でもその情報を落とさない仕組みが必要になります。 実現方法の検討 ここまで整理した2つの要件を満たすには、Pub/Sub messageにルーティング情報を持たせたうえで、その情報をどの段階で使ってmessageを振り分けるかを決める必要がありました。大きく分けると、consumerがmessageを受け取ったあとに判断する方法、Topic自体を分ける方法、subscriptionのfilterで受け取るmessageを分ける方法があります。 私たちは、それぞれの方法について、Pull型subscriptionを維持できるか、対象外のmessageを余計にpullしないか、PRRCごとの運用負荷が大きくなりすぎないか、という観点で比較しました。 最初に検討したのは、consumer側でmessage attributeを見て、対象外のmessageを処理しない方法でした。この方法は実装範囲が小さく見えますが、対象外のmessageも一度pullしてしまいます。対象外のmessageをackすると本来処理すべき環境に届かず、nackするとredeliveryが繰り返される可能性があります。 TopicをDev用とPRRC用に分ける方法も検討しました。この方法では、あらかじめPRRC用のTopicとsubscriptionのペアを用意しておき、PRごとにどのペアを使うかを割り当てます。Topic単位で分離できるため構成は直感的ですが、PRごとの割り当てを手動で管理する必要があります。そのため、割り当て忘れや重複割り当てが起きやすく、PRRC環境が増えるほど運用負荷が高くなります。 これらに対して、subscription filterを使う方法では、consumerがmessageをpullする前に、subscription側で受け取るmessageを分けられます。さらに、振り分けに使うmessage attributeをそのままルーティング情報として扱えるため、後続のPub/Sub publishやgRPC呼び出しにも同じ情報を引き継げます。つまり、要件1の「messageを環境ごとに分けて受け取ること」と、要件2の「ルーティング情報を後続処理へ引き継ぐこと」を、同じmessage attributeを軸に実現できます。 最終的な方針 最終的な方針は、Pull型subscriptionを維持したまま、pubsub messageのattributeで配送先を分けることです。各messageのattributeにルーティング情報を付け、subscription側はその値をfilterの条件にします。こうすると、同じtopicに届いたmessageでも、条件に一致したsubscriptionだけがmessageを受け取ります。 図のように、たとえば PR番号1234 を検証する場合、その message の attribute には、PR番号に対応するルーティング情報(PR1234)を付与します。Dev環境のsubscriptionはこのmessageを受け取らず、PR1234用のsubscriptionだけが受け取ります。 さらにこのルーティング情報は、Santaがpublishするmessageにも引き継げます。受け取ったPub/Sub messageのattributeからルーティング情報を読み取り、contextに格納し、後続のpublishや外部microserviceの呼び出しへ渡します。これにより、PRRC の文脈が処理の途中で途切れません。 一方で、外部サービスの subscription にそのまま filterを足すことはできませんでした。Santa は、ポイント還元などのトリガーとなるイベントを、決済をはじめとする他のmicroservice から Pub/Sub で受け取っています。これらの外部 subscriptionは発行側の microservice(=別チーム)の管理範囲にあり、Santa が直接 filterを足すと、別チームが管理するリソースに手を入れることになってしまいます。そこで外部 subscription については proxy sidecar を挟み、Santa 側の proxy topicへ再 publish する構成にしました。Santa の Pod は外部 subscription を直接 pullせず、proxy topic に対して作った Dev / PRRC 用の filtered subscriptionを読みます。 この方針により、Pull型subscriptionを維持したまま、Pub/Sub messageを意図した環境だけに届けられ、しかも変更をSantaの管轄内だけで完結できます。一方で、PRRCごとにsubscriptionを作る必要があるため、その自動作成・削除の設計が新たに必要になりました。 まとめ 本記事では、Pull型Pub/Subを維持したまま、Pub/Sub message attributeとfiltered subscriptionを使ってSantaにPRRC環境を導入した取り組みを紹介しました。 従来は、QA期間が重複する場合には統合環境を用意するか、複雑なプロジェクト同士の場合は直列で進めてQA待ちが発生するしかありませんでした。しかし、実際にこの仕組みを用いることで、直近の大きな2つのプロジェクトではQAを並列化して進めることができました。また、共有設定を変更するテストを直列でしか実施できなかったのが、QAメンバーの人数に合わせて3つ4つとスケールできるようになり、QA期間の短縮にも貢献できました。 自分たちのチームの状況も踏まえながら、よりチームに合った解決策を模索し、それを実現できたのは、チームの皆さんの協力があってこそです。Loyalty & Incentiveチームの皆様、ありがとうございました! 次の記事は sapuriさんの「内製ワークフローエンジンの設計とメルカリでの活用事例」です。引き続きお楽しみください。
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 EGP Code は、ランディングページ(LP)を AI と作る社内向けのエディタです。作成背景については AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 という記事で紹介しました。本記事では、その内部で動いている 4 つの仕組みを紹介します。 EGP Code が扱うのは、HTML と状態や動きを担う少数の <egp-*> (独自の Web Components)を混ぜた 1 枚のページです。 <section class="p-6 text-center"> <h1>春のキャンペーン</h1> <p>応募受付中</p> <egp-button>応募する</egp-button> </section> この HTML を AI エージェントとの対話やエディタで編集し、プレビューで確認して公開します。紹介する 4 つの仕組みは、(1) エージェントの再帰ループ、(2) Firestore を介したリアルタイム反映、(3) ブラウザだけで完結するテストランナー、(4) プレビューと HTML を結ぶ対応表です。 1 つの指示がユーザーから届いてプレビューに反映されるまでの流れと、それぞれの仕組みが効く場所は次のとおりです。 仕組み 1: 文脈とツールを束ねるエージェントの再帰ループ 「フォントサイズを 24px にして」「文字を太くして」のように特定の要素への簡単な指示なら、その要素を特定して CSS を更新したり文言を調整したりするだけなので、ほぼ 1 回の操作で終わります。一方で複数の要素にまとめて指示したり、API を使った画面やテストを作ったり、Lint・テストのエラーを直したりする場合は、推論とツール実行を何度か往復することになります。 エージェントは「推論 → ツール実行 → 結果を会話履歴に追加 → 再び推論」という再帰ループで動きます。ここでいうツールとは、AI が必要と判断したときに呼べる関数の定義と説明のことです。HTML を書き換える、ファイルを読む、Lint で検証する、テストを走らせる、といった操作をツールとして用意しておき、モデルがその中から必要なものを選んで呼び、戻り値を見て次の手を考えます。 1 回の入力に対して 4 つの情報をまとめて渡します。システムプロンプトで AI エージェントに役割やコード生成のルールといった前提を与え、それにユーザーの指示と選択要素と現在の HTML、これまでの会話履歴、そして使えるツールの一覧を教えます。このうち現在の HTML・仕様・テストといったページの状態は、種類ごとに XML タグで区切ってまとめます。 会話履歴の持ち方は、開発当初 OpenAI API 側に任せていました。しかし全社的に ZDR(Zero Data Retention) を適用することになり、プロバイダ側に会話を残せなくなったため、いまは履歴をすべて自前で記録し、毎回のリクエストに載せて送るステートレス方式にしています。履歴の肥大化を防ぐため、一定量を超えたら要約させてコンテキストを圧縮しています。 // 1 ラウンドの推論 const stream = await client.responses.stream({ ...args, input: sessionBuffer.getItems(), // 自前で管理している履歴全体を送る previous_response_id: undefined, store: false, // プロバイダ側に会話を保存させない }); ループの工夫をいくつか紹介します。 1 つ目は、ツールの失敗の扱いです。ここでいう失敗とは、 apply_patch の差分が当たらない、Lint がエラーを返す、テストが落ちるなど、ツールが期待どおりに完了しなかった状態を指します。こうした失敗ではループを止めず、エラーの内容をそのまま結果としてエージェントに返すことで自己修正させています。 2 つ目は、 find_skill ツールによる情報の出し分けです。社内 API の使い方などのドキュメントは、最初は ID と一行説明の一覧だけを見せておき、本文は必要になったタイミングで読み込みます。たとえば「商品一覧を表示したい」という指示が来ると、エージェントはまず一覧から関連しそうなものを探し、 find_skill でそのドキュメント本文を取得します。エージェントは取得したドキュメントを読み、正しい引数で API を呼びます。 この推論とツール実行の往復を繰り返し、最後にエージェントが apply_patch で HTML を差分更新すると 1 つの指示が編集として完成します。 ループの途中で方向を変える Real-time steering ここまでは、1 つの指示を最後まで処理してから次を受け取る前提でした。ですが実際には、処理の途中で「やっぱり色は青にして」と方針を変えたり、「ついでにフッターも直して」と指示を足したくなることがあります。完了を待たずに割り込みで指示を足し、走っているループに後から反映する仕組みを用意しています。こうした仕組みは Real-time steering とも呼ばれます。 ユーザーが処理中に指示を足したときの流れは、次のようになります。 エージェントが処理中(画面がローディング中)にユーザーがメッセージを送ると、クライアントはそれを通常の指示ではなく、割り込みメッセージとして送信します。サーバは受け取った割り込みメッセージを、 Firestore の通常の会話履歴とは別のサブコレクションに、いま走っているリクエストの ID を添えて書き込みます。エージェントは、自分が処理しているリクエストの ID に一致する割り込みだけを読み取ります。 // 自分のリクエスト宛の割り込みメッセージだけを読み、読んだら消す const consumeSteeringMessages = async (conversationId, requestId) => { const snapshot = await steeringRef .where('requestId', '==', requestId) // このリクエスト宛だけを対象にする .orderBy('timestamp', 'asc') .get(); const messages = snapshot.docs.map((doc) => doc.data()); await deleteDocs(snapshot.docs); // 読んだら消す return messages; }; ID で絞るので別のリクエスト宛の割り込みを拾うことはなく、読んだら消すので同じ指示が二重に効いたり取りこぼしたりすることもありません。処理のループに差し込むため、割り込みが来たときにエージェントが何をしようとしていたかで、対応が 2 つに分かれます。 1 つ目は、ツールを呼ぼうとしていた場合です。そのツールを実行せず、戻り値の代わりに「ツールは実行していません。処理中に新しい指示が届きました」という内容を返します。 // ツール実行の直前に割り込みを確認する const steering = await consumeSteeringMessages(conversationId, requestId); if (steering.length > 0 && toolCalls.length > 0) { for (const toolCall of toolCalls) { // ツールは実行せず、戻り値の代わりに割り込みを差し込む buffer.pushMessage({ role: 'tool', tool_call_id: toolCall.id, content: '[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...', }); } continue; // 計画を立て直すため、もう一度推論へ } 2 つ目は、ツールを呼ばずに、ユーザーへの返信メッセージを作り終えていた場合です。本来ならこれを見せてループが終わるところですが、割り込みが届いたので止めるべきツールがありません。この返信はまだ画面に出していないので破棄し、直前のユーザーの指示に割り込みメッセージを足して送り直すことで、続きの作業を依頼します。 // ツールがない場合は、画面に出していない返信を捨てて指示を足す if (steering.length > 0 && toolCalls.length === 0) { buffer.pop(); // まだ画面に出していない返信を捨てる const priorUserTurn = buffer.pop(); // 直前のユーザーの指示を取り出す buffer.pushMessage({ role: 'user', content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...`, }); continue; } いずれの場合も、すでに実行した副作用を巻き戻すわけではなく、これから実行するはずだったツールを止めたり、まだ画面に出していない返信を捨てたりして、計画を組み直しています。 仕組み 2: Firestore を指示の受け渡し場所にしたリアルタイム反映 仕組み 1 で見たように、エージェントは複数のツールを往復させて指示に応えるため、編集には時間がかかることがあります。処理が終わるまで画面が何も更新されないと、利用者は反映されたかどうか分からないまま待つことになります。 そこで Firestore SDK を利用して、 変更をリアルタイムに受け取れる ようにしています。サーバ側は 1 つの操作が終わるたびにその内容を Firestore へ書き込み、ブラウザ側はそれをサブスクライブして即座に検知・反映します。これで自前で WebSocket を張らずに、編集の途中経過をそのままプレビューへ反映できます。 エージェントが何かを書き換えると、サーバは会話ごとのコレクションに、次のようなドキュメントを 1 件追加します。 { "status": "PENDING", "requests": [ { "action": "setHtmlSchema", "payload": "<body> ...更新後の HTML... </body>", "reason": "ユーザーの依頼を反映" } ] } ブラウザ側の JS は、Firestore の SDK( onSnapshot )でこのコレクションをサブスクライブしており、 PENDING のドキュメントが届くと requests の各操作をエディタの状態へ反映します。たとえば setHtmlSchema なら、エディタが表示している HTML を新しいものに置き換えて、プレビューを再描画します。 ブラウザが requests の操作を反映し終えると、その JS が status を COMPLETED に書き戻します。HTML 差し替えなどの「反映だけ」のアクションは、投げたら終わりで結果を待ちません。一方でテスト実行のように結果が必要なアクションでは、ドキュメントが COMPLETED になるまで一定間隔で読み直して、書き込まれた結果を取り出します。取り出した結果はツールの戻り値としてエージェントに返り、それを見て次のツール呼び出しを決めます。 仕組み 3: ブラウザだけで完結するテストランナー 応募ボタンを押したときの API 呼び出しやその結果に応じた表示の切り替え、リンクによる画面遷移といった LP の動作を手動で確認するのは手間がかかります。そこで EGP Code では、こうした動作をテストで確かめられるようにしています。 テストはブラウザ上のエディタで直接書いたり、AI に書かせたりできます。これらのテストは、サーバや CI ではなく、プレビューと同じブラウザの中で実行します。ただし Jest や Vitest は Node.js 上で動くツールなので、そのままブラウザには読み込めません。そこで test / it / describe / expect を提供する小さなランナーを自作しました。アサーションは単体で使える @vitest/expect 、DOM 操作は Testing Library をそのまま利用しています。 // 自作の test 関数でテストを定義する test('エントリーボタンで API が呼ばれる', async () => { // Testing Library でユーザーのクリックを再現する await userEvent.click(screen.getByText('エントリー')); // @vitest/expect で結果を検証する expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが社内 API を呼ぶこともありますが、本番に飛ばすわけにはいきません。そこで iframe 内で window.fetch を差し替え、リクエストはすべてモック関数に通します。モックしていない呼び出しはエラーになるので、本番へ漏れることはありません。 テストの実行は、この iframe を作るエディタのページ(ホスト)と iframe の postMessage のやり取りで進みます。 ホストは iframe を作ってテスト用 HTML を流し込み、iframe 側の初期化( window.fetch の差し替えなど)が済むのを待ってから実行を指示します。先に指示が届くと取りこぼすため、必ず「準備完了」を待つようにします。結果は仕組み 2 のアクションでエージェントへ戻ります。失敗していれば、仕組み 1 で触れた自己修正がここで働き、内容を読んで実装を直してもう一度走らせます。 仕組み 4: プレビューの要素と HTML の位置を結ぶ対応表 ここまではエージェント主導の編集を見てきましたが、人が直接手を入れる場面もあります。Code タブを開くと Monaco エディタで HTML を直接編集でき、プレビューでリアルタイムに確認できます。プレビューの要素をクリックしてエージェントへ指示を出したり、Monaco の対応行へジャンプするには、「プレビューの要素と HTML 上の位置」を対応づける仕組みが必要です。 HTML をパースして各要素へ data-egp-src-id (公開ページには残らない内部 id)を注入してプレビューを描画することで、これを実現しています。 <section data-egp-src-id="src-10"> <h1 data-egp-src-id="src-11">春のキャンペーン</h1> <egp-button data-egp-src-id="src-42">応募する</egp-button> </section> プレビュー上で「応募する」ボタンをクリックすると src-42 が取れます。クリック先が Web Components の内部要素などで id を持たない場合もあるため、 closest で祖先方向に遡って最寄りの data-egp-src-id を探します。 取得した id はエディタが各要素に振った内部 id で、対応表を引くキーになります。対応表はパース時に作る「id → その要素の HTML 上の位置」の Map で、id を引くと文字列オフセットやタグ名が取れます。 // 対応表(id → HTML 上の位置とタグ) Map { 'src-10' => { range: { startOffset: 0, endOffset: 130 }, tagName: 'section' }, 'src-42' => { range: { startOffset: 64, endOffset: 110 }, tagName: 'egp-button' }, // ... } クリックされるのは描画後の DOM 要素で位置を持つのはこの対応表です。要素に振った data-egp-src-id をキーにすることで、両者を突き合わせています。この対応表には 2 つの用途があります。1 つ目が Monaco エディタへのジャンプです。 // 対応表から位置を取得 const range = await mappingApi.findRangeById('src-42'); // オフセットを Monaco の行・列に変換 const pos = model.getPositionAt(range.startOffset); // その行をエディタの中央に表示してカーソルを移動 editor.revealPositionInCenter(pos); 取得した id を元に対応表から文字列オフセットを取り出して Monaco の行・列に変換します。その行をエディタ中央にスクロールしてカーソルを移動することでジャンプさせています。 もう 1 つが AI への指示です。 data-egp-src-id が付いているのはプレビュー用に描画した HTML だけで、エージェントが読み書きする公開用の HTML には付いていないため、id をそのまま渡しても対応する要素は見つかりません。そこで id は対応表を引くキーとしてのみ使い、エージェントには、そこから取り出した要素の HTML・タグ名・行番号をユーザーの指示文と一緒に XML へまとめて渡します。 <annotated_elements> <annotation> <tagName>egp-button</tagName> <snippet><egp-button class="..."></snippet> <lineNumber>10</lineNumber> <instruction>色を赤にして</instruction> </annotation> </annotated_elements> エージェントはこれらを手がかりに HTML の中から対象要素を見つけて指示に対応します。このように指示を XML タグで構造化する書き方は、 Anthropic のプロンプトのベストプラクティスの一つ としても紹介されています。 おわりに 一見シンプルに見える AI エディタですが、実際に体験を作ろうとすると、待ち時間が長い、途中経過が見えない、どこを編集したか分からないといった小さなつまずきが積み重なります。EGP Code では、そのつまずきを 1 つずつ潰すために、ここまで紹介した仕組みを裏側で積み上げてきました。AI を組み込むときほど、モデルの手前にある体験と安全性を設計することが重要だと感じています。本記事が、AI を組み込んだプロダクトづくりの参考になれば幸いです。 次の記事は@mikupoさんです。引き続きお楽しみください。
こんにちは。メルコインのフロントエンド(FE)エンジニアとしてインターンをしている@nanacomです。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 はじめに インターンではFEに限らず、要件定義からバックエンド(BE)開発まで、1つのプロジェクトに幅広く取り組みました。その中で、メルコインの社内ツールを開発する際に、2つのAPIの結果を日時降順にマージして返すエンドポイントを実装するケースに直面しました。 結果を結合して並べ替えるだけならシンプルですが、 マージした一覧にもページネーションを提供しようとすると、各ソースのカーソルをどこまで進めるべきか が複雑になります。本記事では、「マージ結果として採用された件数」と「各データソース側で進めるべきカーソル」のズレにどう対処したかを紹介します。具体的には、データ取得とカーソル確定を分離する「2フェーズ取得パターン」と、各ソースのカーソルを1つのトークンに束ねる「複合ページネーショントークン」の2つの設計を取り上げます。 前提:対象とするユースケース マイクロサービスアーキテクチャでは、BFF(Backend For Frontend)で複数のサービスからデータを集約して一覧表示することがよくあります。今回対象としたのは、2つの独立したデータソース(A, B)のデータをマージするケースです。いずれも日時降順にソートされたデータを返し、それぞれがカーソルベースのページネーションAPIを提供しています。カーソルベースのページネーションとは、前回の取得結果の末尾を示すトークン(カーソル)を次のリクエストに渡すことで、続きのデータを取得する方式です。 この2つのソースの結果を日時降順にマージした一覧をクライアントに返しつつ、その一覧自体にもページネーションを提供する必要がありました。つまり、各ソースが独立して管理するカーソルを、BFF側でどう扱うかが設計上の焦点でした。   売買と入出金をマージした一覧表示(※表示データはすべてダミーです) 素朴なアプローチとその限界 この設計上の焦点に対して、私たちはまず2つの素朴なアプローチを検討しました。いずれも限界があり、最終的な設計への動機となりました。 アプローチ1:全件取得してソート 最も単純な方法は、両ソースから全件を取得し、アプリケーション側でソートしてからページごとに切り出す方法です。しかし、データ数が増えるとメモリ使用量とレイテンシーが線形に増加するため、スケールしません。 アプローチ2:各ソースからpageSize件取得してマージ 各ソースからそれぞれ pageSize 件を取得し、マージして上位 pageSize 件を選択する方法です。データ取得量を抑えられるため現実的ですが、ここで1つの問題が発生します。 例として pageSize=5 のとき、Aから [A1..A5] 、Bから [B1..B5] が返ってきたとします(いずれも日時降順)。これらをマージして上位5件を作ると、マージ結果に含まれるのがAから3件(A1,A2,A3)、Bから2件(B1,B2)になるとします。 次のページでは本来、AはA4から、BはB3から取得を再開する必要があります。しかし各ソースAPIが返すカーソルは「返却リスト末尾の次」を指すため、手元のカーソルはA6(= Aを5件進めた次)やB6(= Bを5件進めた次)を指してしまいます。 マージ結果に必要な再開位置(A4/B3)と、手元のカーソル(A6/B6)が一致しません。 (図1)各ソースから取得 (pageSize=5) Source A: [A1][A2][A3][A4][A5] -> cursorA = A6 Source B: [B1][B2][B3][B4][B5] -> cursorB = B6 マージして上位5件を採用すると、実際に消費したのは Aが3件 / Bが2件 になります(採用: A1 A2 A3 / B1 B2)。 このとき次ページで「本当に再開したい位置」と「手元のカーソル」がズレます。 ソース 次ページで本当は 手元のカーソル A A4 から再開 A6 を指す B B3 から再開 B6 を指す これが、本記事で解決する核心的な課題です。次のセクションでは、この課題に対して理想的にはどう解決すべきかを考え、そのうえで私たちが採った設計方針を説明します。 理想の解決策と現実の制約 カーソルベースAPIでは、返却件数とカーソルの進行量が常に一致します。 pageSize=5 でリクエストすれば5件返り、カーソルも5件分進みます。しかし今回のように複数ソースのデータをマージするケースでは、5件取得しても実際に採用するのは一部だけです。この「取得件数」と「消費件数」のズレが根本原因です。 仮に各ソースのAPIがカーソルではなくタイムスタンプによる範囲指定をサポートしており、かつソース内のタイムスタンプが一意であれば、この問題は発生しません。例えば、以下のように、マージ結果で最後に消費したアイテムの日時を基準に次ページを取得できます。 GET /orders?before=2025-01-01T10:00:00Z&limit=5 GET /transfers?before=2025-01-01T10:00:00Z&limit=5 この方式であれば、各ソースの消費済み最終タイムスタンプを1つのトークンに含めるだけで、BFF側に状態を持たずに1回のリクエストでページネーションを実現できます。また before で過去方向に切るため、新しいデータが追加されてもページ跨ぎの重複が起きません。 しかし、各マイクロサービスのAPI仕様を変更するのは現実的ではないため、既存仕様のままBFF層で解決する方法を検討しました。 設計方針の決定 BFF層での解決策として、トークンへの情報埋め込み、サーバー側キャッシュ、データ取得とカーソル確定の分離という3つの方法を検討しました。設計のシンプルさとステートレス性を重視した結果、3つ目の「2フェーズ取得」方式を採用しました。 方法1:トークンに情報を詰め込む(拡張複合トークン) 各ソースのカーソルを1つのトークンに束ねて返す際に、 カーソルだけでなく、次ページを再開するために必要な情報をまるごとトークン内に埋め込む 設計です。例えば「Aから何件/Bから何件消費したか」のようなメタ情報も含め、JSONにまとめてBase64エンコードして返します。 { "cursorA": "abc123", "cursorB": "def456", "consumedA": 3, "consumedB": 2 } この方式だと、クライアントが次のリクエストでトークンをそのまま返すことで、サーバーはトークンをデコードするだけで「次ページの再開位置(A4/B3など)」を復元できます。 しかし、既存のソースAPIがカーソルベースの仕組みを提供している中で独自にオフセット等も管理すると、「カーソルの意味」が二重になり設計が複雑化するため、採用しませんでした。 方法2:Redisなどで「使わなかったデータ」を保持する(サーバー側キャッシュ) 各ソースから pageSize 件ずつ取得してマージした結果、 採用されなかった"余り"のデータ(例:A4, A5 / B3, B4, B5)をサーバー側で保持しておく 設計です。例えばユーザー(またはリクエスト)単位のセッションキーでRedisに格納します。 session:user123 → { unusedA: [A4, A5], unusedB: [B3, B4, B5] } 次のページのリクエストが来たら、 まずRedisに残っているデータを先に使ってマージし 足りない分だけ各ソースAPIから追加取得する という流れにすれば、カーソルのズレ問題を回避できます。 しかし、サーバー側に状態を持つことになり、社内ツールの規模に対してインフラの運用コストが見合わないため、採用しませんでした。 方法3(採用):データ取得とカーソル確定を分離する(2フェーズ取得) 上記2つの方法では、1回のAPI呼び出しでデータ取得とカーソル確定を同時に済ませようとしています。発想を変え、 データを取得してマージするフェーズと、消費件数に基づいてカーソルを確定するフェーズを分ける ことで、この問題を解決します。サーバーはステートレスのまま、既存APIの仕組みをそのまま活かせます。API呼び出し回数は増えますが、最もシンプルな設計です。 許容するトレードオフ ただし、この方式では2回のAPI呼び出しの間に多少の時間差が生じます。そのわずかな間に対象データが追加された場合、次ページに重複したデータが現れる可能性があります。 私たちはこの問題を、以下の理由から許容可能なトレードオフと判断しました。 影響は「ページを跨ぐ際の重複表示」に限定される 対象がリアルタイムに頻繁に更新されるデータではないため、発生頻度は低い 完全な整合性を保証するには、各ソースのAPI仕様変更が必要になり、コストに見合わない この判断のもと、以降のセクションで方法3の具体的な実装を説明します。 2フェーズ取得パターン 前のセクションで述べた方法3を、具体的にどう実装したかを説明します。データを取得してマージするフェーズと、消費件数に対応するカーソルを確定するフェーズに分けて設計しました。 フェーズ1:取得とマージ ソースA、ソースBからそれぞれ pageSize 件を並行して取得する 日時降順でマージし、合計 pageSize 件を取り出す ソースAとソースBそれぞれで、実際に消費した件数を記録する この処理は、Go の container/heap を使ったストリーミングマージとして実装できます。各ソースの先頭要素をヒープに入れ、日時が最も新しいものを1つずつ取り出しながら pageSize 件を集めます。以下のコードのとおり、各ソースのインデックス( indexA , indexB )がそのまま消費件数を表します。 func Merge(pageSize int32, itemsA, itemsB []*Item) ([]*Item, int32, int32) { indexA, indexB := 0, 0 result := []*Item{} h := &timeHeap{} heap.Init(h) if len(itemsA) > 0 { heap.Push(h, &record{source: SourceA, time: itemsA[0].Timestamp}) } if len(itemsB) > 0 { heap.Push(h, &record{source: SourceB, time: itemsB[0].Timestamp}) } for h.Len() > 0 && len(result) < int(pageSize) { r := heap.Pop(h).(*record) switch r.source { case SourceA: result = append(result, itemsA[indexA]) indexA++ if indexA < len(itemsA) { heap.Push(h, &record{source: SourceA, time: itemsA[indexA].Timestamp}) } case SourceB: result = append(result, itemsB[indexB]) indexB++ if indexB < len(itemsB) { heap.Push(h, &record{source: SourceB, time: itemsB[indexB].Timestamp}) } } } return result, int32(indexA), int32(indexB) } 戻り値の indexA と indexB が、フェーズ2でカーソルを正確に進めるための入力になります。 フェーズ2:カーソルの確定 ソースA、ソースBそれぞれにおいて、フェーズ1と同じ開始位置から消費件数分だけ再取得し、進んだ位置のページネーショントークンを取得する( cursorA , cursorB ) pageToken を cursorA:cursorB (参照:次のセクション)とすることで、次ページの取得時に正しい位置からデータを取得できる なお、一方のソースのデータがもう一方より古い場合など、フェーズ1でデータが返ってきたにもかかわらずマージで1件も採用されないケースがあります。この場合は、そのソースのカーソルを前回の位置のまま保持し、次ページのリクエストで再び同じデータを取得してマージの対象にします。また、フェーズ1でデータが0件だった場合は、そのソースを枯渇と判定し、ターミナルトークン _ を設定します。 (図2)フェーズ1:取得とマージ(消費件数を記録) Source A ──(pageSize件)──┐ ├→ Merge → Top N Source B ──(pageSize件)──┘ │ 消費件数を記録 (A=3件, B=2件) (図3)フェーズ2:カーソルの確定(消費件数分だけ進める) Source A ──(消費3件)──→ cursorA Source B ──(消費2件)──→ cursorB → 複合トークン: "cursorA:cursorB" 複合ページネーショントークン設計 2フェーズ取得パターンにより、各ソースで消費件数分だけ進んだカーソルを取得できるようになりました。次に、これらのカーソルをクライアントにどのように渡すかを設計します。今回の一覧取得APIでは、 pageToken を各ソースのカーソルを結合した複合トークンとして設計します。 "cursorA:cursorB" 片方のソースが完全に尽きた場合は、ターミナルトークン _ で表現します。トークンがターミナルトークン _ だった場合、API呼び出しをスキップできます。これにより、初回リクエストから片方のソースが枯渇した状態まで、以下のようにページネーショントークンで表現することができます。 トークン 意味 "" (空文字) 初回リクエスト "cursorA:cursorB" 両ソースとも継続あり "_:cursorB" ソースAは枯渇、Bのみ継続 "cursorA:_" ソースBは枯渇、Aのみ継続 "_:_" → "" に変換 全データ取得済み(次ページなし) この複合トークンと2フェーズ取得パターンを組み合わせることで、サーバー側に状態を持たずに、マージした一覧のページネーションを実現できます。 まとめ 本記事では、カーソルベースAPIを持つ複数データソースから一覧を構築する際に直面した「マージで実際に消費した件数」と「APIが返すカーソル位置」のズレという課題と、その解決策を紹介しました。 最初は1回のAPI呼び出しで全てを済ませようとしていましたが行き詰まり、「データを取得するフェーズ」と「カーソルを確定するフェーズ」に分離することで解決できました。1つの処理が複数の責務を担って複雑になったとき、フェーズを分けて各ステップの役割を単純化するアプローチは、ページネーションに限らず設計全般で有効な考え方だと感じています。 このような設計上のトレードオフを実際に手を動かしながら考えられたのは、インターン期間中の貴重な経験でした。FEに限らず幅広く関わらせていただいたことに感謝しています。本当にありがとうございました! 次の記事は@mikupoさんです。引き続きお楽しみください。
こんにちは。メルペイ Payment & Customer Platform (PCP) チームでBackend Engineerをしている@imamuです。この記事は「 Merpay&Mercoin Tech Openness Month 2026 」の6日目の記事になります。 はじめに 「事業者請求払い」とは、事業者(加盟店やビジネスパートナー)向けの後払い決済の仕組みです。取引が発生した時点ではプラットフォーム側が代金を立替え、後からまとめて請求・回収します。事業者にとっては都度の支払いが不要になり、取引のたびにキャッシュフローを気にせず利用できるという利点があります。 立替えて後から請求する以上、プラットフォーム側には一つの重要な責務が生まれます。 事業者ごとに「いくらまで立替えてよいか」= 与信上限を安全に管理すること です。これが破綻すると、回収できない債権が積み上がり事業全体のリスクになります。 本記事では、事業者請求払いを実現するために与信ドメインをCredit Serviceとして切り出した際の4つの設計ポイントを紹介します。 背景:従来の請求払いの課題 事業者請求払いは、取引時に代金を立替え、後からまとめて請求・回収する仕組みです。概念的には、次のようなライフサイクルになります。 従来、この請求払いを実現する手段は主に3つありました。 1. 外部のあと払いサービス いくつかのプロダクトは 外部のあと払いサービスを使って与信管理・請求を実現していました が、プロダクト特性に応じたリスク管理やパートナーに応じた柔軟な請求、入金イベントをトリガーにした精算や会計連携のシステム化が難しい側面があり、システム外での運用が避けられませんでした。 2. 外部の請求代行サービス 請求書の発行・送付といった請求業務のみを外部サービスに委ねる方法も取られていました。しかし、与信枠を自社で管理していないため上限を超過しても取引自体は通ってしまうリスクがありました。 3. クレジットカードによる代理決済 一般的な法人カードで利用上限の範囲内で建替え、請求する運用も使われていましたが、取引前に「いまいくらまで使えるか」を自社システムで把握できず、取引が成立した後の決済段階で初めて残高不足エラーが顕在化し、お客さまの体験にも悪影響が出ていました。 目的:なぜ内製の与信管理が必要だったか 前節の3つの手段はいずれも、請求払いそのものは実現できていました。しかし、プロダクトごとに運用や要件が異なる状態では、 事業者ごとの与信をプロダクト横断で一元管理できない という共通の課題が残ります。事業者請求払いは複数のプロダクトでの利用が見込まれるため、プラットフォームとしては次の不変条件を常に満たす必要があります。 プロダクト横断で保有する債権の合計 < 事業者の与信上限 例えば事業者の与信上限が 100 のとき、プロダクトAの利用 60 とプロダクトBの利用 60 が同時に成立して合計 120 になる、といった状態を許してはなりません。プロダクトごとに与信判断が分散していると、この不変条件は容易に破られてしまいます。 そこで私たちは与信管理をプラットフォーム側で提供し、次の3点を満たすことを目的にしました。 プロダクト横断で与信・債権を一元管理すること 不変条件「債権合計 < 与信上限」を常に満たせる 取引前に安全に通すこと いま使える額を判断し、必要に応じて与信枠を確保したうえで取引を開始できる 請求・入金・精算プロセスまでシームレスにつなぐこと 取引の状態変化に追従し、後続の請求・入金・精算へ自然につなげる 設計:4つの設計ポイント 設計は次のようなサービス連携のユースケースを想定して組み立てました。 まずPayment Serviceが各プロダクトから決済リクエストを受け付けます。これはPayment Serviceが決済手段の提供と決済リソースの状態管理を責務としているためです。そして、Credit Serviceに与信枠の利用を依頼し、Credit Serviceが債権管理を担うDebt Serviceに債権を登録します。この債権を集約してInvoice Serviceが請求書を発行します。この連携を以下の4つの観点で設計しました。 1. 与信管理の責務を1サービスに凝集する まず決めたのは「与信ドメインをどのサービスが持つか」です。 すでに存在するPayment Serviceは決済手段の提供と決済リソースの状態管理を責務としています。ここに与信枠の管理や利用可能額の計算といった与信ドメインの知識を持ち込むと、Payment Serviceが本来のスコープを超えて肥大化してしまいます。そこで、与信に関わる以下の責務を全てCredit Serviceに凝集しました。 与信上限の決定 (事業者審査の結果として上限を確定する) 与信枠の作成・更新 (プロダクトごとに任意に切れる枠を管理する) 利用可能額の計算 (いまいくらまで使えるかを算出する) 与信枠の利用 (与信を消費し、債権の登録を依頼する) 審査(枠を決める)と利用(枠を使う)をあえて分離せず一体で持つことにしました。こうすることで、Payment Serviceは「与信枠を利用する」という一つの操作を呼ぶだけでよくなり、利用可能額のチェック・与信の消費・債権登録という一連の処理がサービス内に閉じます。また、横断的な与信上限(目的の不変条件)をアトミックに守るには、与信の利用を1サービスに集約する以外に方法がありません。このように、責務の凝集は明確なドメイン境界と不変条件から導いた設計判断です。 2. 与信と請求を分離する 次に 与信の単位と請求の単位は同じではない という前提を設計に組み込みました。 与信は「どの枠でいくら使えるか」を、事業者が任意に切った枠(CreditLine)の単位で管理したい 請求は「どの宛先にまとめて請求書を出すか」を、請求先(InvoiceAccount)の単位で管理したい この2つを密結合にしてしまうと、「請求の単位を変えたいだけ」で与信側まで作り直すことになります。両者は本来別々の関心事なので、疎結合に保つ必要があります。 実現方法は次のとおりです。Credit Serviceは与信を消費して債権を登録するとき、その債権に 「どの請求先に属するか」「どの与信枠に属するか」という集計のための情報 を持たせてDebt Serviceに渡します。Debt Serviceは、債権を 複数の軸で集計・一覧できる仕組み を備えており、以下のようにそれぞれのサービスが債権の情報を参照することができます。 Credit Serviceは「与信枠の軸」で未返済の債権から利用額を計算する Invoice Serviceは「請求先の軸」で指定期間の債権から請求書を発行する これにより与信は事業者全体で管理しつつ、請求は支店ごとに行うようなユースケースに対応できます。ポイントは 債権の集計軸をDebt Serviceが中心的な概念として扱う ことにあります。Credit Serviceは与信枠の軸だけを、Invoice Serviceは請求先の軸だけを関心に持ち、軸ごとの集計はDebt Serviceに委ねます。結果として、 与信の粒度(CreditLine)と請求の粒度(InvoiceAccount)を独立して設計 [1] できます 。 3. 事業者ごとに複数の与信枠を持てる 与信枠は事業者単位で1つではなく、 1事業者が複数の与信枠(CreditLine)を持てる 構造にしました。店舗ごと・部門ごとといった、事業者で運用したい任意の単位で枠を切れます。これによってプロダクト毎に柔軟に利用制限を行うことができます。 ただし、複数の枠を切っても事業者全体の与信上限(CreditLimit)は超えられません。そこで最終的な利用可能額は、与信枠の利用可能額と事業者全体の利用可能額の小さい方で決まります。 最終的な利用可能額 = min(CreditLine の利用可能額, 事業者 CreditLimit の利用可能額) ここで、似て非なる2つの「上限」を区別しておきます。 CreditLimit CreditLine 意味 絶対に超えて立て替えない上限 クライアントが切れる 実用枠 決め方 与信審査の結果 クライアントが任意設定 クライアントの変更 不可 更新可能 「審査で決まる硬い上限」と「運用で柔軟に切れる枠」を分けることで、安全性と柔軟性を両立しています。 4. 与信の利用はライフサイクルに沿って状態を持つ 与信枠の利用は、一度の操作で完結するものではありません。実際の取引は、与信を押さえてから確定するまでに時間差があり、途中で取り消されることもあります。そこで与信の利用を、複数のフェーズを持つ 与信トランザクション(CreditTransaction) として管理します。 取引開始時点で与信枠を押さえる 確保(Authorize) 、取引確定時点で債権として確定する 確定(Capture) 、確定前に押さえた与信を解放する 取消(Cancel) という3つのフェーズを扱います。これにより、取引の途中で状態が変化しても、与信の消費と解放を一貫したモデルで管理できます。 確保のタイミングを選択可能に(ReservationPolicy) 与信をいつ・どう押さえたいかはプロダクトによって異なります。そこで確保の仕方を ReservationPolicy として選べるようにし、同じ Credit Service を要件に合わせて使い分けられるようにしました。 確保の仕方 動作 仮確保 (PROVISIONAL) 依頼時に仮の債権で与信を押さえ、確定で正式な債権にする/取消で解放する 確定時に確保 (CHECK_THEN_CAPTURE) 依頼時は利用可能額の確認だけ行い、確定時にまとめて確保する 即時確保 (IMMEDIATE) 依頼した時点で確保と確定を同時に行う 確定後の取消にも対応する(Reversal) 運用上は、与信を確保して取引が確定した 後 に取消が発生することがあります。確定後は与信を解放するだけでは済まず、すでに登録された債権をどう扱うかが問題になるため、確定後の取消を Reversal としてサポートしました。 Reversal では、取消したい金額をその時点の債権残額に応じて未返済分と返済済み分に切り分けます。未返済分は債権そのものを取り消し、返済済み分は債権を取り消せないため、返金すべき金額としてレスポンスで返して呼び出し元(Payment Service)が返金できるようにします。これにより、確保→確定→取消・返金という取引のライフサイクル全体に対して一貫した扱いができます。 全体像:プラットフォーム全体で一気通貫に ここまで見てきたCredit Serviceは、単独で成り立つものではありません。プラットフォームには、Payment(決済)・Debt(債権)・Invoice(請求)・Bank(入金)・Balance(残高)・Settlement(精算)といった、それぞれ明確な責務を持つサービスがすでに存在していました。そこにCredit Serviceが新たに加わることで、各サービスが自分の責務に集中したまま連携し、 決済・与信管理・請求・精算がプラットフォーム全体で一気通貫に実現できる ようになりました。 この構成によって、新しいプロダクトがシームレスに事業者請求払いに対応することができます。あるプロダクトで事業者請求払いを利用したくなったら、必要なのは 与信枠(CreditLine)と請求先(InvoiceAccount)を新たに作るだけ です。プラットフォームの各サービスに手を入れることなく、そのプロダクトの取引が、与信照会から決済・請求・返済までのライフサイクルに自然に乗ります。 終わりに 与信管理マイクロサービスができたことによってPayment Platformとして事業者請求払いを実現できるようになりました。 Payment Platformは今後も進化していきますが、「各サービスが明確な責務を持ち、疎結合に連携する」という設計方針そのものは変わりません。この方針のもとに安全かつ拡張可能な決済基盤を今後も実現していきたいと考えています。 次の記事は nanacomさんとmattsuuさんです。引き続きお楽しみください。 [1] この「債権を任意の軸で集計する仕組み」自体も独立した設計テーマであり、別記事で詳しく紹介される予定です。
はじめに こんにちは。メルペイのAccountingチームでBackend Engineerをしている @hokao です。 この記事は、 Merpay & Mercoin Tech Openness Month 2026 の 5 日目の記事です。 会計データに誤りがあった場合、元のデータを残したまま打ち消すための記録を別途追加するのが会計上の一般的な手法です。本稿では、これをシステムとしてどう扱ったかを設計と実装の観点から紹介します。 背景 Accountingチームでは、会計データを扱うシステムを開発しています。メルカリグループ全体で発生するお金の移動を伴う取引を記録・集計するシステムで、会計イベントの保存と経理向けのレポーティングを責務としています。 会計データは、取引の事実を証明する証拠としての役割を持ちます。そのため、一度記録したデータを後から改変・削除することは原則として許されません。誤りがあったとしても元データを残したまま、打ち消すための記録を別途追加することで修正します。 しかし、私たちのシステムにはこの打ち消すための機能が存在していませんでした。誤って登録された会計データが見つかるたびに、その件数・金額などの情報を手作業で特定し、経理に連携して対応してもらう必要がありました。この運用には、対応コストの大きさや作業ミスのリスクといった構造的な課題がありました。 以降では、会計ドメインの前提を整理した上で、この課題を解決するために導入した打ち消し機能の設計と実装を順に説明します。 会計ドメインの前提 会計では、すべての取引を借方と貸方の 2 つに分けて記録する複式簿記という方式が使われています。借方と貸方それぞれに勘定科目・日付・金額を記載したものが「仕訳」で、これが会計データの最小単位になります。 仕訳に誤りがあった場合、元の仕訳の借方と貸方を入れ替えた「逆仕訳」を計上して打ち消します。 簡単な例として、ある仕入取引を次のように記録していたとします。 借方: 仕入 100 円 貸方: 現金 100 円 この記録が誤りだった場合、逆仕訳は次のようになります。 借方: 現金 100 円 貸方: 仕入 100 円 元の仕訳と逆仕訳を合算すると、勘定科目ごとに借方と貸方が打ち消し合い、金額がゼロになります。元データは残したまま、後から追加した記録によって取引を実質的に打ち消す形になります。 ここで説明したのは、逆仕訳がなぜ打ち消しとして成立するのかという会計上の考え方です。実際の会計レポートでは、必ずしも借方と貸方を足し合わせて相殺しているわけではなく、レポートによっては逆仕訳の金額を符号反転させて打ち消しを表現しています。詳しくは後述します。 逆仕訳の設計と実装 スキーマでの逆仕訳の表現 私たちの会計システムでは、上流のマイクロサービスから受け取った取引はまず会計イベントとして記録され、そこから仕訳が作成される構成になっています。それぞれ AccountingEvents と JournalEntries というテーブルで管理されています。 会計イベント ( AccountingEvents ) には、上流のマイクロサービスから会計の入力として受け取った取引が記録され、会計処理の種類や取引の中身を保持します。これに対して仕訳 ( JournalEntries ) は、会計イベントに仕訳ルールを適用して必要な属性が確定した、正式な会計記録です。1 つの会計イベントから、仕訳ルールの適用によって複数の仕訳が作成されることもあります。 逆仕訳の会計イベントには、必ず打ち消し対象となる元の会計イベントが存在します。そのため、 AccountingEvents に元の会計イベントへの参照 ( OriginalTransactionId ) を持たせて関係性を表現します。この参照を持つイベントから作成される仕訳はすべて逆仕訳になります。 また、登録時にはこの参照を辿って、次のようなバリデーションを行います。 元の会計イベントが存在するか 元の会計イベントと整合する勘定科目か 元の会計イベントが既に他の逆仕訳によって打ち消されていないか 会計レポートには JournalEntries から集計するものと、分析用に AccountingEvents から集計するものがあります。どちらの場合でも JOIN なしで逆仕訳の判定ができるよう、 JournalEntries には、その仕訳が逆仕訳かどうかを表すフラグ ( IsReversal ) を持たせています。このフラグは本質的には AccountingEvents の OriginalTransactionId から決まる情報ですが、 JournalEntries でも独立に判定できるよう別途持たせています。 同じ会計イベントに対する逆仕訳の会計イベントが複数存在してはいけないため、上述のアプリケーション側のバリデーションに加えて、DB 側にも一意性制約を設けています。具体的には、 OriginalTransactionId カラムに NULL_FILTERED オプションを指定した UNIQUE INDEX を張っています。これにより、 OriginalTransactionId が NULL のイベント (通常の会計イベント) は重複扱いされず、NULL でない値だけに一意性制約がかかります。 これらをまとめると、スキーマの該当箇所は次のようになります。 CREATE TABLE AccountingEvents ( EventId STRING(100) NOT NULL, AccountingCode STRING(MAX) NOT NULL, OriginalTransactionId STRING(100), -- ... ) PRIMARY KEY (EventId); CREATE NULL_FILTERED INDEX AccountingEventsByOriginalTransactionId ON AccountingEvents(OriginalTransactionId); CREATE TABLE JournalEntries ( Id STRING(100) NOT NULL, EventId STRING(100) NOT NULL, IsReversal BOOL NOT NULL, -- ... ) PRIMARY KEY (Id); 仕訳作成での借方/貸方の入れ替え 逆仕訳の仕訳作成は、元の取引と同じ仕訳ルールを再利用しつつ、ルールから取り出した借方と貸方の属性をコード側で入れ替えて行います。 会計イベントには取引の方向を表す ItemKey という識別子が含まれ、 X.to.Y の形式を取ります。仕訳ルールもこの ItemKey をキーに登録されています。逆仕訳イベントでは ItemKey が反転して Y.to.X の形で届くため、ルールを検索する際は ItemKey を一度元の向きに戻します。 ルールを取得した後、逆仕訳イベントの場合に限り、ルールから取り出した借方と貸方の各属性をコード側で入れ替えます。簡略化した擬似コードで示すと次のようになります。 originalRule := getOriginalEventRule(ev) debitAccountingTitleCode := originalRule.DebitAccountingTitleCode creditAccountingTitleCode := originalRule.CreditAccountingTitleCode debitXxx := originalRule.DebitXxx creditXxx := originalRule.CreditXxx // ... if isReversal { debitAccountingTitleCode, creditAccountingTitleCode = creditAccountingTitleCode, debitAccountingTitleCode debitXxx, creditXxx = creditXxx, debitXxx // ... } debit := JournalEntry{ AccountingTitleCode: debitAccountingTitleCode, Xxx: debitXxx, // ... IsReversal: isReversal, } credit := JournalEntry{ AccountingTitleCode: creditAccountingTitleCode, Xxx: creditXxx, // ... IsReversal: isReversal, } 仕訳ルールが借方と貸方の対称な構造を持っているため、ItemKey の反転とそれに続く借方と貸方の入れ替えという 2 つの操作だけで逆仕訳を作成でき、実装はシンプルな修正で済みました。 会計レポートでの逆仕訳の打ち消し 会計レポートに打ち消しを反映するには、集計クエリで逆仕訳または逆仕訳の会計イベントを判定し、正しく金額を計算する必要があります。 JournalEntries から集計するクエリでは IsReversal フラグで判定し、分析用に AccountingEvents から集計するクエリでは OriginalTransactionId IS NOT NULL で判定します。 会計ドメインの前提で述べたとおり、本来の逆仕訳は、勘定科目ごとに借方と貸方を足し合わせて相殺することで打ち消しを実現します。ただし、会計レポートには借方または貸方のどちらか一方だけを集計するものもあり、そうしたレポートでは足し合わせによる相殺は成立しません。そのため、集計クエリで逆仕訳または逆仕訳の会計イベントの金額を符号反転して合算するアプローチをとっています。なお、逆仕訳の金額自体は元の仕訳と同じ正の値で保存しています。 会計レポートは、対象とするテーブルや集計の意味合いがそれぞれ異なるため、共通化が難しく、もともと個別に実装されています。逆仕訳の打ち消しを反映するためには、その個別実装それぞれに修正を入れる必要がありました。例えば WHERE 句の絞り込み条件を、逆仕訳の会計イベントも拾えるように拡張するなどです。すべてのレポートに対してこのような修正を加えていったため、実装と検証には時間がかかりました。 逆仕訳バッチによる運用の自動化 加えて、誤って登録された会計データを訂正するために、逆仕訳を作成するためのバッチを新たに実装しました。このバッチは、対象となる取引の ID リストを受け取り、それぞれについて、上流のマイクロサービスの API を介して打ち消し用の取引を作成します。それが会計イベントとして本システムに登録され、逆仕訳が自動的に作成されます。 個別の取引で失敗が発生した場合はログに記録しつつ、残りの処理は継続します。また、冪等性は上流のマイクロサービス側で保証されているため、同じ ID リストで再実行することも可能です。 これにより、誤ったデータの打ち消しをシステム上で自動的に逆仕訳として反映できるようになり、これまで手作業で行っていた特定・連携の負荷と作業ミスのリスクが大きく軽減されました。 おわりに 本稿では、会計データの訂正を支える逆仕訳機能の設計と実装を紹介しました。 会計のように不変性の制約が強いドメインで開発していると、ドメインの原則が設計判断をそのまま導いてくれる場面が多く、その面白さを今回の機能開発でも改めて感じました。 今回の機能開発は、Accountingチームが抱える運用負荷削減という大きな取り組みの一環でもあります。会計システムは、会社が財務状況を正しく把握するための基盤であり、事業の成長に合わせてスケールできる状態に保ち続ける必要があります。これからも、持続的な開発ができるよう取り組んでいきたいと考えています。 次の記事は imamu さんです。引き続きお楽しみください。
こんにちは、Merpay の Payment Core チームと Payment Solution チームで Engineering Manager (EM) をやっている komatsu です。普段は決済基盤や決済体験の開発をするチームを見ていたり、最近は PCP Foundation というチームを発足して Individual Contributor (IC) として基盤の整備や AI 周りのツールの導入も行っています。 この記事は Merpay & Mercoin Tech Openness Month 2026 の 4 日目の記事です。 この記事では、私たちの Payment Platform が「決済」と「会計」のあいだに置いている共通言語 MoneyFlow と、それを支えるために開発したツール群 mfgen (MoneyFlow Generator; /ˌemefˈdʒen/) について紹介します。一見すると別物に見える「システムとしての決済基盤」と「上場企業として厳密さが求められる会計」ですが、実際には深く結びついています。私たちは、その複雑な関係を整理し、経理・PdM・エンジニアといった異なる立場の人々が共通の理解を持てるよう、MoneyFlow という共通言語を整備してきました。この一年ほどで MoneyFlow は徐々に組織へ浸透し、現在では同じフォーマットで記述し、同じフォーマットでレビューする開発プロセスが少しずつ定着しつつあります。本記事では、その取り組みの背景と、それを支える mfgen の仕組みについて深掘りしていきます。 決済プラットフォームと会計のつながり メルカリグループの Payment Platform は、メルカリのマーケットプレイス (Consumer to Consumer; CtoC) だけでなく、メルカリShops やメルペイ、メルコイン、メルカリモバイル、メルカリAds、グローバルアプリなど、「お金が動くすべてのプロダクト」の土台となり、決済にまつわる汎用的な機能を提供しています。決済そのものについての記事は多く上がっているので、興味があれば メルカリのグローバル展開を支えるPayment Platformの進化 や Payment に関するその他の記事 もあわせてご一読ください。 また、Payment Platform チームが掲げる理念の一つに「会計も含めた決済ソリューション」というものがあります。決済の一回一回は、お客さまや Payment Platform の利用者 (つまりプロダクトのマイクロサービス) から見れば「支払いが完了した」という体験です。しかし会社や経理から見れば、それは会計上の仕訳として正しく記録しなければならない事象でもあります。そこで Payment Platform は、外向きの決済 API を提供するだけでなく、その裏側で「お金がどう動き、どの勘定にどう記録されるか」までを引き受け、記帳や会計レポートの発行を通した社内向けの統合的な決済ソリューションとして存在しています。これによってプロダクトはビジネスロジックの開発に集中し、会計にまつわる大半の連携を Payment Platform に委ねることができます [^1]。 たとえば、メルカリのシンプルな購入ひとつを取っても、段階ごとにお金が動き、それぞれに対応する会計上の記録が発生します。お客さまが支払う場面では、残高を使う場合は購入者の資金移動口座からエスクロー決済の預かり金へと振り替え、メルペイのクレジットを使う場合はあと払い債権を計上し、他社クレジットカードを使う場合は PSP (Payment Service Provider) に対する未収入金として処理します。そして取引が完了して売上が立つ段階では、預かり金を取り崩し、出品者の資金移動口座と手数料収入へと振り分けます。 これはまだ単純な例ですが、実際には、コンビニ決済のような非同期な決済手段、キャンセルや返金、資金移動口座の開設有無、クーポン等による値引き、為替が絡む決済——こうした条件の組み合わせやタイミングの違いが絡み合い、仕訳はもっと複雑になります。特に、ひとつの API 呼び出しが複数の勘定に影響することもあれば、逆に、会計上はひとつの動きが複数の API にまたがることもある、という点も複雑性を増す要因となっています。この「ズレ」があるために、エンジニアと経理が直接話しても認識が噛み合わず、議論が長引くということが頻繁に起きていました。エンジニアが「決済 API のフロー」として見ているものを、経理は「仕訳」として見ており、同じ事象を、まったく異なる言語で眺めているからです。 MoneyFlow という共通言語 このすれ違いの根本にあるのは、Payment Platform のエンジニアと経理のあいだに横たわるドメイン的な距離です。両者は使う言葉からして異なり、エンジニアが API やエンドポイントで語るのに対し、経理は勘定科目や仕訳で語ります。前提とする知識も、システムの内部構造と会計基準というように噛み合いません。そのうえ、API と勘定科目が必ずしも 1:1 で対応しないため、片方の言葉をそのまま翻訳しても意味が通じないことがままあります。 この距離を埋めるために、Payment Platform で直近約一年にわたって運用しているのが MoneyFlow です。これはシステム上のお金の動きを表現するためのフロー図であり、同時に、エンジニア・経理・Product Manager (PdM) の理解を揃えるための共通言語でもあります。MoneyFlow は主に三つの要素で構成します。一つ目の Actor は取引の主体(誰が価値を出し、誰が受け取るのか)で、User (購入者や出品者)や Partner (加盟店さま等取引の相手方。社外事業者に限らず、会社として Mercari / Merpay / Mercoin もシステム上は Partner) が該当します。二つ目の Account は各 Actor が持つ勘定 (ledger) で、 USER_FUNDS や PARTNER_SALES 、 USER_DEBT 、 PARTNER_CLEARING_SALES などがあります。三つ目の Flow はおおむね 1 回の API 呼び出しに相当するお金の移動を表し、Source (from) と Target (to) を持つことで、その決済でお金がどこからどこへ動くのかを示します。さらに各 Flow は、勘定科目に対応する accounting code を持ちます。 こうした図として表現することで、エンジニアは価値交換を行う API を記述でき、経理は Flow を中心とした会計観点でのお金の動きを把握できます。図の Actors section は Actor の一覧とそれぞれが持つ Account (ledger) を表現し、MoneyFlow section は商流ごと・API ごとのお金の動きを表現します。 この一年ほどをかけて、MoneyFlow は徐々に組織に浸透してきました。現在では、経理・PdM・エンジニアが同じフォーマットで書き、同じフォーマットでレビューする、といった開発の工程が少しずつスタンダードになりつつあります。著者自身もプロジェクトや開発の最初のフェーズでこれを行うことで、ステークホルダーと認識を合わせやすくなったと実感しており、Design Doc に並ぶ重要なドキュメントだと感じています。さらに、エンジニアリングと会計の両面を表現する概念を持ったことで、エンジニアは会計のドメインを、経理はエンジニアのドメインを理解しやすくなりました。エンジニアが勘定科目について経理に相談したり、経理が API 名を使って商流を表現したりと、お互いの歩み寄りがかなり加速したと感じています。 mfgen CLI — DSL による MoneyFlow の標準化 組織への浸透が進む一方で、MoneyFlow は最初からフォーマットが決まっていたわけではありません。初期は Draw.io (Diagrams.net) で手描きしていました。これはこれで描けるのですが、いくつかの課題がありました。まず、書き方が人によって異なり、解像度や粒度が書き手に依存するため、図にばらつきが出ます。次に、そもそもどう書けばよいか分からない人がほとんどで、書ける人が限られていました。書ける人がいないプロジェクトでは、会計観点の考慮漏れを見落とすこともありました。さらに、Draw.io は XML で表現できるものの human-friendly な宣言的記法が存在せず、同じフォーマットで安定して描画することや AI agent との親和性に欠けていました。 そこでまず取り組んだのが、MoneyFlow を YAML の DSL として定義することでした。そして、その YAML から Draw.io (および PNG 画像) を生成する CLI ツール mfgen を開発しました。YAML は次のようなイメージです。 name: Example Payment Flow actors: - id: user name: User type: USER - id: partner name: Partner type: PARTNER accounts: - id: user_funds owner: user account_type: USER_FUNDS currency: JPY - id: partner_sales owner: partner account_type: PARTNER_SALES currency: JPY flows: - id: MF1 name: Payment api: Payment.CreateCharge currency: JPY accounting_code: xyz from: - id: user_funds amount: 1000 to: - id: partner_sales amount: 1000 描画のクライアントとしては依然として Draw.io を使っているため、CLI の実装の中身はかなり地味なものです。YAML をパースし、描画に必要な座標を計算し、Draw.io の XML を組み立てる——レイアウトのための座標計算や XML 生成を、ひとつずつ実装した形になります。この段階では、validation を強めに設けて一貫性を高めることと、AI agent による生成を簡単にすることを目的としました。具体的には、次のチェックを行います。 必須フィールドを検証する ID の重複を検出する 参照整合性を確認する (actors の owner、flow の from / to などが実在するか) セマンティクスを検証する ( from の合計と to の合計が一致するか、 PARTNER_DEBT / USER_DEBT に debt_type があるか、など) さらに、GitHub Actions で YAML を自動変換して Draw.io 形式および画像でアップロードする CI を組みました。これによってレビューや共有、過去の MoneyFlow の管理が簡単になり、MoneyFlow が蓄積されることで次の MoneyFlow 作成時の AI による精度も向上していきました。 mfgen Web — リアルタイム共同編集の実現 DSL 化によって標準化は大きく進みましたが、mfgen CLI にも残された課題がありました。変換が YAML → Draw.io の片方向であるため、図を見ながら直接編集して保存する、という使い方ができません。ミーティング中に Draw.io を直接編集したりコメントを付けたりすると、それを YAML に取り込むコストが発生します。そして、Draw.io の表現力そのものが上限になる場面もありました。 そこで開発したのが mfgen Web です。これは社内にホストしている Web ツールで、「Draw.io のような同時編集」と「YAML による宣言的な定義」の両方を実現します。具体的には、三つの編集方法を備えています。Canvas 上で直接描く方法では、Draw.io のようにノードを配置してつなぎます。MoneyFlow に特化した編集機能では、Actor / Account / Flow といったドメイン概念をそのまま編集できます。そして YAML を直接記述する方法では、エディタで宣言的に書けます。 これらはすべて双方向に同期します。YAML を更新すれば図に即座に反映され、図をドラッグすれば YAML にも反映されます。さらに、複数人が同じ MoneyFlow をリアルタイムで共同編集でき、メンバーが作成した MoneyFlow はカタログとして蓄積されていくため、チームのドキュメンテーションとしても活用できる形になりました。 この手の内製ツールは Vibe Coding でおおよそ作れてしまう時代ですが、技術的なポイントを簡単に紹介します。 サーバは役割の異なる 2 つに分かれています。 api サーバ (Hono) が SPA の配信と REST API を担い、 collab サーバ (Hocuspocus) が WebSocket での同時編集を担います。設計上のポイントは次のとおりです。 Single Source of Truth として Y.Doc (Conflict-free Replicated Data Type; CRDT) を採用する。サーバが権威ある Y.Doc を保持して各クライアントと同期し、YAML はサーバ側で Y.Doc から投影して生成する派生物とすることで、クライアントが直接書き込むことはありません。これにより、YAML を入力とする CLI 版 mfgen とのデータ互換性を保っています。 「追記ログ + スナップショット」方式で永続化する。編集は append-only な update ログに追記し、2-10 秒程度の debounce で Y.Doc 全体のスナップショットと生成した YAML を Postgres に書き込み、古い update を圧縮 (compaction) します。 Canvas 上の座標を YAML から除外する。位置情報を YAML から外すことで、既存 CLI と等価なデータに保っています。 Origin タグ (5 種) で編集の出どころを区別する。「UI 操作」「YAML 編集」「ドラッグ操作」「他クライアントからの更新」「初期同期」をタグで見分け、双方向同期で起こりがちな無限ループを防ぎつつ、Undo の対象も制御しています。 Redis (Memorystore) の pub/sub で collab サーバ間のメッセージを fan-out する ( @hocuspocus/extension-redis )。複数レプリカ運用に備えた仕組みで、単一レプリカであれば Redis なしでも動作します。 技術スタックは TypeScript / Bun に統一しています。フロントエンドは React + Vite + @xyflow/react + CodeMirror + Yjs、サーバは Hono + Hocuspocus + Drizzle ORM、データストアは Postgres と Memorystore Redis という構成です。これらを専用の Google Cloud プロジェクト上で、api / collab それぞれ独立したサービスとして運用しています。 mfgen Web はまだ開発したばかりで活用事例はありませんが、Web 版になったことで、MoneyFlow はより実践的なツールになると感じています。今後さらに開発を続け、エディタ内に Claude Code SDK などで AI を搭載したり、経理によるレビュー後にシステムへ登録するところまで E2E で自動化できるようになれば、会計がプロダクト開発のブロッカーにならず、基盤として素早いサポートができるようになると信じています。 まとめ この記事では、Payment Platform が「会計も含めた決済ソリューション」であること、そしてエンジニアと経理のあいだを繋ぐ共通言語 MoneyFlow と、それを実践的に運用するための mfgen について紹介しました。MoneyFlow を共通の出発点に置いたことで、これまで噛み合わなかったエンジニアと経理の議論は、同じ図を指しながら進められるようになりました。考慮漏れが減り、お互いがお互いのドメインに歩み寄る——そうした定性的な変化が、この直近多く見られるようになったと感じています。 著者自身、会計はまだ勉強中の身ですが、システムと結びつけて考えるとかなり理解しやすくなる、というのが正直な実感です。そして、こうした内製ツールは AI の力を借りて本当に手軽に作れる時代になりました。私が所属する PCP Foundation チームでは、今後もこうした組織横断のツール開発とメンテナンスを通じて、チームの垣根を越えた生産性の向上につなげていきたいと思っています。 [^1]: 決済 API を利用するタイミングと会計連携のタイミングが異なる場合はプロダクトが直接行っているが、この深掘りと進化はまた別の機会に。 次の記事は hokao さんの「会計システムにおける訂正機能の設計と実装」です。引き続きお楽しみください。
こんにちは、いつも心に冪等性 sinmetalです。「 Merpay & Mercoin Tech Openness Month 2026 」の3日目の記事です。 本記事では、MySQLで動いていた「お客さまが所持するクーポン一覧取得」クエリをSpannerへ移行した際に直面したパフォーマンス問題と、その解決までの過程を紹介します。 DBごとのアーキテクチャの差 MySQLとSpannerはどちらもSQLを利用できますが、アーキテクチャが大きく異なるので、テーブルやクエリの設計は異なります。移行する時は差異があるSQLを修正するだけでなく、各DBのアーキテクチャまで意識して、実装を見直す必要があります。 MySQLのアーキテクチャ MySQLはPrimary InstanceがWriteを担当するのでWriteをスケールさせたいなら、Primary Instanceのマシンを強化します。 ReadはRead Replicaを増やすことでもスケールさせることができます。 Read Replicaは独立しているので、OLAPのような特定のクエリを特定のInstanceで実行することもできます。 Spannerのアーキテクチャ Spannerはデータを分割してSplitに保存します。Splitはデータサイズや負荷で自動的に増減し、データの分散範囲も調整されます。Writeに対してもスケールできますが、Read Replicaが無いので特定のクエリを特定のInstanceで処理するようなことはできません。Read Replicaだけを増やすということもできないので、Read性能だけを増やすこともできません。WriteもReadもどちらもAuto Scaleして分散して処理するのが強みです。 メルカリのクーポン機能の移行 クーポン機能の中に自分が所持しているクーポンの一覧を取得するAPIがあります。その中で実行されていたクエリのチューニングを行いました。 まずはMySQLの実装をそのまま移行したので、以下のようなクエリになっていました。 MySQLなら、それほど問題になるようなクエリではないかもしれませんが、Spannerの実行計画を見るとResidual Condition(Residual Conditionはインメモリで処理する必要がある状態です。後述※1)が必要、Sortが必要など非常に厳しい状態です。 SELECT co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt FROM CouponOwners co JOIN Coupons c ON co.CouponID = c.CouponID WHERE co.UserID = @userID AND c.IsActive = @isActive AND co.Expire > @now AND c.StartDate <= @now AND co.Used = @isUsed AND c.Type IN UNNEST(@couponType) AND c.MarketPlace = @marketPlace ORDER BY co.CreatedAt DESC +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ID | Query_Execution_Plan | +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, preserve_subquery_order: true, split_ranges_aligned: false) | | 1 | +- Serialize Result (execution_method: Row) | | 2 | +- Sort (execution_method: Row) | | *3 | +- Distributed Cross Apply (execution_method: Row) | | 4 | +- [Input] Create Batch (execution_method: Batch) | | 5 | | +- RowToDataBlock | | 6 | | +- Local Distributed Union (execution_method: Row) | | *7 | | +- Filter Scan (execution_method: Row, seekable_key_size: 3) | | *8 | | +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row) | | 34 | +- [Map] Cross Apply (execution_method: Row) | | 35 | +- [Input] KeyRangeAccumulator (execution_method: Row) | | 36 | | +- DataBlockToRow | | 37 | | +- Batch Scan (Batch: $v2, execution_method: Batch, scan_method: Batch) | | 46 | +- [Map] Local Distributed Union (execution_method: Row) | | *47 | +- Filter Scan (execution_method: Row, seekable_key_size: 0) | | *48 | +- Table Scan (Table: Coupons, execution_method: Row, scan_method: Row) | +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ Predicates(identified by ID): 0: Split Range: (($UserID = @userid) AND ($Used = @isused) AND ($Expire > @now)) 3: Split Range: ($CouponID_1 = $CouponID) 7: Residual Condition: ($UserID = @userid) 8: Seek Condition: (IS_NOT_DISTINCT_FROM($UserID, @userid) AND ($Used = @isused)) AND ($Expire > @now) 47: Residual Condition: (($IsActive = @isactive) AND ($MarketPlace = @marketplace) AND ($StartDate <= @now) AND ($Type IN @coupontype(array))) 48: Seek Condition: ($CouponID_1 = $batched_CouponID') DB Schema CREATE TABLE Coupons ( CouponID STRING(36) NOT NULL, Type STRING(36) NOT NULL, IsActive BOOL NOT NULL, StartDate TIMESTAMP NOT NULL, MarketPlace STRING(255) NOT NULL, ) PRIMARY KEY(CouponID); CREATE INDEX CouponsByIsActiveStartDateTypeMarketPlaceID ON Coupons(IsActive, StartDate, Type, MarketPlace); CREATE TABLE CouponOwners ( CouponOwnerID STRING(36) NOT NULL, Used STRING(10) NOT NULL, UserID INT64, CouponID STRING(36) NOT NULL, Expire TIMESTAMP NOT NULL, CreatedAt TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), UpdatedAt TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), ) PRIMARY KEY(CouponOwnerID); CREATE INDEX CouponOwnersByUserIDUsedExpireCreatedAtDESC ON CouponOwners(UserID, Used, Expire, CreatedAt DESC) STORING (CouponID); 実行計画には現れない問題もありました。 HotSpotです。 Coupons Tableはクーポンの情報が書かれているマスタテーブルなのですが、件数はそれほど多くありません。 SpannerはRead Replicaが無いため、小さなTableや特定のRowへの高頻度の読み取りがMySQLと比べると不得意です。対象のRowが存在するSplitに負荷が集中してしまうからです。 特に負荷が集中するものとしてメルカリのすべてのお客さまに配信する 超メルカリ市土日限定クーポン がありました。 自分が所持しているクーポンの一覧を取得するとCoupons Tableの該当のRowがJOINのために参照されます。 すべてのお客さまが持っていて、しかも、超メルカリ市という大きなキャンペーンの中で、特定の土日にだけ使えて、クーポン付与のタイミングでプッシュ通知も行われることもあり、高い確率でHotRowになります。 Note: HotRowとは? アクセスが集中していてそれ以上分割不能なRowのことを指します。 HotRowが発生しているかはHot Spot Statisticsを見ると分かります。 開発環境でチェックする場合は、負荷テストを行い、Hot Split Statisticsをチェックします。 https://docs.cloud.google.com/spanner/docs/introspection/hot-split-statistics?hl=en#hot_row パフォーマンスチューニング これらの問題を解決するためにアーキテクチャを見直しました。 やったことは大きく分けて3つです。 Coupons TableのJOINをやめて、アプリ側に処理を寄せた ORDER BYを削除した NULLを許可しているColumnのFilter条件を調整してResidual Conditionが発生しないようにした Coupons TableのJOINをやめて、Coupons Tableはアプリケーション側で参照するようにし、更に一定期間メモリ上にキャッシュするようにしました。 ValkeyやRedisに保存することも検討しましたが、アクティブな状態のクーポンの数は数十程度であること、変更はほぼないことで、メモリ上に持ってしまって良いだろうと判断しました。 今後、他にもキャッシュしたいものが増えれば、専用のインフラを用意して、実装し直すかもしれません。 キャッシュを入れたことで、HotRowが発生しづらくなり、クエリもCouponOwners Table単体で処理できるようになり、JOINも不要になりました。 次に ORDER BY co.CreatedAt DESC を無くしました。 Filter条件として Expire > @now があるので、CreatedAtのSortがあると単一のIndexをSeek Conditionで読むだけという処理にはできません。 ExpireをResidual ConditionでFilter Scanするか、CreatedAtをSortするかを選択することになります。 1人のお客さまが持っているクーポンの数は多くても10程度なので、Sortしてしまっても良いかなとは思いましたが、呼び出し回数が非常に多いAPIなので、アプリケーション側に処理を寄せています。代わりにアプリケーション側の負担が増えているので、どちらがよいかは悩ましいところです。今後の状況によってはSortをSpanner側に戻すこともあるかもしれません。 もう一工夫している点として、UserIDのFilter条件 (co.UserID IS NULL AND @userID IS NULL) を追加しています。 これはCouponOwners.UserIDはNOT NULL制約がないため、@userIDにNULLが入る可能性をSpannerが考慮して、Filter ScanとしてResidual Condition: ($UserID = @userid)を追加してしまうのを抑制するためです。(元の実行計画の*7)(後述※2) 結果としてクエリの実行計画は非常にシンプルなものにできました。 SELECT co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt FROM CouponOwners co WHERE (co.UserID = @userID) OR (co.UserID IS NULL AND @userID IS NULL) AND co.Expire > @now AND co.Used = @isUsed +----+-----------------------------------------------------------------------------------------------------------------------------------------+ | ID | Query_Execution_Plan | +----+-----------------------------------------------------------------------------------------------------------------------------------------+ | *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, split_ranges_aligned: false) | | 1 | +- Local Distributed Union (execution_method: Row) | | 2 | +- Serialize Result (execution_method: Row) | | 3 | +- Filter Scan (execution_method: Row, seekable_key_size: 3) | | *4 | +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row) | +----+-----------------------------------------------------------------------------------------------------------------------------------------+ Predicates(identified by ID): 0: Split Range: (($UserID = @userid) OR (ISNULL($UserID) AND ($Used = @isused) AND ($Expire > @now) AND ISNULL(@userid))) 4: Seek Condition: (($UserID = @userid) OR (ISNULL($UserID) AND ($Used = @isused) AND ($Expire > @now) AND ISNULL(@userid))) まとめ 元のクエリと比べると非常にシンプルになり、高速で負荷の小さなものになりました。SQLが利用できるDBは増えていますが、インターフェースとしてSQLが使えても中のアーキテクチャがそれぞれ異なります。 Spannerは予算さえあれば気軽にNodeを増やせるため、LatencyやCPU使用率に不満があれば、Nodeを増やすことで解決もできます。 しかし、コスト効率はよくないですし、HotSpotのようにNodeを増やしても解決できない問題もあります。 Statistics tables を定期的に確認し、パフォーマンスの状態をモニタリングすること。 新しいクエリの作成や既存のクエリの修正を行う場合は実行計画を確認すること。 アクセスパターンに対してSpannerがどんな挙動をする必要があるかSpannerの気持ちになって考えること。 これらを日常的に行うことで、よりよいSpannerライフが送れます。 次の記事は komatuさんの「決済プラットフォームと経理を繋ぐ MoneyFlow」です。引き続きお楽しみください。 ※1 FilterScan operator の Seek ConditionとResidual Condition Seek ConditionはTableやIndexのスキャン範囲の開始と終了地点が特定できている状態の時に使われます。開始地点から終了地点まで読み込むだけなので、高速に動作します。 Residual ConditionはTableやIndexを読み込む時に開始地点と終了地点が特定できず、データを実際に読んでFilterする必要がある時に利用されます。Seek Conditionと比べるとCPUを多く消費しますし、スキャンするデータ量に応じてLatencyも増加します。 Seek ConditionとResidual Conditionについては Cloud Spanner Unofficial Hacks を読むとよいでしょう。 ※2 IS NOT DISTINCT FROM SpannerにはIS NOT DISTINCT FROMがないので、co.UserID IS NULL AND @userID IS NULLを入れているわけですが、2026年5月25日に再度試したところIS NOT DISTINCT FROMで動作していました。まだ、ドキュメントには反映されてないので、記事の中では使っていませんが、近々リリースノートが出るのでしょう。
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の 2 日目です。 私たちのチームは、マーケターや PM 向けの社内ツール群 Engagement Platform (EGP) を開発しています。ランディングページ (LP) の作成・公開もその一機能で、過去に WYSIWYG コンポーネントエディタ EGP Pages について同じチームから紹介記事を出しています。 今回はその後継としてゼロから作り直した EGP Code を紹介します。AI エージェントと対話しながら LP を作るための、HTML ベースの社内向け LP エディタです。見た目を生成するだけでなく、本番運用に必要なところまで踏み込んで同じ編集体験の中に組み込んでいるのが特徴で、すでに 10 件以上の本番 LP がこの仕組みで作られています。 v0、Gemini Canvas、Claude Design、Figma Make など、AI で UI を作れるツールはすでに数多くありますが、見た目は作れても本番 LP として運用するには、API 連携・品質保証・Native 連携といった社内固有の課題が残ります。EGP Code は、このギャップを埋めるために内製しました。 EGP Pages と AI 編集における課題 EGP Pages は、ブロックを選んで組み合わせるノーコードの WYSIWYG コンポーネントエディタです。ドラッグ&ドロップで Layout や Text といった 40 種類以上のコンポーネントから、ページを組み立てます。マーケターがエンジニアの手を借りずに LP を作れるという目的に対して非常によく機能しており、いまも多くの LP が EGP Pages で作られています。 転機になったのは、AI でページを編集したいというニーズが出てきたことです。EGP Pages は人がドラッグ&ドロップで組み立てる前提で設計されており、AI が扱う際にデータ構造が問題になります。例えば、ボタンを押すと数字が増えるページの JSON ツリーは次のようになります。 { "components": [{ "id": "root", "elements": [ { "id": "1", "tagName": "Context", "props": { "value": [ { "name": "count", "type": "code", "value": "0" }, { "name": "increment", "type": "code", "value": "(count) => count + 1" } ]}}, { "id": "2", "tagName": "Layout", "props": { "children": [":=element.3", ":=element.4"] }}, { "id": "3", "tagName": "Text", "props": { "value": "Count: ${context.count}" }}, { "id": "4", "tagName": "Action", "props": { "label": "+1", "onTriggerAction": [{ "type": "SET_CONTEXT", "payload": { "count": "${context.increment(context.count)}" }}]}} ] }] } 人がエディタ越しに触る分には、この構造でも問題ありませんが、LLM に直接編集を任せようとすると課題が見えてきます。 ツリー構造が独自 : ":=element.3" のような独自記法を AI に都度教える必要があり、プロンプトが長くなります。 ロジックが分散 : 状態・条件・動作・表示が Context / When / Action / Text に散らばり、挙動の把握にツリー全体を辿る必要があります。 JSON 文字列の中に JavaScript が埋まっている : テンプレートリテラルか eval される式かが描画コンポーネント次第で、正しい解釈が難しくなります。 さらにこの JSON ツリーは等価な HTML のおよそ 2 倍のトークンを消費し、編集のたびに API コストとコンテキスト消費が膨らみます。加えてテスト基盤がなく、AI の編集結果を公開前に機械的に検証する手段がありませんでした。これは EGP Pages の設計が悪かったわけではなく、ノーコード時代に最適化された正しい設計でした。ただ AI に編集させるという前提が加わったことで設計を問い直す必要が出てきたのです。 HTML ベースで作り直す 選択肢は2つありました。既存の JSON 表現を AI 向けに改善するか、AI 前提でゼロから作り直すかです。私たちは後者を選び、ページの表現を HTML ベースにしました。HTML は人にも LLM にも馴染みがあり、独自の JSON ツリーや参照記法を毎回プロンプトで教える必要がないからです。 先ほどのカウンターページは、EGP Code では次にようになります。 <body> <egp-script timing="page-loaded"> rx.count = 0; </egp-script> <egp-script> rx.increment = () => { rx.count = (rx.count ?? 0) + 1; }; </egp-script> <p><egp-text>Count: {{rx.count}}</egp-text></p> <egp-button :onclick="rx.increment">+1</egp-button> </body> 機能は同じですが、コード量もトークン消費もおおよそ半分です。ただし、素の HTML だけでは、状態管理や条件分岐といった動的な振る舞いは表現できません。かといって <script> で自由に JavaScript を書かせると、ページの挙動を追いづらくなります。 そこで、状態管理・条件分岐・繰り返しといった動的な部分だけを、少数の Web Components ( <egp-*> ) に閉じ込めました。「静的な部分は普通の HTML、動的な部分だけ Web Components」という切り分けによって、EGP Pages のように状態・条件・動作が散らばらない構造になっています。 見た目のスタイリングには Tailwind CSS を採用しています。Web 開発者に馴染みのある書き方に寄せることで、人も AI も独自の作法を覚えずに済みます。また、副次的な効果として、外部ライブラリへの依存が最小限になり、LP ごとに独自パッケージが混ざりません。ランタイムは中央で管理する少数のものだけで動くため、npm パッケージ起因のサプライチェーンリスクが問題になる中でも安全面で利点があります。 使い方 EGP Code では、ほとんどの操作を AI エージェントとのチャットで進めます。「LP を作りたい」のように要件が固まっていない依頼では、エージェントはいきなり作り始めるのではなく、文脈に応じた質問を返してくれます。対象デバイス、カラーテーマ、入れたいセクションといった項目を選択肢から答えていきます。 回答を送ると、エージェントが HTML やテストをまとめて生成し、たたき台となる LP ができあがります。 大まかな見た目ができたら、ボタンやテキストなどの要素を直接選んで仕上げます。対象をクリックして「文字サイズを大きくして」「文言を〜に変えて」のように頼めば、位置を説明しなくてもエージェントが直す箇所を正確に把握します。複数箇所にまとめてコメントを付けたり、参考にしたい画像を貼って渡すことも可能です。 実運用に必要な 3 つの仕組み LP と聞くと、文章と画像が並んだ静的なページを思い浮かべるかもしれませんが、実際には、エントリー状況で CTA ボタンを切り替えたり、お客さまの属性で見せ方が変わったりと動きを伴う LP も少なくありません。 そのため、見た目だけが整っていれば十分というわけではなく、社内 API との連携、タップ時の分析ログ送出、公開前に表示や挙動を検証する仕組み、アプリと Web の遷移差を吸収する仕組みなど、周辺の仕組みもあわせて必要になります。 EGP Code では、こうした仕組みを編集体験の中に組み込んでいます。以降では、この 3 つの仕組みを順に紹介します。 社内 API 連携と Logging LP からは、商品一覧の取得やエントリー状況の確認といった社内 API への呼び出しが頻繁に発生します。しかし、社内 API は AI ツールの学習データに含まれていません。EGP Code では、このギャップを「使い方をその場で AI に教える」仕組みで埋めています。 例えば「商品一覧を出す API は?」と聞くと、エージェントは候補の社内 API を用途つきで挙げてくれます。 このやり取りの裏側では、エージェントが「関連する API を探す → 使い方を理解する → 型付きで実装する」という流れで動いています。 この流れを実現するために、いくつかの工夫をしています。まず、社内 API の使い方を、API ごとに Markdown ファイルに記載しています。実際には、次のようなファイルが並んでいます。 api-searchExampleItems.md api-postExampleEntry.md api-getExampleSegment.md api-getExampleRecommendations.md runtime-event-log.md ... すべての API の説明をプロンプトに乗せてしまうと不要にトークンを消費してしまいます。そこで各ドキュメントのタイトルと用途だけを渡しておき、AI が必要と判断したときにだけその本文を読み込ませる形にしています。 次に、社内 API は型付きの薄いラッパー関数越しに呼び出します。LP から見えるのは関数呼び出しだけで、認証ヘッダ・サービス分岐・パスの違いはラッパーが吸収します。誤った使い方があれば Lint が検知します。 分析ログの送出も同じドキュメント参照のしくみで扱っています。 ログが必要になったタイミングで、エージェントが対応するドキュメントを読み込み、API 呼び出しと同じ流れでログ用のコードを生成します。このしくみによって、AI に「商品一覧を出して」と頼むだけで、社内 API を正しく呼んだ動的な LP が組み上がります。 エディタ内で完結するテストと品質保証 AI が生成した API 呼び出しが正しく動いているか、ボタンが期待どおりに反応するかを、変更のたびに人が目視で確認するのは現実的ではありません。そこで EGP Code は、エディタ内にテストの仕組みを内蔵しています。 エンジニア以外にとってテストは馴染みがなく、自分で作成するのはハードルの高い作業です。そこで、テストを直接書く代わりに、実現したい振る舞いを自然な言葉で書ける Spec タブを用意しています。ここに、LP の仕様を書き残していきます。 あとは AI とのチャットで「@SPEC.md を元にテストを書いて」と頼むと、文脈に応じてテストが自動で生成されます。テストはエディタ向けに自作した Jest 風の API で書いており、内蔵のモックサーバで fetch を差し替えられるので、本番 API がなくても動的ページの挙動をエディタ内で再現できます。 // ブラウザ上でそのまま実行される test('エントリーボタンで API が呼ばれる', async () => { render(html); await userEvent.click(screen.getByText('エントリー')); expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが失敗すると、その結果は AI にフィードバックされ、AI が自己修正します。 仕様を書く → AI が実装とテストを生成する → ブラウザで挙動を確かめる という流れがエディタ内で完結するため、静的な LP から API を使った動的な LP まで、同じ仕組みで品質を担保しながら作ることが可能です。 アプリと Web の差を吸収する Native 連携 キャンペーンページなどの LP は、Web ブラウザだけでなく、メルカリアプリ内の WebView でも開かれます。このとき通常のリンクのままだと、アプリ内では外部ブラウザが開いてしまい、アプリのネイティブ画面へ遷移できません。これを LP ごとに userAgent 判定や Native bridge の呼び出しで書くのは現実的ではありません。そこで、その差をプラットフォーム側で吸収し、LP を作る側は専用の Web Components を使うだけで済むようにしました。 <egp-link href="https://jp.mercari.com/search?keyword=camera"> カメラを探す </egp-link> このリンクをクリックすると環境を自動で判定して、アプリ内なら Native bridge 経由でネイティブ画面へ、Web なら通常のリンクとして開きます。 まとめ さまざまな AI ツールによって UI を作りやすくなっていますが、実運用の LP に乗せるには、API 連携・品質保証・Native 連携といった作り込みが必要になります。EGP Code は、そこをプラットフォーム側に組み込むことで、UI づくりから運用までを同じ編集体験の中でつなげようとしています。 実際に次のような新しい進め方が出てきています。 PM とフロントエンドエンジニアだけで、仕様策定から実装・リリースまでを完遂 バックエンドエンジニアが API から LP まで 1 人で構築 マーケターが静的な LP を 1 人で制作 テストや API 連携を含む動的な LP は、まだ非エンジニアだけで完結させるには難しい部分が残っています。それでも、誰がどこまで担えるかの境界は少しずつ動いていて、いずれはこうした動的な LP も非エンジニアだけで作れるようになると考えています。 次の記事は @sinmetalさんの「MySQLからSpannerに移行した時のQueryチューニング」です。引き続きお楽しみください。
こんにちは。メルペイ Payment & Customer Platform(PCP)チームでBackend Engineerをしている @ryuyama です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の1日目の記事です。 はじめに メルカリでは、2025年9月30日にグローバル版のメルカリアプリをリリースしました。このアプリは台湾・USをはじめとして、 3年以内に50カ国へ展開することを目指しており 、メルカリのグローバル展開を加速させるための重要なマイルストーンとなりました。 この記事では、メルカリ グローバルアプリ(以下、グローバルアプリ)のリリースに向けて、Payment Platformがどのような課題に向き合い、どのようにアーキテクチャを進化させてきたのかを紹介します。 Payment Platformに課せられたミッション グローバルアプリ対応プロジェクトが立ち上がった際に、Payment Platformに課せられたミッションは、 「3年以内に50カ国展開できる決済・会計システムを作ること」 でした。 そこで、Payment Platformのシステムを大きく「決済」と「会計」のドメインに分け、達成すべきゴールを整理しました。 決済基盤としては、主に以下のような対応が求められました。 50カ国の通貨・決済手段への対応 決済画面の多言語対応 複数のPSP(決済事業者)との接続 為替レートを考慮した金額計算 不正利用やチャージバックのリスクへの対応 会計基盤としては、以下のような状態をゴールと置きました。 通貨ごと、国・地域ごとの売上、返金、決済手数料を正しく集計できること PSP(決済事業者)からの入金と、メルカリ側の取引データを照合できること 在庫変動や税務関連の会計イベントを、外部システムから会計システムへ正しい粒度・タイミングで連携できること 既存の国内向け会計システムとの整合性を保ちながら、新しい会計基盤へ移行できること また、これらを一度きりのゴールとして実装するのではなく、グローバル展開のコストとスピードを強く意識する必要がありました。例えば、通貨や決済手段の追加をできるだけ迅速に行い、展開国を早く増やせること。不正対策、税務、会計などの各国・地域ごとの最適化を、それぞれ独立して進められることが求められました。 Payment Platformの進化 既存のPayment Platform 先ほど掲げたミッションを達成するために、現在のアーキテクチャにある課題洗い出しを行いました。Payment Platformは、日本のメルカリアプリの決済基盤をベースにして誕生し、 メルペイの成長とともに進化してきました 。 決済のレイヤーでは、Payment Serviceというマイクロサービスが存在します。Payment Serviceでは、メルカリで商品を購入し、取引が完了するとお金が移動するという一連の取引を表現した Escrow というリソースや、お金の動きに注目した Charge、さらに3Dセキュアの普及や高度化する決済手段への対応のために Source というリソースが開発されてきました。 会計のレイヤーでは、Balance Service(残高サービス)やAccounting Service(会計サービス)といったサービスが存在します。Balance Serviceはお客さまの残高や加盟店の売上残高の管理を、Accounting Serviceは会計イベントの保存と経理向けのレポーティングを責務としています。 これらはいずれも、これまでのメルカリグループ全体の成長を支えてきた重要なサービスです。 一方で、グローバル対応に向けて、特に「展開コスト」と「展開スピード」という観点において、いくつかの課題がありました。 グローバル展開に向けた課題 PSPや決済手段の追加にPayment Serviceが必要 1つ目の課題は、Payment Serviceにおいて、PSP(決済事業者)との接続ロジックと Source リソースが密結合していたことです。 グローバル展開では、国や地域によって利用される決済手段が異なります。また、接続すべきPSPも国や地域ごとに変わる可能性があります。 そのため、新しいPSPや決済手段を追加するたびにPayment Serviceのロジックへ変更が入る状態では、展開国が増えるほど開発・運用コストが増大してしまいます。 また、Source が特定の決済手段と密結合していたため、新しい決済手段を追加する際には、新しいリソース定義や既存リソースへの影響を慎重に検討する必要がありました。 国内向けの決済基盤としては十分に機能していた設計でも、50カ国展開を前提にすると、決済手段やPSPの追加をより小さな変更範囲で実現できる構成が必要でした。 残高と会計イベントの整合性がアーキテクチャで保証できていない 2つ目の課題は、残高管理と会計イベントの整合性です。 既存の構成では、Balance Serviceが残高を管理し、Accounting Serviceが会計イベントを保存していました。しかし、両者の整合性は会計レイヤー自体で保証されているわけではなく、Payment Service側のサービス間トランザクション管理に依存していました。 グローバル展開では、通貨ごと、国・地域ごと、PSPごとの売上、返金、決済手数料を正しく集計できることが求められます。また、PSPからの入金とメルカリ側の取引データを照合できることや、在庫変動・税務関連の会計イベントを外部システムから会計システムへ正しい粒度・タイミングで連携できることも必要になります。 そのため、単に決済処理の結果を保存するだけではなく、後続の照合やレポーティング、既存の国内向け会計システムとの整合性を見据えた会計基盤が必要でした。 この課題は、グローバル対応が始まる以前から、メルカリのグローバルなマーケットプレイスの実現というビジョンに向けての課題だと認識されていました。これらの課題は次世代の会計システムであるBalance V2やBookkeeperとして、 メルコイン やメルカリ ハロの立ち上げに合わせて導入され、実運用の中で磨き上げられていました。一方で、メルカリのマーケットプレイスに関連する領域では既存システムからの移行コストが高く、導入の機会を待っている状況でした。 現在のアーキテクチャ Payment Platformでは、グローバル対応に合わせて2つの大きな意思決定を行いました。 1つ目は、PSP(決済事業者)と接続するための処理をPayment ServiceからPSPとの接続を責務とするPayment Provider Serviceに切り出し、PSPとの接続ロジックや決済手段に関するロジックを決済リソース管理から分離することです。 2つ目は、会計システムにBalance V2とBookkeeperを利用し、Payment Serviceのトランザクション管理による整合性の管理から、残高と会計イベントが整合したモデルで扱われるアーキテクチャへ移行することです。 Payment Serviceが決済リソースのライフサイクル管理に集中する 新しいアーキテクチャでは、Payment Serviceの責務をより明確にしました。 Payment Serviceは注文や取引に紐づく決済リソースのライフサイクル管理に集中し、決済の作成、オーソリ、キャプチャ、キャンセルなどといった状態遷移を管理します。 一方で、どのPSPのAPIをどのように呼び出すか、各決済手段にどのようなパラメータが必要でPSPから返ってくるレスポンスやWebhookをどのように処理するかといった詳細はPayment Provider Serviceに切り出しました。 これにより、Payment Serviceは特定のPSPや決済手段に強く依存せず、より安定した決済リソース管理の責務に集中できるようになりました。 Balance V2 / Bookkeeperで残高と会計イベントの一貫性を担保する 会計基盤には、 Balance V2 とBookkeeperを利用しました。 Balance V2とBookkeeperでは、残高の変動と会計イベントを一貫したモデルとして扱います。これにより、残高更新と会計イベントの記録が別々のデータとして管理されるのではなく、複式簿記のように、「現在の残高(ストック)」と「残高が変化した理由(フロー)」を対応づけて保存されるようになりました。 このアーキテクチャにより、Payment Service側で残高と会計イベント間の整合性を保つ複雑なサービス間トランザクション管理を担う必要が減り、Payment Serviceは決済リソースの管理により集中できるようになりました。 終わりに 今回のプロジェクトでは、グローバル展開に必要な決済・会計基盤の土台を作ることができました。 記事執筆時点では台湾・USへのローンチが完了し、クレジットカード決済・Apple Pay・Google Payなどの決済手段にも対応しています。 また、Payment Platformで実現した抽象化を利用して、カートを使ったまとめ買い機能などの新しい購入体験も日本国内に先立ってグローバルアプリで提供することができました。 一方で、今後取り組むべき課題もまだ残っています。 例えば、日本のメルカリで日本円で売られている商品を海外通貨で販売する時の為替変換の仕組みは、現時点ではグローバルアプリ専用の実装に近く、社内のPayment Platformを利用しているチームにとって十分になめらかな体験になっているとは言えません。また、グローバルアプリ内でのポイントや残高の利用サポートも、今後進めていく必要があります。 そして、より長期的には、今回整備した基盤を日本国内のメルカリにも適用するプロジェクトも進行しています。今回整備したグローバル向けの基盤や設計を国内向けの基盤にも還元することで、Payment Platform全体をより柔軟で拡張しやすい基盤へ進化させていきたいと考えています。 次の記事は mattsuuさんです。引き続きお楽しみください。
こんにちは。メルペイ Engineering Engagement チームの @mikichin です。 メルカリグループは「あらゆる価値を循環させ、あらゆる人の可能性を広げる」をミッションに、さまざまなサービスを展開しています。 メルペイは単なる決済サービスではなく、新しい「信用」を基盤として、それに基づく循環型社会、なめらかな社会を創ることを、メルコインはテクノロジーによって、さまざまな価値観の境界線を打ち破り、誰もが暗号資産・デジタル資産などあらゆる価値を簡単に交換できる世界の実現を目指しています。 そのためには、お客さま・企業・金融機関など、さまざまなステークホルダーに対して「OPENNESS」な姿勢で向き合うことで、もっと身近なものに変えていきたいと考えています。 本企画は、技術も「OPENNESS」にしていこうという考えのもと、2019年にスタートしました。 「Merpay & Mercoin Tech Openness Month 2026」では、メルペイ・メルコイン・メルカリモバイルの開発をしているエンジニアたちの取り組みをご紹介します。 各エンジニア組織がテクノロジーでお客さまの課題解決を実現することを大切にし、その挑戦の中で得た知見を6月1日から約1ヶ月間に渡り公開していきます!技術、開発設計や思想、組織ストラクチャー、Tips、その他最近の取り組みなど、幅広くお伝えします。 2019年は こちら 2020年は こちら 2021年は こちら 2022年は こちら 2023年は こちら 2025年は こちら ▼公開予定表 (こちらは、後日、各記事へのリンク集になります) Title Author GOPの100カ国展開を支えるPayment Platform @ryuyama AI と作る HTML ベースの LP エディタ EGP Code を作った理由 @mattsuu MySQLからSpannerに移行した時のQueryチューニング @sinmetal 決済プラットフォームと経理を繋ぐ MoneyFlow @komatsu TBD @hokao 事業者請求払いのための与信管理マイクロサービスの設計 @imamu 独立したカーソルを持つ複数データソースを統合するページネーション設計 @nanacom event駆動のチームにおいてpull型subscriptionを保ったままのPRRC環境の導入 @mikupo 内製ワークフローエンジンの設計とメルカリでの活用事例 @sapuri 初回ミーティングに開発完了した状態で臨むと超Move Fastになる @kubomi CloudFunction移行で1000万件滞留した話:PubSub × Cloud Run × Spannerのチューニング @mewuto 修正PRを食べてレビュースキルが賢くなる:Claude Codeによる自己改善サイクル @um(うめ) メルペイのキャンペーン基盤をルールベース汎用システムに書き直して Otoku Revolutionするまで — Santa Service の Rulebase 移行の話 @hasegway TiDB – BQ連携データパイプライン @orfeon About my AI work setup @cyan Product Engineerとして働く @anzai Otoku Revolutionのすべてをお話します @yutaro 任意の単位での債権の色付けと与信の分割を実現するDebt View @kobaryo TBD @abcdefuji Growth Platform体制の振り返り @yo-gawa Next Payment @haoyu TBD @Marlon どんな知見が得られるのか、毎日が楽しみです。 Merpay & Mercoin Tech Openness Month 2026 の1日目は、メルペイ Payment Platform @ryuyama が執筆予定です。 ひとつでも気になる記事がある方は、この記事をブックマークしておくか、 エンジニア向け公式X をフォロー&チェックしてくださいね!
はじめに こんにちは。MercariでPMインターンをしている 菊池翔吾 です。 インターン期間中に mercari-pm-agent というClaude CodeのSkillを開発しました。PMが行う「問題の発見→データ収集→PRD作成→UIモック」の一連のワークフローを、1つのセッション内で処理するエージェントです。 この記事では、PMのワークフローをClaude Code上でどのように実装したか——Skillの設計と、MCP(Model Context Protocol)を使ったNotion・Slack・Looker・Figmaとの接続方法——を中心に紹介します。 背景:メルカリPMの情報収集ワークフローと課題 メルカリのPMが意思決定を行うには、複数のツールを横断して状況を把握する必要があります。 Notionで中期戦略・KPI目標の方向性を確認する Slackで社内の改善要望やフィードバックを検索する Lookerでユーザー行動の定量指標を確認する Figmaで対象画面の現状デザインを確認する これらを統合してPRD(製品要求仕様書)に落とし込む 各ツールへのアクセス自体は難しくありませんが、ツールを横断しながら「どのデータが今の判断に関係するか」を整理する作業には一定の時間がかかります。PMが本来時間を使うべきは、集めた情報をもとに深く考え、意思決定し、関係者と対話することのはずです。情報収集にかかる時間を、思考と意思決定に充てられるようにしたい——それがこのツールを作った動機です。 mercari-pm-agentの概要 mercari-pm-agent は、Claude CodeのSkillとして実装したPM支援エージェントです。 PMがプロダクト上のビジネス課題を自然言語で入力すると、以下のステップが自動的に進みます。 処理の流れ 実装:Claude Code SkillsでPMワークフローを定義する Claude Code Skillsとは Claude Code Skillsは、Claude Codeの振る舞いをMarkdownファイルで定義する仕組みです。 SKILL.md にエージェントの動作手順・制約・ツールへのアクセス方法を記述することで、特定の業務フロー専用のエージェントを構築できます( 公式ガイド )。 コードを書かずにエージェントの振る舞いを定義できる点が特徴です。PM向けSkillの実装例としては phuryn/pm-skills も参考にしました。ただし、後述するように「Markdownを書くだけ」では精度は出ません。振る舞いの制約設計と評価サイクルが重要です。 ファイル構成:関心の分離をプロンプト設計に適用する mercari-pm-agent/ ├── [SKILL.md](http://skill.md/) # エージェントの振る舞い定義(英語) └── references/ ├── [prd-template.md] # PRDテンプレート ├── [prd-checklist.md] # PRD品質チェックリスト(9項目) ├── [ui-and-figma.md] # UI Spec・Figma Makeプロンプトテンプレート ├── [laplace-guide.md] # データ解釈ガイド ├── [data-sources.md] # データソース一覧・使い方 └── [quick-reference.md] # 出力チェックリスト 初期は全ての定義を SKILL.md 1ファイルに集約していましたが、後述する評価スキルによるスコアリングを通じて、 ファイルが長くなるほど出力精度が低下する という問題を確認しました。 これはLLMの特性と関係しています。コンテキストが長くなると、モデルが文脈の中で関連情報に適切に注目できなくなる現象(いわゆる「Lost in the Middle」問題)が知られており、Anthropicのプロンプトエンジニアリングガイドでもプロンプトを簡潔に保つことが推奨されています。 対応として、 振る舞いの定義 ( SKILL.md 本体)と 参照データ・テンプレート (references/)を分離しました。ソフトウェア開発における「関心の分離(Separation of Concerns)」をプロンプト設計に適用したアプローチです。 SKILL.md はエージェントが「何をどの順序でするか」のみを保持し、具体的なデータやテンプレートは必要なタイミングでreferencesから参照する設計です。この構造変更だけでスコアが明確に改善しました。 なお、 SKILL.md は英語で記述しています。Claudeへの指示として英語の方が精度が高いためです。 MCP接続:複数ツールをエージェントに繋ぐ mercari-pm-agent の中核的な価値は、Step 2のデータ収集を自動化する点にあります。ここではMCP(Model Context Protocol)を使ったツール接続の設計について説明します。 MCPとは MCPはAnthropicが策定したオープンプロトコルで、LLMアプリケーションが外部ツールやデータソースに接続するための標準仕様です。MCPサーバーを通じて、Claude CodeからNotion・Slack・Lookerなどの外部サービスをツールとして呼び出せるようになります。 接続しているMCPサーバー MCPサーバー 種別 取得できる情報 用途 Notion MCP 公式(Notion提供) 戦略ドキュメント・KPIダッシュボード 中期戦略との整合性確認 Slack MCP 社内独自実装 社内フィードバックチャンネルの投稿 改善要望・現場の声の収集 Socrates 社内独自実装(BigQuery・Lookerベース) CVR等の指標データ 定量的な課題の裏付け Figma MCP 社内独自実装 デザインファイルのコンポーネント情報 既存デザインの取得・UI Specへの反映 並列クエリと堅牢性の設計 Step 2(データ収集)では、これら複数のMCPを 並列で クエリします。 data-sources.md に以下のルールを記述しています。 - Pull in parallel during Data Enrichment — do not wait for one source before querying another. (データ収集フェーズでは並列で参照する。1つのソースの完了を待たないこと) - If a source is unavailable, skip silently and mark it in the output. (ソースが利用不可の場合は、出力にその旨を明記してスキップする) 直列での順次参照に比べてユーザーの待ち時間を削減するためです。また、いずれかのMCPが利用不可の状態でも処理が止まらないようフォールバック設計を入れています。 セキュリティ上の考慮 Slack MCPのセットアップには社内VPN接続とUser Tokenによる認証が必要です。トークンはClaude Codeの設定ファイルに環境変数として渡す形にしており、チャット上でトークン文字列が露出しない設計にしています。また、SlackのUser Tokenは7日で失効するため、更新用のスクリプトを別途用意しています。 開発で大事にしたこと 評価基準を先に決める——プロンプトのTDD 実装を始める前に、まず「エージェントの出力をどう評価するか」の基準を定義しました。 課題の理解精度(問題の本質を正しく捉えているか) 仕様の具体性(実装可能なレベルで記述されているか) 実現可能性(技術的・リソース的に妥当か) UXの妥当性(お客さまにとって使いやすいか) これはソフトウェア開発におけるテスト駆動開発(TDD)に近い発想です。LLMベースのエージェントは「動くかどうか」より「正しく動くかどうか」の判定が難しい。評価軸を先に定義することで、プロトタイプの改善サイクルを感覚ではなく基準で回せるようになりました。実際のWeb改善課題を収集して評価データセットを作り、反復的に精度を上げていきました。 LLMの「それらしい嘘」を制約として防ぐ LLMを業務フローに組み込む上で最も危険なのは、「根拠のないそれらしい情報」の生成です。データが存在しない状況でも、モデルは自然に「それっぽい数値」を出力します。PMがその数値を信じてPRDに記載してしまうと、意思決定の根拠がフィクションになります。 これは「嘘をつくな」とプロンプトで命令するだけでは解決しません。モデルがデータ不足を認識したとき、どう振る舞うかを 制約として設計 する必要があります。 Data integrity rules: Unconfirmed data must be labeled "Not provided" or "To be validated" (未確認のデータは "Not provided" または "To be validated" とラベルすること) Never fabricate numbers or sources (数値や出典を捏造しないこと) さらに、PMの確認なしに次のステップへ自動的に進むことを禁じました。 You are NOT allowed to infer completeness. Only explicit confirmation from the PM allows progression. (完了を推測して次へ進むことを禁じる。PMの明示的な確認があった場合のみ次へ進める) これにより、エージェントが「それらしい流れ」で自動進行するのではなく、常にPMが意思決定のドライバーである状態を維持します。 スキルをスキルで評価する——自動評価パイプライン 設計したルールが実際に機能しているかを検証するため、評価専用のスキル( skill-creator-max )を別途作成しました。 mercari-pm-agent に対してテストケースを投げ、出力の品質をスコアリングして返すエージェントです。このスコアを使った反復改善の中から、前述の「 SKILL.md は短いほど精度が上がる」という知見が得られ、ファイル分割の設計変更につながりました。 まとめ mercari-pm-agent の開発を通じて得た、Claude Code Skillsを使ったエージェント設計の主な知見をまとめます。 Skillの設計は「振る舞いの仕様書」を書くことに近い。  命令ではなく制約の設計が重要で、LLMが「どう振る舞うべきでないか」を明示することが精度に直結する。 MCPによる外部ツール接続は並列設計で。  直列参照はユーザー体験を悪化させる。フォールバック設計とあわせて、接続の堅牢性を考慮する必要がある。 プロンプト設計にも関心の分離が有効。  コンテキストが長くなるほど精度が下がる。振る舞い定義と参照データの分離は、ソフトウェア設計の原則をLLM設計に適用した結果として機能した。 評価基準は実装より先に作る。  LLMエージェントの品質評価は主観に陥りやすい。評価軸を先に定義し、評価専用のエージェントを作ることで客観的な改善サイクルが回せる。 mercari-pm-agent はClaude CodeのSkillとして実装しているため、MCP設定が済んでいれば /mercari-pm-agent のコマンド1つで起動できます。 PMの業務効率化やClaude Code Skillsを使ったエージェント設計に興味のある方の参考になれば幸いです。