プロジェクト設計—ShopMetrics SaaSプラットフォームアーキテクチャ
設計なしのコーディングは, 設計図なしで家を建てるようなものです—3階に達してから基礎が深くないことに気づき, 取り壊してやり直すしかなくなります。
1. 学ぶこと
- 要件分析とユーザーストーリー:Alice (テナント管理者)/Bob (プラットフォーム運用)/Charlie (データアナリスト)
- マルチテナントアーキテクチャの選択:共有データベース vs. 専用データベース戦略
- データベースER設計:tenants/users/plans/subscriptions/shops/orders/analytics
- APIドキュメント設計:OpenAPI 3.0仕様とPostman Collections
- 技術選定:Laravel 11 / MySQL / Redis / S3 / WebSocket
2. ゼロからSaaSビジネスを構築する実話
(1) 痛み:Bobはeコマース分析プラットフォームを構築したいが, どこから始めればよいかわからない
Bobは50以上のクライアントを持つeコマースデータコンサルティング会社を運営しています。各クライアントのデータは8つのeコマースプラットフォームに散在しており, BobはExcelで手動集計しています—1日4時間レポート作成に費やし, ミスも頻発します。クライアントの1つであるAlice (運用マネージャー)は言いました。「すべての店舗データをリアルタイムで表示できるダッシュボードがあればいいのに。」BobのデータアナリストであるCharlieは言いました。「マルチテナントSaaSプラットフォームが必要ですが, アーキテクチャはどう設計すべきでしょうか?」
(2) 体系的な設計アプローチ
要件分析 → ユーザーストーリー → アーキテクチャ選定 → データモデリング → API設計 → 技術選定の順に進めます。各ステップに明確な成果物があり, コーディング時は設計図に従います。
要件分析 → ユーザーストーリー → アーキテクチャ選定 → ER設計 → API設計 → 技術選定 → コーディング開始
(3) 成果
Bobが2週間かけて設計を完了した後, コーディング効率は3倍に向上しました—各要件に明確なインターフェースとデータベース設計があったため, コーディングしながら変更する必要がなくなりました。
3. 要件分析とユーザーストーリー
(1) 3つのユーザーペルソナ
| ロール | 名前 | コアニーズ | 典型的なアクション |
|---|---|---|---|
| テナント管理者 | Alice | 自身の店舗とチームの管理 | 店舗の作成, メンバーの招待, ダッシュボードの閲覧 |
| プラットフォーム運用 | Bob | 全テナントと課金の管理 | テナントの承認, プランの管理, プラットフォーム統計の閲覧 |
| データアナリスト | Charlie | eコマースデータの分析とレポート生成 | レポートの作成, アラートの設定, データのエクスポート |
(2) ユーザーストーリー
Alice (テナント管理者)として, 私は以下を望みます:
- US-01:新しいeコマースショップを追加して指標を追跡したい
- US-02:チームメンバーを招待してCharlieがアナリティクスにアクセスできるようにしたい
- US-03:全店舗の売上/注文を表示するダッシュボードを見たい
- US-04:店舗数に合ったプランにサブスクライブしたい
- US-05:レポートをCSV/Excel形式でエクスポートしたい
- US-06:売上が閾値を下回った時のアラートを設定したい
Bob (プラットフォーム運用者)として, 私は以下を望みます:
- US-07:テナントアカウントを管理したい (作成/一時停止/削除)
- US-08:異なる機能制限を持つサブスクリプションプランを定義したい
- US-09:プラットフォーム全体の指標を閲覧したい (総テナント数/MRR/アクティベーション)
- US-10:Stripeでサブスクリプション支払いを処理したい
- US-11:サブスクリプション期限切れのテナントに通知を送りたい
Charlie (データアナリスト)として, 私は以下を望みます:
- US-12:日付範囲とフィルターでカスタムアナリティクスレポートを作成したい
- US-13:店舗のパフォーマンスを並べて比較したい
- US-14:自動レポート生成をスケジュールしたい (日次/週次/月次)
- US-15:重要なイベント発生時にリアルタイム通知を受け取りたい
(1) ▶ サンプル:ユーザーストーリーを機能モジュールに分解
// ユーザーストーリー → 機能モジュールのマッピング
return [
'Tenant Management' => [
'US-07: Manage tenant accounts',
'US-01: Add e-commerce shops',
'US-02: Invite team members',
],
'Subscription & Billing' => [
'US-04: Subscribe to a plan',
'US-08: Define subscription plans',
'US-10: Process Stripe payments',
'US-11: Expiring notifications',
],
'Analytics & Reports' => [
'US-03: View revenue dashboard',
'US-05: Export reports',
'US-12: Build custom reports',
'US-13: Compare shop performance',
'US-14: Schedule report generation',
],
'Alerts & Notifications' => [
'US-06: Revenue drop alerts',
'US-15: Real-time event notifications',
],
'Platform Administration' => [
'US-09: Platform-wide metrics',
],
];
出力:
// 実行成功
4. マルチテナントアーキテクチャの選定
(1) 3つのマルチテナンシー戦略
| 戦略 | 分離レベル | コスト | 複雑さ | 用途 |
|---|---|---|---|---|
| スタンドアロンデータベース | 最高 | 高 | 中 | 金融/ヘルスケアのコンプライアンス要件 |
| 共有DB + 独立Schema | 中 | 中 | 中 | 中規模, ある程度の分離要件 |
| 共有DB + 共有Schema | 最低 | 低 | 低 | 大多数のSaaS, tenant_idによる分離 |
(2) ShopMetricsの選定決定
flowchart TD
A[マルチテナント戦略] --> B{データ分離要件?}
B -->|厳格なコンプライアンス| C[テナントごとに別DB]
B -->|標準的なSaaS| D{テナント数?}
D -->|< 100| E[共有DB + 別Schema]
D -->|> 100| F[共有DB + 共有Schema]
F --> G[全行にtenant_id]
G --> H[Global Scopeで自動フィルタ]
H --> I[ShopMetricsの選択 ✅]
ShopMetricsは共有データベース + 共有Schema戦略を選択しました:
- 500以上のテナントを想定。コスト重視
- 金融コンプライアンス要件なし。tenant_idによる分離で十分
- LaravelのGlobal Scopeで自動フィルタリングを実装
(1) ▶ サンプル:マルチテナントGlobal Scopeの実装
// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$tenantId = Tenant::current()?->id;
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
});
static::creating(function (Model $model) {
$tenantId = Tenant::current()?->id;
if ($tenantId && ! $model->isDirty('tenant_id')) {
$model->tenant_id = $tenantId;
}
});
}
}
// app/Models/Tenant.php
class Tenant extends Model
{
protected static Tenant $currentTenant;
public static function setCurrent(self $tenant): void
{
static::$currentTenant = $tenant;
}
public static function current(): ?self
{
return static::$currentTenant ?? null;
}
}
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
Tenant::setCurrent($user->tenant);
}
return $next($request);
}
}
出力:
// 実行成功
5. データベースER設計
(1) コアエンティティ関係
erDiagram
TENANT ||--o{ USER : "has many"
TENANT ||--o{ SHOP : "has many"
TENANT ||--|| SUBSCRIPTION : "has one"
PLAN ||--o{ SUBSCRIPTION : "subscribed to"
SHOP ||--o{ ORDER : "has many"
SHOP ||--o{ PRODUCT : "has many"
ORDER ||--|{ ORDER_ITEM : "contains"
ORDER_ITEM }o--|| PRODUCT : "references"
USER ||--o{ REPORT : "creates"
TENANT ||--o{ ALERT : "configures"
TENANT {
bigint id PK
string name
string slug UK
string domain
string status
timestamp created_at
}
USER {
bigint id PK
bigint tenant_id FK
string name
string email UK
string role
timestamp created_at
}
PLAN {
bigint id PK
string name
string slug UK
int shop_limit
int user_limit
int price_cents
string stripe_price_id
}
SUBSCRIPTION {
bigint id PK
bigint tenant_id FK
bigint plan_id FK
string stripe_id
string status
timestamp trial_ends_at
timestamp ends_at
}
SHOP {
bigint id PK
bigint tenant_id FK
string name
string platform
string external_id
string status
}
ORDER {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string external_id
string customer_email
int total_cents
string status
timestamp ordered_at
}
PRODUCT {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string name
string sku
int price_cents
}
ORDER_ITEM {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
int unit_price_cents
}
REPORT {
bigint id PK
bigint tenant_id FK
bigint user_id FK
string type
string format
string status
string storage_path
timestamp generated_at
}
ALERT {
bigint id PK
bigint tenant_id FK
string type
string condition
string channel
boolean is_active
}
(2) 主要な設計決定
| 決定 | 選択 | 理由 |
|---|---|---|
| 金額の保存 | int price_cents |
浮動小数点の精度問題を回避 |
| テナント分離 | tenant_id on every table |
共有Schema戦略 |
| サブスクリプションステータス | Stripe Webhook同期 | 単一データソース (Stripe) |
| 外部ID | external_id UK per tenant |
異なるeコマースプラットフォームのID形式 |
| ソフトデリート | User/Tenantのみ | 注文/商品は削除せず, ステータス変更のみ |
(1) ▶ サンプル:ShopMetricsコアModel定義
// app/Models/Tenant.php
class Tenant extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name', 'slug', 'domain', 'status'];
protected static function booted(): void
{
static::creating(function (self $tenant) {
$tenant->slug ??= Str::slug($tenant->name);
$tenant->domain ??= "{$tenant->slug}.shopmetrics.io";
});
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->ofMany([], fn ($q) => $q->orderByDesc('created_at'));
}
public function alerts(): HasMany
{
return $this->hasMany(Alert::class);
}
public function isActive(): bool
{
return $this->status === 'active' &&
$this->subscription?->stripe_status === 'active';
}
public function canAddShop(): bool
{
$limit = $this->subscription?->plan->shop_limit ?? 0;
return $this->shops()->count() < $limit;
}
}
// app/Models/Order.php
class Order extends Model
{
use BelongsToTenant, HasFactory;
protected $fillable = [
'tenant_id', 'shop_id', 'external_id',
'customer_email', 'total_cents', 'status', 'ordered_at',
];
protected $casts = [
'total_cents' => 'integer',
'ordered_at' => 'datetime',
];
public function shop(): BelongsTo
{
return $this->belongsTo(Shop::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function getTotalDollarsAttribute(): float
{
return $this->total_cents / 100;
}
}
出力:
// 実行成功
6. API設計
(1) APIバージョニングとリソース計画
| リソース | プレフィックス | メソッド | 説明 |
|---|---|---|---|
| Auth | /api/v1/auth |
POST login/logout/refresh | 認証 |
| Tenants | /api/v1/tenants |
GET/PATCH current | テナント情報 |
| Shops | /api/v1/shops |
CRUD | 店舗管理 |
| Orders | /api/v1/shops/{id}/orders |
GET/POST | 注文照会 |
| Products | /api/v1/shops/{id}/products |
GET/POST | 商品管理 |
| Dashboard | /api/v1/dashboard |
GET overview/top/revenue | ダッシュボード |
| Reports | /api/v1/reports |
POST generate/GET status | レポート |
| Alerts | /api/v1/alerts |
CRUD | アラート設定 |
| Plans | /api/v1/plans |
GET list | プラン一覧 |
| Subscriptions | /api/v1/subscriptions |
POST/DELETE | サブスクリプション管理 |
(2) APIレスポンス仕様
{
"data": {
"id": 1,
"type": "shop",
"attributes": {
"name": "Alice's Amazon Store",
"platform": "amazon",
"status": "active",
"created_at": "2024-01-15T10:00:00Z"
},
"relationships": {
"tenant": { "data": { "id": 1, "type": "tenant" } }
}
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2024-03-15T14:30:00Z"
}
}
(1) ▶ サンプル:ShopMetrics APIルーティング設計
// routes/api.php
Route::prefix('v1')->group(function () {
// パブリック:認証
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
// 認証済みルート
Route::middleware(['auth:sanctum', 'set-tenant-context'])->group(function () {
// 認証
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
// テナント (現在のユーザーのテナント)
Route::get('tenant', [TenantController::class, 'show']);
Route::patch('tenant', [TenantController::class, 'update']);
// 店舗
Route::apiResource('shops', ShopController::class);
// 店舗配下のネストされたリソース
Route::prefix('shops/{shop}')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
Route::apiResource('products', ProductController::class);
});
// ダッシュボード
Route::prefix('dashboard')->group(function () {
Route::get('overview', [DashboardController::class, 'overview']);
Route::get('top-products', [DashboardController::class, 'topProducts']);
Route::get('revenue', [DashboardController::class, 'revenueChart']);
});
// レポート
Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
Route::post('reports/{report}/download', [ReportController::class, 'download']);
// アラート
Route::apiResource('alerts', AlertController::class);
// サブスクリプション
Route::get('plans', [PlanController::class, 'index']);
Route::post('subscriptions', [SubscriptionController::class, 'store']);
Route::get('subscription', [SubscriptionController::class, 'show']);
Route::delete('subscription', [SubscriptionController::class, 'cancel']);
// チーム管理 (tenant_ownerのみ)
Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
Route::get('members', [TeamController::class, 'index']);
Route::post('invite', [TeamController::class, 'invite']);
Route::delete('members/{user}', [TeamController::class, 'remove']);
});
});
// Stripe Webhooks (認証なし)
Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});
出力:
// 実行成功
7. 技術選定の決定
(1) 技術スタックの比較と選定
| レイヤー | 選択肢 | 選定 | 理由 |
|---|---|---|---|
| フレームワーク | Laravel/Symfony/Lumen | Laravel 11 | 機能豊富, エコシステム充実, SaaS開発が迅速 |
| データベース | MySQL/PostgreSQL | MySQL 8.0 | チームに馴染みがある, Laravelのデフォルト, JSON対応 |
| キャッシュ | Redis/Memcached | Redis 7 | キャッシュ, セッション, キュー, ブロードキャストを統一 |
| ストレージ | S3/MinIO/Local | S3 | スケーラブル, CDN統合, 開発にはMinIO |
| キュー | Redis/Database/SQS | Redis | 低レイテンシ, 開発と本番の統一 |
| 認証 | Sanctum/Passport | Sanctum | SPA + モバイルのトークン認証で十分 |
| リアルタイム | Pusher/Soketi | Soketi | Pusherプロトコル互換, 自己ホストで無料 |
| フロントエンド | Blade/Inertia/Livewire | Inertia + Vue | SPA体験 + サーバーサイドルーティング |
(2) アーキテクチャ概要
flowchart TB
subgraph Client["クライアント層"]
WEB[Web SPA - Inertia/Vue]
MOBILE[モバイルアプリ]
API_CLIENT[APIコンシューマー]
end
subgraph LB["ロードバランサー - Nginx"]
direction LR
direction TB
end
subgraph App["アプリケーション層"]
API[APIサーバー - Laravel]
WS[WebSocket - Soketi]
end
subgraph Worker["バックグラウンド処理"]
QUEUE[キューワーカー]
SCHED[スケジューラー]
end
subgraph Data["データ層"]
DB[(MySQL 8.0)]
CACHE[(Redis 7)]
S3[S3/MinIO]
end
subgraph External["外部サービス"]
STRIPE[Stripe API]
SHOPS[eコマースAPI]
MAIL[メールサービス]
end
WEB --> LB
MOBILE --> LB
API_CLIENT --> LB
LB --> API
WEB --> WS
API --> DB
API --> CACHE
API --> S3
API --> STRIPE
API --> SHOPS
API --> QUEUE
API --> WS
QUEUE --> DB
QUEUE --> CACHE
QUEUE --> S3
QUEUE --> MAIL
QUEUE --> STRIPE
SCHED --> QUEUE
(1) ▶ サンプル:ShopMetrics技術選定設定プロファイル
// config/shopmetrics.php
return [
'tenant' => [
'strategy' => env('TENANT_STRATEGY', 'shared_schema'),
'default_plan' => env('DEFAULT_PLAN_SLUG', 'starter'),
'trial_days' => env('TRIAL_DAYS', 14),
],
'limits' => [
'starter' => ['shops' => 3, 'users' => 5, 'reports_per_month' => 10],
'pro' => ['shops' => 25, 'users' => 25, 'reports_per_month' => 100],
'enterprise' => ['shops' => -1, 'users' => -1, 'reports_per_month' => -1],
],
'reports' => [
'max_date_range_days' => 365,
'formats' => ['csv', 'xlsx', 'json'],
'storage_disk' => env('REPORT_DISK', 's3'),
'retention_days' => env('REPORT_RETENTION_DAYS', 90),
],
'sync' => [
'platforms' => ['amazon', 'shopify', 'ebay', 'etsy'],
'sync_interval_minutes' => env('SYNC_INTERVAL', 60),
'batch_size' => env('SYNC_BATCH_SIZE', 500),
],
'alerts' => [
'channels' => ['email', 'slack', 'webhook'],
'check_interval_minutes' => env('ALERT_CHECK_INTERVAL', 15),
],
];
出力:
// 実行成功
8. 総合サンプル:ShopMetricsプロジェクトの初期化
// ============================================
// 総合:プロジェクトスキャフォールドと初期設定
// データベースマイグレーション, モデル, 設定
// ============================================
// database/migrations/2024_01_01_000001_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->unique();
$table->string('status')->default('active'); // active/suspended/cancelled
$table->softDeletes();
$table->timestamps();
$table->index('status');
});
// database/migrations/2024_01_01_000002_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->integer('shop_limit')->default(3);
$table->integer('user_limit')->default(5);
$table->integer('price_cents')->default(0);
$table->string('stripe_price_id')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// database/migrations/2024_01_01_000003_create_subscriptions_table.php
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained();
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'stripe_status']);
});
// database/migrations/2024_01_01_000004_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('platform'); // amazon/shopify/ebay/etsy
$table->string('external_id');
$table->string('status')->default('active');
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'platform', 'external_id']);
$table->index(['tenant_id', 'status']);
});
// database/migrations/2024_01_01_000005_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('shop_id')->constrained()->cascadeOnDelete();
$table->string('external_id');
$table->string('customer_email')->nullable();
$table->unsignedBigInteger('total_cents')->default(0);
$table->string('status')->default('pending');
$table->timestamp('ordered_at');
$table->timestamps();
$table->unique(['tenant_id', 'external_id']);
$table->index(['tenant_id', 'shop_id', 'status', 'ordered_at']);
$table->index(['tenant_id', 'ordered_at']);
});
❓ よくある質問
tenant_idをフィルタ, API層で現在のユーザーのテナントを検証, raw SQLがScopeをバイパスしないように防止。より高い分離要件 (金融・ヘルスケアなど)の場合は, 別データベース戦略を使用してください。AlertがShopまたはTenantに属する可能性があるため, ポリモーフィック関連を検討できます。しかし, 関連タイプが固定されている場合は, 外部キーの方が明確です。/api/v1/)です。v1とv2が共存する場合, v2ルートは別ファイルで処理し, ModelとService層は共有します。メジャーバージョンアップのみ新バージョン番号を割り当て, マイナーな変更は後方互換で実装します。tenant_idカラムを追加し, データ移行スクリプトを作成するだけです。単一テナントからマルチテナントへの移行時は, Artisanコマンドでtenant_id値を一括割り当てし, 処理完了後にNOT NULL制約を追加します。📖 まとめ
- ユーザーストーリーは3つのペルソナ (Alice, Bob, Charlie)に基づいて機能モジュールに分解します
- 共有データベース + 共有Schemaは大多数のSaaSアプリケーションに最適な選択です
- コアER設計:tenant_idによる分離, 金額はセント単位, 外部IDにユニーク制約
- API設計はRESTfulアーキテクチャに従い, バージョンプレフィックスを使用し, 統一レスポンス形式を採用します
- 技術選定ではチームの能力とエコシステムの成熟度を優先します
- プロジェクト初期化はマイグレーションファイルから始め, データモデルの基盤を築きます
📝 練習問題
-
基本問題 (⭐):ER図に基づいて, ShopMetricsの残り3テーブル (products, order_items, alerts)のスキーマファイルを記述し, 必要なインデックスと外部キーを含めてください。
-
応用問題 (⭐⭐):OpenAPI 3.0 YAML形式で, ShopMetricsのOrders API (GET一覧 / GET詳細 / POST同期)のドキュメントを記述し, リクエストパラメータ, レスポンス形式, エラーコードを含めてください。
-
チャレンジ (⭐⭐⭐):ShopMetricsの完全なマルチテナントミドルウェアソリューションを設計してください—
SetTenantContextミドルウェア,BelongsToTenantトレイトを実装し, パスパラメータ (サブドメイン)でテナントを識別します。Tenant AがTenant Bのデータにアクセスできないことを確認するテストを記述してください。



