Stripeを利用したSaaS開発の苦い思い出
この記事はPAY Advent Calendar 2024 24 日目の記事です。
はじめに断っておきますが、Stripeという決済サービスはとても使いやすいSDKが用意されており、ドキュメントも丁寧で開発者体験が良かったです。この記事はStripeの苦い思い出ではなく、自分のDB設計ミスの話です。同じ失敗をする人が少しでもいなくなることを願って昔の失敗談を共有します。
初めての0→1開発。社内でSaaSの新規開発プロジェクトを任された
ソフトウェアエンジニアになってちょうど3年が経った頃、2社目での出来事です。社内で新規プロジェクトが立ち上がりました。上の人が前からこういうサービスを作りたい!と考えていたSaaSの開発です。
当時、自分はバックエンドのコードを書いていました。しかし、個人開発でWebサイトを作るうちにフロントエンドの面白さに目覚め、以前からフロントエンドもやってみたいとマネージャーに話していました。すると熱意を買ってもらえたのか、バックエンドとフロントエンドの両方を任せてもらえました。
プロジェクトのメインメンバーはプロダクトオーナー、デザイナー、そして自分の3名でした。システムのコアの部分はテックリードが担当し、自分はユーザー管理、決済周りとフロントエンド全般を担当しました。また、インフラは別のチームのエンジニアにヘルプで入って貰ってECSで構築しました。
当時はあたかもスタートアップのような働き方をしていました。
ビジネスのアイデアはふわっとしたものが上から降りてきただけだったので、詳細はプロダクトオーナーを始め自分たちで話し合って詰めていきました。デザインも0から作りました。侃侃諤諤あーだこーだと議論してプロトタイプを作って、それを元にユーザーインタビューを5件ほど行いました。
東京のオフィスを飛び出して、チームみんなで丸の内や埼玉の大宮のユーザー候補の方のところに直接出向いて行きました。できるだけ多くのインサイトを得ようと自分たちのプロダクトを実際に使うところを後ろから見させてもらったり、普段の業務フローやプロダクトの使い心地のインタビューをさせてもらったのは良い思い出です。
バックエンドの設計とStripeの利用
システム設計について、DB設計はシンプルで5テーブルもなかったと思います。テックリードは口数が少ないものの自分たち普通のエンジニアが考えていることの2歩も3歩も先のことを静かに、しかし鋭く指摘する方でした。
そんなテックリードにテーブル設計をレビューをしてもらったときはとても緊張しました。それでも保持するデータがシンプルなため「いいと思います」ということで大きな手直しなく無事レビューは通りました。
バックエンドに関してはルーティングがあるくらいの薄いフレームワークを使いました。そこにORMとDIライブラリを入れてアプリケーションを構築しました。
SaaSであるため決済周りは外部サービスを使うことになりました。いくつか候補があったのですが、Stripeを使うことに決まりました。Stripeの使い方や月額課金の更新などの質問をすると、日本オフィスの人が日曜日でも返信してくれました。なんてサポートが手厚いんだと感動した覚えがあります。
他の候補はGMOペイメントゲートウェイ、Veritransだったと思います。本記事はPAY.JPのアドベントカレンダーですが、PAY.JPの存在は当時知りませんでした。
外部連携であるためStripeのSDKからこちらの部分はAdapterパターンを採用して、SDKのバージョンアップに柔軟に対応できるようにしました。StripeがSDKで持っているCustomerやCardオブジェクトをそのまま使うのではなく、自分たちの作るPlainなクラスに変換して、SDKへの依存を一部に留めることを意識しました。
当時の自分はケント・ベックに傾倒していたため、全てのクラスをTDDで作りました。サービスは必ずインターフェースを作り、public メソッドは1クラス1つにしました。DI機構も入れて外から必要なクラスを注入する形になっています。サービスロケーターを回避していたためオブジェクトの差し替えが簡単にでき、サービスクラスのテストも容易でした。Stripeはモックを用意してくれているので、外部連携ながらもモックを使ってテストすることも簡単でした。
OpenAPIの定義を書いてPrismaでモックサーバーを動かしたり、サーバーを立ててAPIをコールするテストフレームワークをテックリードが導入してくれたため、ユニットテストはインテグレーションテストのみならず、APIレスポンスを検証するテストコードも書き溜めていったり(テックリードはこれをE2Eテストと呼んでいました)、テストのカバレッジが高く堅牢な作りだったと思います。
今から考えるとディレクトリ構成面でもコードの設計面でもテストコードの面でもまだまだブラッシュアップできたと思います。当時の自分はドメインモデリングの力が弱く、StripeのCustomerやSubscription、Cardなどのクラスの写しを作ってしまっただけだったことや、アクティブレコードパターンで闇雲にJOINすることは避けられたものの、1テーブル1Repositoryで作ってしまっていたのは反省ポイントです。
それでも当時はボブおじさんの書籍を熱心に読み漁っていたため、SOLID原則やクリーンアーキテクチャを強く意識した作りにしていました。3年目にしては変更に強い柔軟な設計になっていたと自負しています。
Stripeの利用
Stripeで利用した機能について触れておきます。そもそも開発するのは個人向けのSaaSであり、月額課金を実施したいのでした。毎月末に翌1ヶ月分の請求をする月額プランと、月額プランより少しお得な年額プランを用意していました。月の途中に加入しても1ヶ月分の利用料が引き落とされるため、月初に加入するのがお得という仕組みです。
StripeのSDKで利用したクラスはCustomer, Plan, Product, Subscriptionなどです。Stripeのモデリングは本当によく出来ていて便利でした。自分もしっかりモデリングをするぞと息巻いて取り組もうとしたものの、Stripeのモデリングのレベルが高すぎてそのまま使わせてもらおうと思ったのでした(今から考えると工夫の余地はありそうですが)。Stripe APIのドキュメントには大変お世話になりました。
Customerは顧客です。APIをコールすると以下のような顧客データを取得できます。データはStripeのドキュメントのものです。
{
"id": "cus_NffrFeUfNV2Hib",
"object": "customer",
"address": null,
"balance": 0,
"created": 1680893993,
"currency": null,
"default_source": null,
"delinquent": false,
"description": null,
"discount": null,
"email": "[email protected]",
"invoice_prefix": "0759376C",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {},
"name": "Jenny Rosen",
"next_invoice_sequence": 1,
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}
名前や住所、メールアドレスなどを持っています。API経由で作成したり取得したCustomerを自分たちで作成したクラスに置き換えます。ここがAdapterの役割です。
流石に前職で書いたコードをそのまま紹介するのは良くないので、大体こんな感じというものをTypeScript風の疑似コードで書きます。
Plain Old Java Object のように、自分たちのコードでCustomerクラスを定義しました。
class Customer {
constructor(
customerId: CustomerId,
address: Address,
mailAddress: MailAddress,
name: Name,
// ...
) {
}
// ドメインロジック
}
そこにStripeから取得したCustomerをマッピングします。クラス名はもっとしっかりしたものだったと思うのですが、かなりうろ覚えなので雰囲気だけ掴んでもらえればと思います。
import { type Customer as StripeCustomer } from 'stripe'
class CustomerAdapter {
public static function createCustomer(stripeCustomer: StripeCustomer): Customer {
return new Customer(
new CustomerId(stripeCustomer.id),
new Address(stripeCustomer.address),
new MailAddress(stripeCustomer.mailAddress),
new Name(stripeCustomer.name),
...
)
}
}
SaaSであったため、特にSubscriptionを活用しました。Stirpeサポートへの問い合わせもSubscriptionの使い方に関するものを一番多く聞いた覚えがあります。リリース後は「初月無料」や「1月に加入すると、年額プランは10%OFF」など、最初になかった機能を後から追加するたびに疑問が湧いたらすぐにサポートの方を頼らせていただいた記憶があります。
Subscriptionのcancel_at_period_endという解約予約フラグや、trial_endという無料のトライアル期間の終わりの日時を設定するものや(この時間が過ぎた瞬間に課金されます)、CouponとかDiscountを活用したのを覚えています。
ビジネスの要求に応じてソフトウェアは変化していくのだ、そのソフトウェアの変化を起こすのがソフトウェアエンジニアなのだとこの時深く実感しました。
StripeのSubscription
StripeのSubscription Objectは本当によくできているため、基本的な構造を解説します。
Subscriptionはその名の通り定額課金を実現するためのオブジェクトです。これは以下のような値を保持しています(重要な部分以外は省略しています)。
{
"id": "sub_1MowQVLkdIwHu7ixeRlqHVzs",
"object": "subscription",
"billing_cycle_anchor": 1679609767, // 支払いサイクルの始まり
"created": 1679609767,
"currency": "usd", // 決済通貨
"current_period_end": 1682288167,
"current_period_start": 1679609767,
"customer": "cus_Na6dX7aXxi11N4", // Customer ID
"items": {
"object": "list",
"data": [
{
"id": "si_Na6dzxczY5fwHx",
"object": "subscription_item",
"created": 1679609768,
"plan": { // Subscription の Plan
"id": "price_1MowQULkdIwHu7ixraBm864M",
"object": "plan",
"active": true,
"amount": 1000,
"billing_scheme": "per_unit",
"created": 1679609766,
"currency": "usd",
"interval": "month", // 月単位の決済
"interval_count": 1,
"product": "prod_Na6dGcTsmU0I4R", // 商品(何に対して課金しているか)
},
"price": { // 金額
"id": "price_1MowQULkdIwHu7ixraBm864M",
"object": "price",
"active": true,
"created": 1679609766,
"currency": "usd",
"product": "prod_Na6dGcTsmU0I4R",
"type": "recurring",
"unit_amount": 1000,
},
"quantity": 1,
"subscription": "sub_1MowQVLkdIwHu7ixeRlqHVzs",
"tax_rates": []
}
],
},
"latest_invoice": "in_1MowQWLkdIwHu7ixuzkSPfKd", // 請求書のID
"start_date": 1679609767,
"status": "active", // サブスクリプションのステータス
"trial_end": null,
"trial_start": null // (もしあれば)無料期間
}
StripeのSubscriptionは、Customer IDを持ちます。これによりどのCustomerのサブスクか辿れます。次にSubscription Itemを持ちます。これはPlanとPriceを持ちます。Planは月額プランや年額プラン、freeプランやBusinessプランのように期間や金額(Price)を自由に設定できるものです。
Stripeを活用する前は、Subscription自体が決済サイクルの期間や金額を持っているのかなと思っていました。しかし、蓋を開けてみるとこのようにしっかりオブジェクトが分かれており、しかもそれぞれで必要十分な、確かにその通りに分けた方が便利だと思えるような責務の分け方でした。
これが全世界で活用されるサービスを提供するグローバル企業のハイレベルなモデリングなのかと驚き、憧れたことを今でも覚えています。自分にとってSubscriptionオブジェクトはその最たるものでした。
フロントエンドの構築、リリースと運用、そして退職へ
フロントエンドでも2019年末という比較的早い時期にNext.jsの導入を決めて、公式ドキュメントを熟読してアプリケーションを構築しました。また、Storybookでデザイナーとコミュニケーションを取ったり、E2Eのみならずコンポーネントテストすらもこのサービスには少し過剰ではないかと考え、フロントエンドでもUnitテストを書くことで、フロントでも90%以上のカバレッジを保ちました。
Renovateを導入して依存パッケージのバージョンアップを2週間ごとに実施するなど、フロントは運用面の体制を整えていました(バックエンドのログの取り方やアラートの通知周りは同僚に教えてもらいました)。
サービスをリリースしてから離職するまで1年間ほど運用しました。リリース後は業務委託のエンジニアがもう一人増え、そこから1年間はずっとペアプロで開発していました。1年間機能追加を続けていってもバグは2,3件しかなかったように思います。それも表示崩れが1件、フロントのデグレが1件のような小さいもので、システムが停止するとか決済が失敗するとか、過剰に金額を取ってしまうというような大きなトラブルは起こりませんでした。
もちろん成功ばかりではありません。追加開発をしながら突発的な差し込み対応をこなしたり、エンジニア不足で社員が運用する管理画面の作成ができなかったり運用を自動化できなかったところもあり、「この時はこの手順でこれを実行するんだ」という秘伝のスクリプトもいくつか生まれてしまいました。しかし、常にペアプロとペアオペをしていたため、作業の属人化はしておらず引き継ぎで困ることはほとんどなかったことはまだ救いでした。
優秀で素晴らしい同僚たちから毎日良い刺激を受けながら、本当に貴重な経験を積ませてもらいました。リリースしてから1年後に退職しましたが、退職の時も初期設計で安定した良いシステムを残せたのではないかと内心誇らしく思っていました。
退職2週間前に受けた同僚からの指摘に愕然とする
とまあ自画自賛が続いたわけですが、失敗談はこれくらい前振りが大きい方が落差があっていいですよね。
自分の退職2週間前にある同僚からテーブル構成について指摘を受けました。「あれ、このテーブル、UserとStripアカウントって別管理してないんですか?」
それを聞いてえっ?と思いました。記憶は曖昧ですが、確かに User テーブルのカラムは auto increment の id と stripe account の id(customer id だったかな) を持っていました。流石に subscription や card の下何桁のようなテーブルは分けていたような気がします。
その人の指摘は、User テーブルと Customer テーブルは分けて、Customer テーブルに User の id を持たせた方が良いというものだったと思います。そうしないと、Stripeのユーザーと自分たちのサービスのユーザーが一体になってしまって良くないとのことです。
それを聞いて自分はあっ、と思いました。大きく複雑なものではないけれど、全力を発揮して良いシステムを作りたい、そしてそれが出来たと思ったのに、よりによって一番変更のしにくいDBでやらかした。しかもそれに気付かされたのが退職の2週間前。退職までに時間があれば自分のスキマ時間を使ってでも絶対対処してリカバリーするという気持ちはあったのに、もうどうしようも出来ない...。
思い返すとユーザーの何かのメタ情報もStripe側に保存していたような記憶があります。このメタ情報もこちら側で持つべきデータでした。Stripeのダッシュボードが便利だったんです...。
失敗談はなかなか表に出てきません。というより自分が全然書いてないだけです。普通にいっぱい失敗してます。ここで懺悔致します。
当時はChatGPTもありません。頼れるのは先輩と書籍、そしてGoogle検索のみです。自信がないときはもっと周りの力を頼りましょう。
前職の同僚たちとは自分の退職後も密に連絡を取っており、この件に関してその後何か言われたことはないです。サービス自体も人気があるようでまだまだ稼働中です。ただ、あの後テーブル構成がどうなったかとても気になるので、次に同僚に会ったときは恐る恐る聞いてみようと思いました。
若手エンジニア時代の失敗、Stripeを使ったSaaS開発の苦い思い出でした。
Happy Coding 🎉