ashineko

MemberHub — Djangoモノリス一つに19のツールを載せたSaaS

カレンダーから請求まで、APIの書き方も認証モデルも課金基盤も一つ。一人で作り、一人で運用しています。

2026 · apiinfra

ルート
~390
NinjaAPI一つ、26モジュール
コンポーネント
~95
約54,000行、バンドラなし
フロントエンドの文字列
1,200
二言語対応、バックエンドに145
手数料の下限
1.5%
5%から階層ごとに下がる

背景

MemberHubは、クラブ、スタジオ、学校、地域団体、小さな店といった会員制の組織のためのマルチテナントSaaSです。顧客の姿はどこも似ています。管理者は一人から数人、会員は数十人から数百人。多くはバイリンガルで、決済も扱い、社内に開発者はいません。彼らに残されている選択肢は、五つ六つの別々のサービスを継ぎ接ぎすることでした。

だからこの製品は、組織が必要なものだけ個別に有効化する19のツールでできています。会員管理、イベント、クラス、予約、カレンダー、請求書、ストア、メール配信、チャット、アンケート、Webフォーム、ページビルダー、ノーコードのデータベース、ファイル、画像編集、レポート、サポート、自動化、共同ホワイトボード。

開発しているのも運用しているのも私一人で、その制約がすべてを決めました。一人で19のツールを成り立たせるには、それらが一つのものの19のバリエーションでなければなりません。APIの書き方も、認証モデルも、テナントの規則も、課金基盤も、デプロイも一つ。専用のサービスやデータベース、独自の認証を必要とする機能は、そもそも出荷できませんでした。

作ったもの

JWT · X-Org-SlugブラウザLit 3 SPA、バンドラなしDjango19ツール、約390ルートPostgreSQLスキーマ一つオブジェクトストレージファイルと画像MQTTブローカーリアルタイムStripeとConnectSendGridメールの送受信
  • ブラウザ — Lit 3 SPA、 バンドラなし
  • Django — 19ツール、 約390ルート
  • PostgreSQL — スキーマ一つ
  • オブジェクト ストレージ — ファイルと画像
  • MQTT ブローカー — リアルタイム
  • Stripe — とConnect
  • SendGrid — メールの送受信
  • ブラウザ → Django (JWT · X-Org-Slug)
  • Django → PostgreSQL
  • Django → オブジェクト ストレージ
  • Django → MQTT ブローカー
  • Django → Stripe
  • Django → SendGrid
アーキテクチャ

バックエンドはDjango 5.2のモノリス一つで、django-ninja経由のJSON APIをPostgreSQL上で提供しています。ツールはすべてDjangoアプリとして同じ四つのファイル構成を繰り返します。トップレベルの各モデルがOrganization外部キーを持つmodels、管理画面向けと公開向けを分けたPydanticスキーマ、ロジックを置くservices、そして薄いルーター。それらは一つのNinjaAPIインスタンスに集約され、26のルートモジュールにおよそ390のルートが並びます。命名規約が効いていて、認証付きルーターは/api/<tool>、公開ルーターは/api/public/<tool>。認証がAPI全体の既定なので、公開エンドポイントは明示的に例外を宣言する必要があり、「このエンドポイントは公開か」がgrepで分かる性質になっています。

フロントエンドはLit 3のSPAで、Webコンポーネントが約95個、JavaScriptはおよそ54,000行。バンドラもnpmもビルドステップもありません。CDNのimport map越しにブラウザネイティブのESモジュールを読み、115行の自作ハッシュルーターと、JavaScriptをネットワーク優先で返すService Workerが支えています。

サイトのツール一覧
サイトのツール一覧

認証は三つの層に分かれ、それぞれ信頼のモデルが違います。管理アプリを使うスタッフにはAuth0のJWTを、キャッシュした鍵セットに対してサーバー側で検証します。テナントはX-Org-Slugヘッダーで、サーバーが解決したうえで認可します。注文を一つ見るためにアカウントを作ったりはしない会員や購入者には、メールのマジックリンク。使い捨てのトークンから、寿命の長い別のセッショントークンを発行する形です。

課金はStripeの実装一つで、二種類のお金の流れを扱います。組織はプラットフォームに月額$49・$149・$299のバンドル(単体ツールのプランは$9.99から)で加入し、その組織の顧客はStripe Connectのダイレクトチャージで組織へ直接支払います。プラットフォーム手数料は、加入している階層が上がるほど5%から1.5%へ下がります。製品全体が英語と日本語の二言語対応で、レコード単位の翻訳オーバーレイとAIによる翻訳まで含みます。

設計上の判断

DRFではなくdjango-ninja。 エンドポイントはただの型付き関数で、Pydanticスキーマがバリデーションとドキュメントを与えてくれます。シリアライザやビューセットの儀式は要りません。そのスキーマ層はそのままフロントエンドとの契約になります。予想していなかった副作用が一つ。django-ninjaの認証はDjangoミドルウェアより後に走ります。これが後に、テナント認可を置ける場所を決めてしまいました。

