# 経済産業大臣賞受賞プロダクトで学んだB2B SaaS開発の7つの教訓

> 木材流通業界のDXを実現したB2BサブスクリプションSaaSの開発を通じて学んだ、技術選定、アーキテクチャ設計、セキュリティ、スケーラビリティの実践的教訓を公開します。TypeScript + Python + AWS Terraform による実装事例。

- 公開日: 2025-01-09
- 著者: 友田 陽大
- タグ: B2B SaaS, 経済産業大臣賞, 技術選定, アーキテクチャ設計, AWS, Terraform, レガシー産業DX
- URL: https://tomodahinata.com/blog/b2b-saas-lessons-from-award-winning-product
- カテゴリ: B2B SaaS・DX戦略
- 総合ガイド: https://tomodahinata.com/blog/award-winning-b2b-saas-architecture-deep-dive

## 要点

- 技術選定は業界特有の複雑さから逆算する。8種のユーザー属性はAWS Cognitoのカスタム属性で表現した
- セキュリティは認証・認可・データの3層で多層防御し、競合データ保護を会社IDのRow-Level Securityで担保する
- バリデーションはzod（フロント）とMarshmallow（バック）で二重化し、不正データの混入を構造で防ぐ
- 重い処理は性質で使い分ける。帳票生成はThreadPoolExecutorでスレッド並列、Excel取り込みはイベント駆動Lambdaへ分離
- 決済はStripe Connectでサーバ側金額解決＋冪等キー＋Webhook重複排除＋アウトボックスまで実装する

---

# 経済産業大臣賞受賞プロダクトで学んだB2B SaaS開発の7つの教訓

## はじめに：電話・FAX・Excelからの脱却

私は2年前、木材流通業界という「極めてアナログな業界」のDXに挑戦しました。電話、メール、FAX、Excelでの受発注が主流で、在庫情報はExcelで管理され、FAXでの発注は記録が残らず、電話での確認作業に毎日数時間を要する——このような状況を、**Web上で一元管理するB2BサブスクリプションSaaS**として構築しました。

その結果、このプロダクトは**経済産業大臣賞を受賞**しました。

本記事では、この開発を通じて学んだ7つの教訓を、技術選定、アーキテクチャ設計、セキュリティ、スケーラビリティの観点から公開します。特に、**toB向けSaaS開発を検討されている方**にとって、実践的な示唆を提供できると考えています。

---

## 教訓1: 技術選定は「業界の複雑さ」から逆算せよ

### 課題：8種類のユーザー属性と複雑な商流

木材流通業界には、「林業」「市場」「製材所」「プレカット」「工務店」「メーカー」「問屋」「その他」という8種類のユーザー属性が存在します。各属性で実行可能な機能や閲覧できる情報が異なるため、**厳格な認証・認可が必須**でした。

### 決断：AWS Cognito + カスタムロジック

当初、Firebase Authenticationも検討しましたが、以下の理由でAWS Cognitoを選択：

```typescript
// AWS Cognito User Poolsのカスタム属性で8種類のユーザー属性を管理
{
  "custom:user_type": "lumber_mill",  // 製材所
  "custom:permissions": "create_order,view_inventory",
  "custom:company_id": "company-123"
}
```

**選定理由:**
- **カスタム属性の柔軟性**: 8種類のユーザー属性ごとに異なる権限を定義可能
- **AWSエコスystem**: ECS、RDS、Lambda との統合が容易
- **スケーラビリティ**: MAU課金で、初期コスト抑制

**教訓:** 技術選定は「業界特有の複雑さ」を最優先に考慮すべき。汎用的なソリューションではなく、業界のドメイン知識を反映できる技術を選ぶ。

---

## 教訓2: セキュリティは「ページ単位・API単位」で設計せよ

### 課題：データ漏洩リスクの最小化

B2B SaaSでは、競合他社のデータが同一システム内に存在します。「製材所Aが市場Bの在庫情報を閲覧できてしまう」という事態は、**ビジネス上の致命的なリスク**です。

### 実装：多層防御アーキテクチャ

