404 Not Found

404 Not Found


nginx

プロジェクト設計—ShopMetrics SaaSプラットフォームアーキテクチャ

設計なしのコーディングは, 設計図なしで家を建てるようなものです—3階に達してから基礎が深くないことに気づき, 取り壊してやり直すしかなくなります。

1. 学ぶこと


2. ゼロからSaaSビジネスを構築する実話

(1) 痛み:Bobはeコマース分析プラットフォームを構築したいが, どこから始めればよいかわからない

Bobは50以上のクライアントを持つeコマースデータコンサルティング会社を運営しています。各クライアントのデータは8つのeコマースプラットフォームに散在しており, BobはExcelで手動集計しています—1日4時間レポート作成に費やし, ミスも頻発します。クライアントの1つであるAlice (運用マネージャー)は言いました。「すべての店舗データをリアルタイムで表示できるダッシュボードがあればいいのに。」BobのデータアナリストであるCharlieは言いました。「マルチテナントSaaSプラットフォームが必要ですが, アーキテクチャはどう設計すべきでしょうか?」

(2) 体系的な設計アプローチ

要件分析 → ユーザーストーリー → アーキテクチャ選定 → データモデリング → API設計 → 技術選定の順に進めます。各ステップに明確な成果物があり, コーディング時は設計図に従います。

TEXT
要件分析 → ユーザーストーリー → アーキテクチャ選定 → ER設計 → API設計 → 技術選定 → コーディング開始

(3) 成果

Bobが2週間かけて設計を完了した後, コーディング効率は3倍に向上しました—各要件に明確なインターフェースとデータベース設計があったため, コーディングしながら変更する必要がなくなりました。


3. 要件分析とユーザーストーリー

(1) 3つのユーザーペルソナ

ロール 名前 コアニーズ 典型的なアクション
テナント管理者 Alice 自身の店舗とチームの管理 店舗の作成, メンバーの招待, ダッシュボードの閲覧
プラットフォーム運用 Bob 全テナントと課金の管理 テナントの承認, プランの管理, プラットフォーム統計の閲覧
データアナリスト Charlie eコマースデータの分析とレポート生成 レポートの作成, アラートの設定, データのエクスポート

(2) ユーザーストーリー

TEXT
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) ▶ サンプル:ユーザーストーリーを機能モジュールに分解

PHP
// ユーザーストーリー → 機能モジュールのマッピング
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',
    ],
];

出力:

TEXT
// 実行成功

4. マルチテナントアーキテクチャの選定

(1) 3つのマルチテナンシー戦略

戦略 分離レベル コスト 複雑さ 用途
スタンドアロンデータベース 最高 金融/ヘルスケアのコンプライアンス要件
共有DB + 独立Schema 中規模, ある程度の分離要件
共有DB + 共有Schema 最低 大多数のSaaS, tenant_idによる分離

(2) ShopMetricsの選定決定

100%
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戦略を選択しました:

(1) ▶ サンプル:マルチテナントGlobal Scopeの実装

PHP
// 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);
    }
}

出力:

TEXT
// 実行成功

5. データベースER設計

(1) コアエンティティ関係

100%
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定義

PHP
// 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;
    }
}

出力:

TEXT
// 実行成功

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レスポンス仕様

JSON
{
    "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ルーティング設計

PHP
// 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']);
});

出力:

TEXT
// 実行成功

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) アーキテクチャ概要

100%
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技術選定設定プロファイル

PHP
// 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),
    ],
];

出力:

TEXT
// 実行成功

8. 総合サンプル:ShopMetricsプロジェクトの初期化

PHP
// ============================================
// 総合:プロジェクトスキャフォールドと初期設定
// データベースマイグレーション, モデル, 設定
// ============================================

// 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']);
});

❓ よくある質問

Q 共有Schemaのテナント分離は十分安全ですか?
A ほとんどのSaaSアプリケーションでは十分です。主要な対策:Global Scopeが自動的にtenant_idをフィルタ, API層で現在のユーザーのテナントを検証, raw SQLがScopeをバイパスしないように防止。より高い分離要件 (金融・ヘルスケアなど)の場合は, 別データベース戦略を使用してください。
Q ER設計でポリモーフィック関連はいつ使うべきですか?
A ポリモーフィック関連は「単一エンティティが複数の親エンティティに属する」シナリオに適しています (例:CommentがPostまたはVideoに属する)。ShopMetricsでは, AlertShopまたはTenantに属する可能性があるため, ポリモーフィック関連を検討できます。しかし, 関連タイプが固定されている場合は, 外部キーの方が明確です。
Q APIのバージョンはどのように管理すべきですか?
A 最もシンプルな方法はURLプレフィックスによるバージョニング (/api/v1/)です。v1とv2が共存する場合, v2ルートは別ファイルで処理し, ModelとService層は共有します。メジャーバージョンアップのみ新バージョン番号を割り当て, マイナーな変更は後方互換で実装します。
Q 技術選定でチームの能力を考慮すべきですか?
A 絶対に。最先端の技術スタックでも, チームが不慣れなら逆に遅くなります。チームが最も精通している技術を優先し, 次にエコシステムの成熟度, 最後に技術の先進性を考慮してください。
Q 設計段階はどの程度詳細にすべきですか?
A コアエンティティと関係は100%確定させる必要があります (ER図, APIエンドポイント)。実装の詳細はコーディング中に調整できます。設計の目標:コーディング中に新しいテーブルやインターフェースを「発明」する必要がないこと。
Q マルチテナント環境でのデータ移行はどう扱いますか?
A 共有Schemaの場合はシンプル—tenant_idカラムを追加し, データ移行スクリプトを作成するだけです。単一テナントからマルチテナントへの移行時は, Artisanコマンドでtenant_id値を一括割り当てし, 処理完了後にNOT NULL制約を追加します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ER図に基づいて, ShopMetricsの残り3テーブル (products, order_items, alerts)のスキーマファイルを記述し, 必要なインデックスと外部キーを含めてください。

  2. 応用問題 (⭐⭐):OpenAPI 3.0 YAML形式で, ShopMetricsのOrders API (GET一覧 / GET詳細 / POST同期)のドキュメントを記述し, リクエストパラメータ, レスポンス形式, エラーコードを含めてください。

  3. チャレンジ (⭐⭐⭐):ShopMetricsの完全なマルチテナントミドルウェアソリューションを設計してください—SetTenantContextミドルウェア, BelongsToTenantトレイトを実装し, パスパラメータ (サブドメイン)でテナントを識別します。Tenant AがTenant Bのデータにアクセスできないことを確認するテストを記述してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%