共有スキーマのマルチテナンシー。 データベースもスキーマも一つ、組織にひもづく行はすべて組織への外部キーを持ちます。スキーマ別・データベース別の分離ならDB側がテナントを隔ててくれますが、マイグレーションと運用が顧客数の分だけ増えます。隔離が完全にアプリケーションコード任せになると承知のうえで、運用の安い道を選びました。これは一度、手痛く跳ね返ってきます。

バンドラを使わない。 Litは約5KBで、ビルドパイプラインはこのプロジェクトに必要のない複雑さでした。見返りはビルド待ちゼロと、collectstaticとキャッシュバスターの更新だけで終わるデプロイ。代償も承知のうえで、CDNへの実行時依存と、コンパイラの保証ではなく人間の規律に頼るキャッシュ無効化です。

デスティネーションチャージではなくダイレクトチャージ。 ダイレクトチャージでは、決済も顧客レコードも返金も係争も、すべて組織自身のStripeアカウントに載ります。組織は自分の顧客との関係を手放さずに済み、私は彼らのお金を預かりません。代わりに、組織がStripeのオンボーディングを終えるまでは、会費も注文も請求書も決済できません。

上限は分岐ではなくデータ。 クォータの仕組みは、ツールごとの上限を無料版とPro版で持つ二つの辞書と、プランごとの上書き、そして各作成エンドポイントでの一回のチェックだけです。したがって適用は作成時のみで、しかもオプトイン。プランを下げた組織は既存の12個のボードを持ったまま、13個目が作れなくなります。商売の上ではそれで十分ですし、新しいツールに上限を足すのは辞書に二行とコードに二行で済みます。

うまくいかなかったこと

皆が信じていたヘッダー。 ミドルウェアがX-Org-Slugを組織に解決してリクエストに載せ、エンドポイントはそれを読んでクエリを絞る。どの部品もそれぞれの責任を果たしているように見えて、誰も認可をしていませんでした。ある組織の認証済みユーザーが別の組織のスラッグを送れば、その組織の注文も会員も請求もファイルも読み書きできる。ヘッダー越しのIDORが、約200のエンドポイントに潜んでいたわけです。利用者から見える症状は、何もありませんでした。

分かりやすい修正は使えませんでした。ミドルウェアはdjango-ninjaの認証より先に走るので、その時点では認証済みユーザーが存在しないからです。そこで規則はビュー層のヘルパー一つに置きました。ヘッダーが無い・未知なら400、メンバーでなければ常に403。判定はリクエスト上にメモ化し、メンバーシップの照会は意図的に読み取り専用にしています。既存のユーザーヘルパーは呼ぶたびに最終ログイン時刻を書き込むからで、読み取りの経路が書き込んではいけません。置き換えは対象を絞らず徹底しました。19のツールのルートモジュールが一バイト違わぬコピペのヘルパーを抱えていて、そのすべてが一行の委譲になりました。修正より先に、落ちる回帰テストをコミットしています。マージの時点でテストは173件グリーンでした。

いちばん重要な発見は、実装ではなく機能全体のレビューから出ました。APIのincludeより前に登録された三つの素のDjangoビューが、保護されたはずのルートを覆い隠していて、リクエスト属性へのgrepにはまったく映らなかったのです。規約に沿った点検は、規約を無視したコードに対しては盲目になります。

片方向にしか効かないJOIN。 ノーコードのデータベースツールで、二つのシートの結合が一方向では動き、逆方向では静かに半分だけ失敗していました。結合先の列がただ欠けるだけで、エラーは出ません。クエリ実行のサービスを直して直接呼ぶと正しい結果が返るのに、動いているアプリは壊れた行を返し続けました。この矛盾は、死んだコードを編集しているという合図です。実際そのとおりで、エンジンはほぼ同一のコピーが三つあり、生きていたのはAPIのルートを覆い隠す素のDjangoビューでした。

バグそのものは同語反復でした。方向の判定が「外部キーの列は結合元のシートのものか」を尋ねていたのですが、結合の保存のされ方からしてそれは定義上いつでも真で、エンジンは常に順方向を選び、何にも一致しなかったのです。正しい問いは、誰が列を持っているかではなく、どちらのシートのデータがすでに結果に入っているか。いまは三つのコピーすべてがそれを追跡しています。そして数か月後、同じ構造が認可の見直しであやうく見落とされるところでした。

結果

19のツールがすべて無料枠付きで動いていて、それを一つのDjangoプロセスとして私自身が運用しています。製品は完全な二言語対応で、フロントエンドのキーは1,200以上、バックエンドの文字列は145以上。認可のハードニングは、173件のテストがグリーンの状態でマージしました。

代償ははっきりしていて、いまも開いたままです。タスクキューが無いので、メールの再送はバックグラウンドジョブではなくデータベースの状態として持っています。あるツールのインポート時エラーはAPI全体を落とします。URLマップを一枚で読めることの代価です。エンドポイント単位のロール制御は先送りのまま。そして無料から$49への段差は、自分で認識しているだけで、まだ均せていない価格の崖です。