```python
# Flask + AWS Cognito統合による認証・認可
from functools import wraps
from flask import request, jsonify

def require_user_type(*allowed_types):
    """ユーザー属性ごとのアクセス制御デコレータ"""
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            user_type = get_user_type_from_token(request.headers.get('Authorization'))

            if user_type not in allowed_types:
                return jsonify({'error': 'Forbidden'}), 403

            return f(*args, **kwargs)
        return decorated_function
    return decorator

@app.route('/api/inventory', methods=['GET'])
@require_user_type('lumber_mill', 'market', 'manufacturer')
def get_inventory():
    """在庫情報取得（製材所・市場・メーカーのみアクセス可）"""
    user_company_id = get_company_id_from_token()
    # 自社の在庫のみ取得（他社データは取得不可）
    inventory = Inventory.query.filter_by(company_id=user_company_id).all()
    return jsonify([inv.to_dict() for inv in inventory])
```

**セキュリティ対策の3層構造:**
1. **認証層**: AWS Cognito JWTトークン検証
2. **認可層**: ユーザー属性ごとのAPI単位アクセス制御
3. **データ層**: 会社IDによるRow-Level Security（RLS）

**教訓:** セキュリティは「認証すればOK」ではなく、ページ単位・API単位・データ単位で多層的に設計する。特にB2B SaaSでは、競合他社のデータ保護が最優先。

---

## 教訓3: バリデーションは「フロント・バック両方」で徹底せよ

### 課題：不正データの混入防止

B2B SaaSでは、データの正確性が企業間取引の信頼性に直結します。不正な価格、在庫数、発注数が混入すると、**ビジネス全体が破綻**します。

### 実装：zod + Marshmallow の二重バリデーション

**フロントエンド（TypeScript + zod）:**
```typescript
import { z } from "zod";

const OrderSchema = z.object({
  product_id: z.string().uuid(),
  quantity: z.number().int().positive().max(10000),
  unit_price: z.number().positive().max(1000000),
  delivery_date: z.string().datetime(),
});

type Order = z.infer<typeof OrderSchema>;

// フォーム送信前にバリデーション
const handleSubmit = (data: Order) => {
  const result = OrderSchema.safeParse(data);
  if (!result.success) {
    alert(result.error.errors[0].message);
    return;
  }
  // API送信
};
```

**バックエンド（Python + Marshmallow）:**
```python
from marshmallow import Schema, fields, validate, ValidationError

class OrderSchema(Schema):
    product_id = fields.UUID(required=True)
    quantity = fields.Integer(required=True, validate=validate.Range(min=1, max=10000))
    unit_price = fields.Decimal(required=True, validate=validate.Range(min=0, max=1000000))
    delivery_date = fields.DateTime(required=True)

@app.route('/api/orders', methods=['POST'])
def create_order():
    schema = OrderSchema()
    try:
        data = schema.load(request.json)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400

    # DBに保存
    order = Order(**data)
    db.session.add(order)
    db.session.commit()
    return jsonify(order.to_dict()), 201
```

**教訓:** フロントエンドのバリデーションは「UX向上」、バックエンドのバリデーションは「セキュリティとデータ整合性」。両方を徹底することで、不正データの混入を完全に防ぐ。

---

## 教訓4: IaC（Infrastructure as Code）は「初日から」導入せよ

### 課題：複雑なAWS環境の再現性確保

B2B SaaSでは、VPC、ECS、RDS、Cognito、ALB、CloudFront、SESなど、**広範なAWSサービス**を組み合わせます。手動構築では再現性がなく、障害時の復旧が困難です。

### 実装：Terraform による完全自動化

```hcl
# VPC設定
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.project_name}-vpc"
    Environment = var.environment
  }
}

# ECS on Fargate
resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"
}

resource "aws_ecs_service" "app" {
  name            = "${var.project_name}-app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.environment == "production" ? 3 : 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = aws_subnet.private[*].id
    security_groups = [aws_security_group.ecs_tasks.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 8080
  }
}

# AWS Cognito User Pool
resource "aws_cognito_user_pool" "main" {
  name = "${var.project_name}-user-pool"

  # 8種類のユーザー属性をカスタム属性で定義
  schema {
    name                = "user_type"
    attribute_data_type = "String"
    mutable             = true
  }

  schema {
    name                = "company_id"
    attribute_data_type = "String"
    mutable             = false
  }
}
```

