Stripeを利用したSaaS開発の苦い思い出
ソフトウェアエンジニアのための問答ラジオ
パンダとおくだが、Web業界の当たり前を「これって本当にそうだっけ?」と問い直すラジオを配信しています
この記事は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 開発の苦い思い出でした。