経済産業大臣賞受賞プロダクトで学んだ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を選択:
// 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の在庫情報を閲覧できてしまう」という事態は、ビジネス上の致命的なリスクです。
実装:多層防御アーキテクチャ
# 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層構造:
- 認証層: AWS Cognito JWTトークン検証
- 認可層: ユーザー属性ごとのAPI単位アクセス制御
- データ層: 会社IDによるRow-Level Security(RLS)
教訓: セキュリティは「認証すればOK」ではなく、ページ単位・API単位・データ単位で多層的に設計する。特にB2B SaaSでは、競合他社のデータ保護が最優先。
教訓3: バリデーションは「フロント・バック両方」で徹底せよ
課題:不正データの混入防止
B2B SaaSでは、データの正確性が企業間取引の信頼性に直結します。不正な価格、在庫数、発注数が混入すると、ビジネス全体が破綻します。
実装:zod + Marshmallow の二重バリデーション
フロントエンド(TypeScript + zod):
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):
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 による完全自動化
# 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 へ完全にオフロードします。
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(サーバ側金額解決+冪等キー)
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自動デプロイ
# .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つの黄金律
- 技術選定は「業界の複雑さ」から逆算せよ
- セキュリティは「ページ単位・API単位」で設計せよ
- バリデーションは「フロント・バック両方」で徹底せよ
- IaC(Infrastructure as Code)は「初日から」導入せよ
- パフォーマンスは「非同期処理」で最適化せよ
- 月額課金は「Stripe」で安定運用せよ
- CI/CDは「品質保証の自動化」として設計せよ
これらの教訓は、2年間の試行錯誤の結果です。B2B SaaS開発は、toC向けアプリとは全く異なる設計思想が必要です。企業間取引の信頼性、セキュリティ、スケーラビリティを最優先に考え、技術選定からアーキテクチャ設計まで、一貫した戦略を持つことが成功の鍵です。
あなたのプロジェクトでも実現可能です
もし、あなたが「レガシー産業のDX」「B2B SaaS開発」「技術選定に悩んでいる」場合、私がサポートできます。要件定義から設計、実装、インフラ構築まで、ワンストップで対応可能です。
無料技術相談(30分) を実施していますので、お気軽にご連絡ください。