**IaCのメリット:**
- **再現性**: `terraform apply` 一発でインフラ構築
- **バージョン管理**: Git管理で変更履歴を追跡
- **環境分離**: `terraform workspace` で dev/staging/production を分離
- **災害復旧**: インフラ全体を数分で復旧可能

**教訓:** IaCは「後で導入」ではなく、「初日から」導入すべき。手動構築→IaC移行は、技術的負債が膨大になる。

---

## 教訓5: パフォーマンスは「非同期処理」で最適化せよ

### 課題：重い処理によるUX低下

B2B SaaSでは、「見積書・納品書・請求書」のPDF/Excel生成、「既存ExcelのDB化」といった**重い処理**が頻繁に発生します。同期処理では、ユーザーが数十秒待つことになり、UXが著しく低下します。

### 実装：ThreadPoolExecutor によるスレッド並列 ＋ イベント駆動 Lambda

重い処理には 2 つの性質があり、それぞれ別の手段を選びました。**帳票生成（注文書・納品書・請求書）は CPU/IO バウンドで同期的に完了させたい**ので `ThreadPoolExecutor` でスレッド並列に。**Excel→DB 取り込みは時間が読めない**ので、S3 アップロードをトリガーにした Lambda へ完全にオフロードします。

```python
from concurrent.futures import ThreadPoolExecutor, as_completed
from sqlalchemy.orm import selectinload

def parallel_create_documents(app, order_id: str) -> None:
    """注文書・納品書・請求書を同時生成する。"""
    tasks = [create_order_form, create_delivery_note, create_invoice]

    def run(task):
        with app.app_context():                       # スレッドごとにコンテキストを張る
            doc = (
                Document.query
                .options(selectinload(Document.lines))  # N+1 を選択ロードで回避
                .filter_by(order_id=order_id)
                .with_for_update()                       # 行ロックで競合生成を防ぐ
                .one()
            )
            return task(doc)                             # openpyxl で Excel → LibreOffice で PDF

    with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
        futures = [pool.submit(run, t) for t in tasks]
        for f in as_completed(futures):
            f.result()  # 最初の例外を伝播させる
```

**設計のポイント:**
- **帳票は 3 種を並列生成**。各スレッドで Flask のアプリコンテキストを張り直し、`selectinload` で N+1 を防ぎ、`with_for_update` で競合を防ぐ。
- **Excel 取り込みは Lambda へ分離**。`openpyxl` を `read_only=True` で開き `execute_values` で一括 INSERT。50MB 上限と数式インジェクション無害化（CWE-1236）も実装。
- **フロントは指数バックオフ＋Page Visibility 対応のポーリング**で完了を待つので、重い処理中でも管理画面は固まらず、背面タブで API を浪費しない。

**教訓:** 「非同期化」は手段が複数ある。同期的に完了させたい CPU/IO バウンドな処理はスレッド並列、時間が読めない処理はイベント駆動でサーバ本体から切り離す——性質で使い分けるのが本番品質。

---

## 教訓6: 決済は「Stripe Connect」で冪等に組め

### 課題：継続課金 ＋ 取引精算 ＋ 重複配信

このプロダクトの収益モデルは月額サブスクリプションですが、それだけではありません。企業同士が取引するマーケットプレイスなので、**継続課金に加えて取引ごとの精算**が発生します。そこで単なる Stripe ではなく **Stripe Connect** を採用しました。決済では `二重課金` `取りこぼし` `金額改ざん` を絶対に起こさないことが要件です。

### 実装：Stripe Connect（サーバ側金額解決＋冪等キー）

```python
import stripe, hashlib

stripe.api_key = os.getenv("STRIPE_SECRET_KEY")

def create_subscription(customer: User, plan_id: str) -> dict:
    # 金額は plan_id からサーバ側で解決する（クライアント指定額を信用しない＝改ざん対策）
    subscription = stripe.Subscription.create(
        customer=customer.stripe_customer_id,
        items=[{"price": plan_id}],
        payment_behavior="default_incomplete",
        expand=["latest_invoice.payment_intent"],
        # 同一内容の再送が二重課金にならないよう冪等キーを付ける
        idempotency_key=f"sub_{customer.id}_{plan_id}",
    )
    return {"subscription_id": subscription.id}
```

**冪等性を二層で守る:**
- **第 1 層（Stripe API）**: 冪等キーに「内容のハッシュ」を織り込み、同一内容の再送は安全に、内容変更は別キーに。
- **第 2 層（Webhook）**: Webhook は Flask 本体ではなく 3 つの Lambda で受け、DynamoDB の条件付き書き込み（`attribute_not_exists`・30日TTL）で重複イベントを排除。テーブル名未設定なら起動を止める（fail-closed）。
- **アウトボックス**: 課金調整は業務トランザクションと同じ DB トランザクションで `outbox` に書き、別 Lambda が確実に Stripe へ送る。

**教訓:** 決済は「Stripe を呼べば終わり」ではない。`サーバ側金額解決 ＋ 冪等キー ＋ Webhook 重複排除 ＋ アウトボックス` までやって初めて、リトライと重複配信に耐える。課金周りのバグは「売上損失」と「信用失墜」に直結する。

---

## 教訓7: CI/CDは「品質保証の自動化」として設計せよ

### 課題：デプロイ時のヒューマンエラー

B2B SaaSでは、ダウンタイムは「企業間取引の停止」を意味します。デプロイ時のミスでシステムが停止すると、顧客企業のビジネスが止まります。

### 実装：GitHub Actions + ECS自動デプロイ

```yaml
# .github/workflows/deploy.yml
name: Deploy to ECS

on:
  push:
    branches: [main]

jobs:
  test-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # リンター実行
      - name: Run ESLint
        run: npm run lint

      - name: Run Flake8
        run: flake8 app/

      # 脆弱性診断
      - name: Security Audit
        run: |
          npm audit --production
          pip-audit

      # テスト実行
      - name: Run Tests
        run: pytest tests/

      # Dockerイメージビルド
      - name: Build Docker Image
        run: docker build -t my-app:${{ github.sha }} .

      # ECRにプッシュ
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${{ secrets.ECR_REGISTRY }}
          docker push my-app:${{ github.sha }}

      # ECS デプロイ
      - name: Deploy to ECS
        run: |
          aws ecs update-service --cluster my-cluster --service my-app --force-new-deployment
```

**CI/CDの効果:**
- **自動テスト**: コミットごとに全テスト実行、バグの早期発見
- **自動デプロイ**: `git push` だけでデプロイ完了
- **ロールバック**: デプロイ失敗時、自動的に前バージョンにロールバック

**教訓:** CI/CDは「デプロイの自動化」ではなく、「品質保証の自動化」として設計する。リンター、脆弱性診断、テストを徹底することで、本番環境のバグを最小化。

---

## まとめ：B2B SaaS開発の7つの黄金律

1. **技術選定は「業界の複雑さ」から逆算せよ**
2. **セキュリティは「ページ単位・API単位」で設計せよ**
3. **バリデーションは「フロント・バック両方」で徹底せよ**
4. **IaC（Infrastructure as Code）は「初日から」導入せよ**
5. **パフォーマンスは「非同期処理」で最適化せよ**
6. **月額課金は「Stripe」で安定運用せよ**
7. **CI/CDは「品質保証の自動化」として設計せよ**

これらの教訓は、2年間の試行錯誤の結果です。B2B SaaS開発は、toC向けアプリとは全く異なる設計思想が必要です。**企業間取引の信頼性、セキュリティ、スケーラビリティ**を最優先に考え、技術選定からアーキテクチャ設計まで、一貫した戦略を持つことが成功の鍵です。

---

## あなたのプロジェクトでも実現可能です

もし、あなたが「レガシー産業のDX」「B2B SaaS開発」「技術選定に悩んでいる」場合、私がサポートできます。要件定義から設計、実装、インフラ構築まで、ワンストップで対応可能です。

**無料技術相談（30分）** を実施していますので、お気軽にご連絡ください。

[お問い合わせはこちら](/contact)
