$ cat post.metadata

TypeScriptモノレポでBtoB SaaSを構築する -- GTMエンジニアリングを組み込んだアーキテクチャ設計

GTMエンジニアリングアーキテクチャ

BtoB SaaSをTypeScriptモノレポで構築する際に、GTMエンジニアリング機能(ヘルススコア、CRM連携、利用状況分析)をプロダクトに組み込むアーキテクチャ設計。Turborepo + Hono + React + Prismaのパッケージ構成を解説。

$ cat post.content | render --format=markdown

TypeScriptモノレポでBtoB SaaSを構築する -- GTMエンジニアリングを組み込んだアーキテクチャ設計

この記事の位置づけ

Phase 2のここまでの記事では、GTMエンジニアリングの個別機能を実装してきた。カスタムデモ環境の15分構築CLAUDE.mdによる営業暗黙知のコード化商談同席から得た学びのエンジニアリングへの還元

これらは全て「ある課題に対する個別の解法」だった。Phase 3では視座を上げ、BtoB SaaSのアーキテクチャ全体にGTMエンジニアリングをどう組み込むかを設計する。

SLG時代のGTMエンジニアの定義で提示した3つのタイプのうち、Product-Embedded型はプロダクト自体をSLGの武器にするエンジニアだった。この記事では、Product-Embedded型のGTMエンジニアがBtoB SaaSの設計段階からどのようにアーキテクチャに関与するかを、TypeScriptモノレポの具体的なパッケージ構成として示す。

なぜモノレポでGTMを統合するのか

プロダクトとGTMの分断が生む問題

多くのBtoB SaaS企業では、プロダクト開発とGTMエンジニアリングが分離している。プロダクトチームはReactとAPIを書き、GTMエンジニアは別リポジトリでCRM連携スクリプトやSlack通知ボットを運用している。

この分断が引き起こす問題がある。

1. 型の二重管理

プロダクトのユーザーデータの型定義と、GTMパッケージがCRMに送るデータの型定義が別々に管理される。プロダクト側でUser型にフィールドを追加しても、GTM側のCRM同期ロジックは気づかない。結果として、CRMに古い構造のデータが送られ続ける。

2. イベントの欠落

プロダクトで新機能をリリースしたとき、その機能の利用イベントをGTM側の分析パイプラインに流し込む作業が後回しになる。「リリースから3ヶ月経って、やっと利用状況が取れるようになった」という話は珍しくない。

3. デプロイの不整合

プロダクトのAPIスキーマが変わったのに、GTMのWebhook受信側が追従できていない。デプロイのタイミングがずれて、一時的にCRM連携が壊れる。

4. レビューの盲点

プロダクトのPRレビューで「この変更はCRM連携に影響しますか?」という質問が出ない。GTMリポジトリのPRレビューで「プロダクト側のデータ構造と整合していますか?」という確認がされない。

モノレポによる解決

これらの問題は全て、プロダクトとGTMが同一リポジトリにあれば解決する

  • 型定義をpackages/shared/で一元管理すれば、CRMに送るデータとプロダクト内部のデータに乖離が起きない
  • プロダクトの機能追加PRでpackages/gtm/analytics/への影響も確認できる
  • CIパイプラインで全パッケージのビルドとテストが通ることを保証できる
  • PRのdiffに「プロダクト変更」と「GTM対応」が同時に含まれるので、レビューで見落としが減る

技術選定フレームワークで述べた「TypeScriptフルスタックで切り替えコストを最小化する」原則は、このモノレポ構成で最大の効果を発揮する。

パッケージ構成

このモノレポの全体像を示す。

monorepo-root/
  turbo.json
  package.json
  packages/
    frontend/          # React + React Router v7 (SSR)
    backend/           # Hono API サーバー
    shared/            # 共有型定義・ユーティリティ
    db/                # Prisma スキーマ・マイグレーション
    gtm/               # GTMエンジニアリング専用パッケージ
      health-score/    # ヘルススコア計算エンジン
      crm-sync/        # CRM双方向同期
      analytics/       # 利用状況分析・レポート
      notifications/   # 営業チーム通知
    mcp-servers/       # MCPサーバー群
      crm/             # CRM操作用MCPサーバー
      analytics/       # 分析データ参照用MCPサーバー

ポイントはpackages/gtm/の存在だ。プロダクトのfrontend/backend/と同列にGTMエンジニアリング専用パッケージを配置する。これが「GTMをプロダクトに組み込む」アーキテクチャの核心になる。

packages/mcp-servers/MCPサーバーの詳細で解説するが、Claude Code Agent TeamからCRMデータや分析データに安全にアクセスするためのインターフェース層だ。

各パッケージの責務を順に見ていく。

packages/shared/ -- 型定義の一元管理

モノレポの要は共有型定義だ。プロダクトとGTMの間のデータ構造を一箇所で管理する。

typescript
// packages/shared/src/tenant.ts export interface Tenant { readonly id: string readonly name: string readonly plan: 'free' | 'starter' | 'professional' | 'enterprise' readonly createdAt: Date readonly settings: TenantSettings } export interface TenantSettings { readonly crmIntegration: CRMIntegrationConfig | null readonly notificationChannels: NotificationChannel[] readonly healthScoreWeights: HealthScoreWeights } export interface CRMIntegrationConfig { readonly type: 'hubspot' | 'salesforce' | 'kintone' readonly credentials: { readonly accessToken: string readonly refreshToken: string readonly instanceUrl?: string // Salesforce用 readonly subdomain?: string // kintone用 } readonly syncEnabled: boolean readonly lastSyncedAt: Date | null }
typescript
// packages/shared/src/health-score.ts export interface HealthScoreInput { readonly tenantId: string readonly period: '7d' | '30d' | '90d' readonly metrics: { readonly loginFrequency: number // DAU / 総ユーザー数 readonly featureAdoptionRate: number // 利用機能数 / 全機能数 readonly apiCallTrend: number // 前期比(1.0 = 横ばい、1.2 = 20%増) readonly supportTicketCount: number // サポートチケット数 readonly dataVolumeGrowthRate: number // データ量の前期比 } } export interface HealthScoreOutput { readonly score: number // 0-100 readonly trend: 'improving' | 'stable' | 'declining' readonly riskLevel: 'low' | 'medium' | 'high' | 'critical' readonly riskFactors: readonly RiskFactor[] readonly recommendations: readonly Recommendation[] readonly calculatedAt: Date } export interface RiskFactor { readonly type: 'login_decline' | 'feature_drop' | 'support_spike' | 'data_stagnation' readonly severity: 'warning' | 'critical' readonly description: string readonly metric: string readonly threshold: number readonly actual: number } export interface Recommendation { readonly action: string readonly priority: 'immediate' | 'this_week' | 'this_month' readonly targetTeam: 'cs' | 'sales' | 'product' readonly expectedImpact: string }
typescript
// packages/shared/src/events.ts export type GTMEvent = | UserLoginEvent | FeatureUsedEvent | ApiCallEvent | SupportTicketCreatedEvent | PlanUpgradeAttemptedEvent | ExportRequestedEvent export interface UserLoginEvent { readonly type: 'user.login' readonly tenantId: string readonly userId: string readonly timestamp: Date readonly metadata: { readonly deviceType: 'desktop' | 'mobile' readonly browser: string } } export interface FeatureUsedEvent { readonly type: 'feature.used' readonly tenantId: string readonly userId: string readonly featureId: string readonly timestamp: Date } export interface PlanUpgradeAttemptedEvent { readonly type: 'plan.upgrade_attempted' readonly tenantId: string readonly userId: string readonly currentPlan: string readonly targetPlan: string readonly timestamp: Date readonly blocked: boolean readonly blockedReason?: string }

この型定義が全パッケージの契約になる。backend/がイベントを発行するとき、gtm/analytics/がそれを処理するとき、gtm/health-score/がスコアを算出するとき、全て同じ型を参照する。型が変わればビルドが壊れ、PRで気づける。

packages/gtm/health-score/ -- ヘルススコア計算エンジン

GTMパッケージの中核がヘルススコアだ。SLG時代のGTMエンジニアの定義で示したProduct-Embedded型の代表的な機能で、顧客の健全性を0-100のスコアで数値化する。

設計方針

ヘルススコアの計算ロジックは、テナントごとにカスタマイズ可能な重み付けを持たせる。SaaSの利用パターンは業種によって大きく異なるためだ。製造業のテナントは毎日ログインしなくても月次バッチで大量のAPIを叩く使い方をするかもしれない。SaaS企業のテナントは毎日ログインするがAPIは使わないかもしれない。

typescript
// packages/gtm/health-score/src/calculator.ts import type { HealthScoreInput, HealthScoreOutput, HealthScoreWeights, RiskFactor, Recommendation, } from '@monorepo/shared' const DEFAULT_WEIGHTS: HealthScoreWeights = { loginFrequency: 0.25, featureAdoption: 0.25, apiUsage: 0.20, supportHealth: 0.15, dataGrowth: 0.15, } as const export function calculateHealthScore( input: HealthScoreInput, customWeights?: Partial<HealthScoreWeights> ): HealthScoreOutput { const weights = { ...DEFAULT_WEIGHTS, ...customWeights } const componentScores = { loginFrequency: normalizeLoginFrequency(input.metrics.loginFrequency), featureAdoption: normalizeFeatureAdoption(input.metrics.featureAdoptionRate), apiUsage: normalizeApiUsage(input.metrics.apiCallTrend), supportHealth: normalizeSupportHealth(input.metrics.supportTicketCount), dataGrowth: normalizeDataGrowth(input.metrics.dataVolumeGrowthRate), } const score = Math.round( componentScores.loginFrequency * weights.loginFrequency + componentScores.featureAdoption * weights.featureAdoption + componentScores.apiUsage * weights.apiUsage + componentScores.supportHealth * weights.supportHealth + componentScores.dataGrowth * weights.dataGrowth ) const riskFactors = detectRiskFactors(input.metrics, componentScores) const riskLevel = determineRiskLevel(score, riskFactors) return { score, trend: determineTrend(input.metrics), riskLevel, riskFactors, recommendations: generateRecommendations(riskFactors, riskLevel), calculatedAt: new Date(), } }

各メトリクスの正規化関数は、生の値を0-100のスコアに変換する。

typescript
// packages/gtm/health-score/src/normalizers.ts // DAU率が高いほどスコアが高い // 0.01(1%)以下 → 0、0.5(50%)以上 → 100 function normalizeLoginFrequency(dauRate: number): number { if (dauRate <= 0.01) return 0 if (dauRate >= 0.5) return 100 return Math.round((dauRate - 0.01) / (0.5 - 0.01) * 100) } // 機能利用率が高いほどスコアが高い // 0.05以下 → 0、0.8以上 → 100 function normalizeFeatureAdoption(adoptionRate: number): number { if (adoptionRate <= 0.05) return 0 if (adoptionRate >= 0.8) return 100 return Math.round((adoptionRate - 0.05) / (0.8 - 0.05) * 100) } // APIコール量の増減トレンド // 0.5以下(50%減)→ 0、1.0(横ばい)→ 60、1.5以上(50%増)→ 100 function normalizeApiUsage(trend: number): number { if (trend <= 0.5) return 0 if (trend <= 1.0) return Math.round((trend - 0.5) / 0.5 * 60) if (trend >= 1.5) return 100 return Math.round(60 + (trend - 1.0) / 0.5 * 40) } // サポートチケット数は少ないほどスコアが高い // 20件以上 → 0、0件 → 100 function normalizeSupportHealth(ticketCount: number): number { if (ticketCount >= 20) return 0 if (ticketCount <= 0) return 100 return Math.round((1 - ticketCount / 20) * 100) }

リスクファクター検出

スコアが低い要因を具体的に特定し、CSチームが何をすべきかを明確にする。

typescript
// packages/gtm/health-score/src/risk-detection.ts import type { RiskFactor } from '@monorepo/shared' interface MetricScores { readonly loginFrequency: number readonly featureAdoption: number readonly apiUsage: number readonly supportHealth: number readonly dataGrowth: number } const RISK_THRESHOLDS = { critical: 20, warning: 40, } as const export function detectRiskFactors( metrics: HealthScoreInput['metrics'], scores: MetricScores ): readonly RiskFactor[] { const factors: RiskFactor[] = [] if (scores.loginFrequency < RISK_THRESHOLDS.critical) { factors.push({ type: 'login_decline', severity: 'critical', description: 'DAU率が1%を下回っています。ほとんどのユーザーがログインしていない状態です。', metric: 'loginFrequency', threshold: 0.01, actual: metrics.loginFrequency, }) } else if (scores.loginFrequency < RISK_THRESHOLDS.warning) { factors.push({ type: 'login_decline', severity: 'warning', description: 'DAU率が低下傾向にあります。主要ユーザーへの個別フォローを検討してください。', metric: 'loginFrequency', threshold: 0.05, actual: metrics.loginFrequency, }) } if (scores.featureAdoption < RISK_THRESHOLDS.critical) { factors.push({ type: 'feature_drop', severity: 'critical', description: '利用されている機能が全体の5%以下です。オンボーディングの再実施を推奨します。', metric: 'featureAdoptionRate', threshold: 0.05, actual: metrics.featureAdoptionRate, }) } if (scores.supportHealth < RISK_THRESHOLDS.warning) { factors.push({ type: 'support_spike', severity: metrics.supportTicketCount >= 15 ? 'critical' : 'warning', description: `サポートチケットが${metrics.supportTicketCount}件に達しています。プロダクト側の問題がないか確認してください。`, metric: 'supportTicketCount', threshold: 10, actual: metrics.supportTicketCount, }) } return factors }

チャーン予兆アラート

ヘルススコアの時系列変化からチャーン予兆を検出する。単一時点のスコアだけでなく、スコアの推移パターンが重要だ。

typescript
// packages/gtm/health-score/src/churn-predictor.ts import type { HealthScoreOutput } from '@monorepo/shared' export interface ChurnPrediction { readonly tenantId: string readonly churnProbability: 'low' | 'medium' | 'high' | 'imminent' readonly pattern: ChurnPattern readonly daysUntilEstimatedChurn: number | null readonly suggestedInterventions: readonly string[] } type ChurnPattern = | 'gradual_decline' // 3ヶ月連続でスコア低下 | 'sudden_drop' // 前月比で30ポイント以上低下 | 'chronic_low' // 3ヶ月以上スコア30以下 | 'support_escalation' // サポートチケットが3倍以上に増加 | 'none' export function predictChurn( tenantId: string, scoreHistory: readonly HealthScoreOutput[] ): ChurnPrediction { if (scoreHistory.length < 3) { return { tenantId, churnProbability: 'low', pattern: 'none', daysUntilEstimatedChurn: null, suggestedInterventions: ['データ蓄積中。3ヶ月後に再評価。'], } } const recent = scoreHistory.slice(-3) const [oldest, middle, latest] = recent // パターン1: 3ヶ月連続低下 if (oldest.score > middle.score && middle.score > latest.score) { const declineRate = (oldest.score - latest.score) / 2 // 月あたりの低下ポイント const daysUntilZero = latest.score > 0 ? Math.round((latest.score / declineRate) * 30) : 0 return { tenantId, churnProbability: latest.score < 30 ? 'imminent' : 'high', pattern: 'gradual_decline', daysUntilEstimatedChurn: daysUntilZero, suggestedInterventions: [ 'エグゼクティブスポンサーとの1on1ミーティングを設定', '利用率の低い部署への再オンボーディング提案', 'カスタマーサクセスマネージャーによる週次チェックイン開始', ], } } // パターン2: 急激な低下 if (latest.score < middle.score - 30) { return { tenantId, churnProbability: 'high', pattern: 'sudden_drop', daysUntilEstimatedChurn: 60, suggestedInterventions: [ '直近の障害・不具合の影響を確認', '主要ユーザーへの緊急ヒアリング実施', 'プロダクトチームへのエスカレーション', ], } } // パターン3: 慢性的な低スコア if (recent.every(s => s.score <= 30)) { return { tenantId, churnProbability: 'imminent', pattern: 'chronic_low', daysUntilEstimatedChurn: 30, suggestedInterventions: [ '契約更新日を確認し、更新3ヶ月前のリカバリープラン作成', '経営層への価値再提示(ROIレポート自動生成)', '解約理由の事前ヒアリング', ], } } return { tenantId, churnProbability: 'low', pattern: 'none', daysUntilEstimatedChurn: null, suggestedInterventions: [], } }

ここまでがヘルススコアパッケージの全体像だ。計算ロジック、リスク検出、チャーン予兆の3層構造になっている。重要なのは、入出力の型が全てpackages/shared/に定義されていることで、backend/からこのパッケージを呼び出すときも、crm-sync/にスコアを渡すときも、型の一貫性が保証される。

packages/gtm/crm-sync/ -- CRM双方向同期

ヘルススコアを算出しても、営業チームがCRMを見てくれなければ意味がない。BtoB SaaSの営業組織において、CRMは「唯一の真実の源泉(Single Source of Truth)」であることが多い。プロダクト内のダッシュボードにどれだけ美しいグラフを表示しても、営業がHubSpotやSalesforceを離れて別のツールを開く習慣は生まれにくい。

だからこそ、プロダクトのデータをCRMに自動で流し込む仕組みが必要になる。

双方向同期の設計

プロダクト → CRM(アウトバウンド同期)
  - ヘルススコアの値をCRMのカスタムフィールドに書き込む
  - 利用状況サマリーをCRMのノートに自動追加
  - アップセル機会のフラグをCRMの商談に反映

CRM → プロダクト(インバウンド同期)
  - CRMの商談ステージ変更 → プロダクト内の権限管理に反映
  - CRMの契約終了日 → プロダクトの利用期限に反映
  - CRMの担当者変更 → プロダクトの通知先を更新

アダプターパターン

複数のCRMをサポートするため、アダプターパターンで抽象化する。

typescript
// packages/gtm/crm-sync/src/adapter.ts import type { HealthScoreOutput, Tenant } from '@monorepo/shared' export interface CRMSyncResult { readonly success: boolean readonly syncedAt: Date readonly recordsUpdated: number readonly errors: readonly CRMSyncError[] } export interface CRMSyncError { readonly field: string readonly message: string readonly retryable: boolean } export interface CRMAdapter { // アウトバウンド: プロダクト → CRM syncHealthScore(tenantId: string, score: HealthScoreOutput): Promise<CRMSyncResult> syncUsageMetrics(tenantId: string, metrics: UsageMetricsSummary): Promise<CRMSyncResult> flagUpsellOpportunity(tenantId: string, opportunity: UpsellOpportunity): Promise<CRMSyncResult> // インバウンド: CRM → プロダクト fetchDealStage(tenantId: string): Promise<DealStage> fetchContractEndDate(tenantId: string): Promise<Date | null> registerWebhook(events: readonly CRMEventType[]): Promise<WebhookRegistration> } export type CRMEventType = | 'deal.stage_changed' | 'deal.closed_won' | 'deal.closed_lost' | 'contact.updated' | 'company.updated'
typescript
// packages/gtm/crm-sync/src/adapters/hubspot.ts import type { CRMAdapter, CRMSyncResult } from '../adapter' import type { HealthScoreOutput } from '@monorepo/shared' export class HubSpotAdapter implements CRMAdapter { constructor( private readonly accessToken: string, private readonly portalId: string ) {} async syncHealthScore( tenantId: string, score: HealthScoreOutput ): Promise<CRMSyncResult> { const companyId = await this.findCompanyByTenantId(tenantId) if (!companyId) { return { success: false, syncedAt: new Date(), recordsUpdated: 0, errors: [{ field: 'companyId', message: `テナント${tenantId}に対応するHubSpot企業が見つかりません`, retryable: false, }], } } const response = await fetch( `https://api.hubapi.com/crm/v3/objects/companies/${companyId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ properties: { health_score: String(score.score), health_score_trend: score.trend, health_score_risk_level: score.riskLevel, health_score_updated_at: score.calculatedAt.toISOString(), churn_risk_factors: score.riskFactors .map(f => f.description) .join(' | '), }, }), } ) if (!response.ok) { const error = await response.json() return { success: false, syncedAt: new Date(), recordsUpdated: 0, errors: [{ field: 'hubspot_api', message: `HubSpot APIエラー: ${error.message}`, retryable: response.status === 429, }], } } return { success: true, syncedAt: new Date(), recordsUpdated: 1, errors: [], } } // 省略: 他のメソッドも同様にHubSpot APIを呼び出す }
typescript
// packages/gtm/crm-sync/src/adapters/salesforce.ts import type { CRMAdapter, CRMSyncResult } from '../adapter' import type { HealthScoreOutput } from '@monorepo/shared' export class SalesforceAdapter implements CRMAdapter { constructor( private readonly instanceUrl: string, private readonly accessToken: string ) {} async syncHealthScore( tenantId: string, score: HealthScoreOutput ): Promise<CRMSyncResult> { const accountId = await this.findAccountByTenantId(tenantId) if (!accountId) { return { success: false, syncedAt: new Date(), recordsUpdated: 0, errors: [{ field: 'accountId', message: `テナント${tenantId}に対応するSalesforceアカウントが見つかりません`, retryable: false, }], } } const response = await fetch( `${this.instanceUrl}/services/data/v59.0/sobjects/Account/${accountId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ Health_Score__c: score.score, Health_Score_Trend__c: score.trend, Health_Score_Risk_Level__c: score.riskLevel, Health_Score_Updated_At__c: score.calculatedAt.toISOString(), }), } ) return { success: response.ok, syncedAt: new Date(), recordsUpdated: response.ok ? 1 : 0, errors: response.ok ? [] : [{ field: 'salesforce_api', message: `Salesforce APIエラー: ${response.status}`, retryable: response.status === 429 || response.status >= 500, }], } } }
typescript
// packages/gtm/crm-sync/src/adapters/kintone.ts import type { CRMAdapter, CRMSyncResult } from '../adapter' import type { HealthScoreOutput } from '@monorepo/shared' export class KintoneAdapter implements CRMAdapter { constructor( private readonly subdomain: string, private readonly apiToken: string, private readonly appId: string ) {} async syncHealthScore( tenantId: string, score: HealthScoreOutput ): Promise<CRMSyncResult> { const recordId = await this.findRecordByTenantId(tenantId) if (!recordId) { return { success: false, syncedAt: new Date(), recordsUpdated: 0, errors: [{ field: 'recordId', message: `テナント${tenantId}に対応するkintoneレコードが見つかりません`, retryable: false, }], } } const response = await fetch( `https://${this.subdomain}.cybozu.com/k/v1/record.json`, { method: 'PUT', headers: { 'X-Cybozu-API-Token': this.apiToken, 'Content-Type': 'application/json', }, body: JSON.stringify({ app: this.appId, id: recordId, record: { health_score: { value: String(score.score) }, health_score_trend: { value: score.trend }, risk_level: { value: score.riskLevel }, updated_at: { value: score.calculatedAt.toISOString() }, }, }), } ) return { success: response.ok, syncedAt: new Date(), recordsUpdated: response.ok ? 1 : 0, errors: response.ok ? [] : [{ field: 'kintone_api', message: `kintone APIエラー: ${response.status}`, retryable: response.status === 429, }], } } }

ファクトリー + 同期オーケストレーター

テナントの設定に応じて適切なアダプターを生成し、同期を実行する。

typescript
// packages/gtm/crm-sync/src/factory.ts import type { CRMAdapter } from './adapter' import type { CRMIntegrationConfig } from '@monorepo/shared' import { HubSpotAdapter } from './adapters/hubspot' import { SalesforceAdapter } from './adapters/salesforce' import { KintoneAdapter } from './adapters/kintone' export function createCRMAdapter(config: CRMIntegrationConfig): CRMAdapter { switch (config.type) { case 'hubspot': return new HubSpotAdapter( config.credentials.accessToken, config.credentials.refreshToken ) case 'salesforce': return new SalesforceAdapter( config.credentials.instanceUrl ?? '', config.credentials.accessToken ) case 'kintone': return new KintoneAdapter( config.credentials.subdomain ?? '', config.credentials.accessToken, config.credentials.refreshToken // kintoneの場合appIdを格納 ) } }

Webhook + ポーリングのハイブリッド同期

CRMからプロダクトへの同期は、Webhookとポーリングの2つの経路を持つ。Webhookだけに依存すると、Webhook配信が失敗したときにデータの不整合が起きる。ポーリングだけだとリアルタイム性が犠牲になる。両方を組み合わせることで、リアルタイム性と信頼性を両立する。

typescript
// packages/gtm/crm-sync/src/sync-orchestrator.ts import type { CRMAdapter, CRMSyncResult } from './adapter' import { createCRMAdapter } from './factory' import type { Tenant, HealthScoreOutput } from '@monorepo/shared' export interface SyncOrchestrator { // リアルタイム同期(イベント駆動) handleProductEvent(tenantId: string, event: ProductEvent): Promise<CRMSyncResult> handleCRMWebhook(payload: CRMWebhookPayload): Promise<void> // バッチ同期(定期実行) runFullSync(tenantId: string): Promise<SyncReport> runReconciliation(): Promise<ReconciliationReport> } export interface SyncReport { readonly tenantId: string readonly syncedAt: Date readonly outbound: { readonly updated: number; readonly failed: number } readonly inbound: { readonly updated: number; readonly failed: number } readonly discrepancies: readonly DataDiscrepancy[] } export interface DataDiscrepancy { readonly field: string readonly productValue: string readonly crmValue: string readonly resolution: 'use_product' | 'use_crm' | 'manual_review' }

packages/gtm/analytics/ -- 利用状況分析

テナントごとのダッシュボードデータ

プロダクトの管理画面にCSチーム・営業チーム向けの利用状況ダッシュボードを組み込むためのデータを生成する。

typescript
// packages/gtm/analytics/src/usage-analyzer.ts import type { GTMEvent } from '@monorepo/shared' export interface TenantUsageReport { readonly tenantId: string readonly period: { readonly from: Date; readonly to: Date } readonly activeUsers: { readonly daily: number readonly weekly: number readonly monthly: number } readonly featureUsage: readonly FeatureUsageStat[] readonly topUsers: readonly UserActivity[] readonly unusedFeatures: readonly string[] readonly upsellSignals: readonly UpsellSignal[] } export interface FeatureUsageStat { readonly featureId: string readonly featureName: string readonly uniqueUsers: number readonly totalUsageCount: number readonly trend: 'growing' | 'stable' | 'declining' readonly planRequired: string // この機能が含まれるプラン } export interface UpsellSignal { readonly type: 'plan_limit_approaching' | 'premium_feature_attempted' | 'usage_growth' readonly description: string readonly confidence: 'low' | 'medium' | 'high' readonly suggestedAction: string }

アップセル機会の検出

利用状況分析の最も直接的なビジネスインパクトは、アップセル機会の自動検出だ。

typescript
// packages/gtm/analytics/src/upsell-detector.ts import type { UpsellSignal, GTMEvent } from '@monorepo/shared' export function detectUpsellSignals( events: readonly GTMEvent[], currentPlan: string ): readonly UpsellSignal[] { const signals: UpsellSignal[] = [] // シグナル1: 上位プランの機能を試みた const upgradeAttempts = events.filter( (e): e is PlanUpgradeAttemptedEvent => e.type === 'plan.upgrade_attempted' && e.blocked ) if (upgradeAttempts.length >= 3) { const targetPlans = [...new Set(upgradeAttempts.map(e => e.targetPlan))] signals.push({ type: 'premium_feature_attempted', description: `上位プラン(${targetPlans.join(', ')})の機能を${upgradeAttempts.length}回試みています`, confidence: upgradeAttempts.length >= 5 ? 'high' : 'medium', suggestedAction: `${targetPlans[0]}プランへのアップグレード提案。直近で${upgradeAttempts.length}回の利用試行あり。`, }) } // シグナル2: 利用量が現プランの上限に近づいている const apiEvents = events.filter(e => e.type === 'feature.used') const uniqueFeatures = new Set( apiEvents .filter((e): e is FeatureUsedEvent => e.type === 'feature.used') .map(e => e.featureId) ) // 現プランの機能数上限に対する利用率 const planLimits: Record<string, number> = { free: 5, starter: 15, professional: 50, enterprise: Infinity, } const limit = planLimits[currentPlan] ?? 5 const usageRate = uniqueFeatures.size / limit if (usageRate >= 0.8 && limit !== Infinity) { signals.push({ type: 'plan_limit_approaching', description: `機能利用数が現プランの${Math.round(usageRate * 100)}%に到達。${uniqueFeatures.size}/${limit}機能を利用中。`, confidence: 'high', suggestedAction: 'プランアップグレードの事前案内を送信。上限到達前にスムーズな移行を提案。', }) } return signals }

CSチームや営業チームがこのデータを見ることで、「この顧客にいつ、何を提案すべきか」が定量的に判断できる。

packages/gtm/notifications/ -- 営業チーム通知

ヘルススコアの変動やアップセル機会をリアルタイムで営業チームに届ける。

typescript
// packages/gtm/notifications/src/notifier.ts import type { HealthScoreOutput, UpsellSignal, ChurnPrediction } from '@monorepo/shared' export interface NotificationChannel { readonly type: 'slack' | 'email' | 'webhook' readonly config: SlackConfig | EmailConfig | WebhookConfig } export interface SlackConfig { readonly webhookUrl: string readonly channelId: string readonly mentionUserIds: readonly string[] } export interface NotificationPayload { readonly tenantId: string readonly tenantName: string readonly type: 'health_score_alert' | 'upsell_opportunity' | 'churn_warning' readonly severity: 'info' | 'warning' | 'critical' readonly title: string readonly body: string readonly actionUrl: string } export function buildHealthScoreAlert( tenantId: string, tenantName: string, score: HealthScoreOutput, previousScore: number ): NotificationPayload | null { // スコアが30以下に低下した場合のみ通知 if (score.score > 30 || previousScore <= 30) return null return { tenantId, tenantName, type: 'health_score_alert', severity: score.score <= 15 ? 'critical' : 'warning', title: `${tenantName}のヘルススコアが${score.score}に低下`, body: [ `前回: ${previousScore} → 今回: ${score.score}${previousScore - score.score}pt低下)`, `リスクレベル: ${score.riskLevel}`, '', '主なリスク要因:', ...score.riskFactors.map(f => `- ${f.description}`), '', '推奨アクション:', ...score.recommendations .filter(r => r.priority === 'immediate') .map(r => `- [${r.targetTeam}] ${r.action}`), ].join('\n'), actionUrl: `/admin/tenants/${tenantId}/health`, } } export function buildUpsellAlert( tenantId: string, tenantName: string, signals: readonly UpsellSignal[] ): NotificationPayload | null { const highConfidenceSignals = signals.filter(s => s.confidence === 'high') if (highConfidenceSignals.length === 0) return null return { tenantId, tenantName, type: 'upsell_opportunity', severity: 'info', title: `${tenantName}にアップセル機会を検出(${highConfidenceSignals.length}件)`, body: highConfidenceSignals .map(s => `- ${s.description}\n 推奨: ${s.suggestedAction}`) .join('\n'), actionUrl: `/admin/tenants/${tenantId}/analytics`, } }

通知の粒度設計は慎重にやった方がいい。通知が多すぎると無視される。少なすぎると機会を逃す。このバランスは営業チームとの対話の中で調整していく領域で、商談同席から得た学びで述べた「営業の言語を理解する」姿勢が不可欠になる。

packages/db/ -- マルチテナント設計

Row-Level Securityによるテナント分離

BtoB SaaSのセキュリティ要件として、テナント間のデータ分離は最も重要な設計判断だ。PostgreSQLのRow-Level Security(RLS)を使い、データベースレベルでテナント分離を強制する。

prisma
// packages/db/prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Tenant { id String @id @default(cuid()) name String plan String @default("free") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt settings TenantSettings? users User[] healthScores HealthScore[] events GTMEvent[] crmSyncLogs CRMSyncLog[] @@map("tenants") } model TenantSettings { id String @id @default(cuid()) tenantId String @unique tenant Tenant @relation(fields: [tenantId], references: [id]) crmType String? // 'hubspot' | 'salesforce' | 'kintone' crmCredentialsEncrypted String? crmSyncEnabled Boolean @default(false) slackWebhookUrl String? healthScoreWeightsJson String? // JSON文字列でカスタム重み付けを保存 @@map("tenant_settings") } model User { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) email String name String role String @default("member") lastLoginAt DateTime? createdAt DateTime @default(now()) @@unique([tenantId, email]) @@index([tenantId]) @@map("users") } model HealthScore { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) score Int trend String riskLevel String riskFactorsJson String // JSON recommendationsJson String // JSON period String // '7d' | '30d' | '90d' calculatedAt DateTime @default(now()) @@index([tenantId, calculatedAt]) @@map("health_scores") } model GTMEvent { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) type String userId String? metadata String // JSON createdAt DateTime @default(now()) @@index([tenantId, type, createdAt]) @@map("gtm_events") } model CRMSyncLog { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) direction String // 'outbound' | 'inbound' crmType String success Boolean recordsUpdated Int @default(0) errorsJson String? // JSON syncedAt DateTime @default(now()) @@index([tenantId, syncedAt]) @@map("crm_sync_logs") }

ミドルウェアでのテナントコンテキスト注入

HonoのミドルウェアでリクエストごとにテナントIDを検証し、Prismaクエリに自動的にテナントフィルタを付与する。

typescript
// packages/backend/src/middleware/tenant.ts import { createMiddleware } from 'hono/factory' import type { Context } from 'hono' export interface TenantContext { tenantId: string tenantPlan: string } export const tenantMiddleware = createMiddleware(async (c, next) => { const tenantId = c.req.header('X-Tenant-ID') if (!tenantId) { return c.json({ error: 'X-Tenant-ID header is required' }, 401) } const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, plan: true }, }) if (!tenant) { return c.json({ error: 'Tenant not found' }, 404) } c.set('tenant', { tenantId: tenant.id, tenantPlan: tenant.plan }) await next() })
typescript
// packages/backend/src/lib/prisma-tenant.ts import { PrismaClient } from '@prisma/client' // テナントスコープ付きクエリヘルパー export function withTenant(tenantId: string) { return { where: { tenantId }, } } // 使用例: GTMイベント取得 export async function getEventsForTenant( prisma: PrismaClient, tenantId: string, eventType?: string, since?: Date ) { return prisma.gTMEvent.findMany({ where: { tenantId, ...(eventType ? { type: eventType } : {}), ...(since ? { createdAt: { gte: since } } : {}), }, orderBy: { createdAt: 'desc' }, }) }

RLSを使うことで、アプリケーションコードのバグでtenantIdのフィルタが漏れたとしても、データベースレベルでテナント間のデータ漏洩を防げる。特にGTMパッケージは複数テナントのデータを横断的に処理する場面があるため、この防御層は重要だ。

Turborepoの設定

turbo.json

パッケージ間の依存関係とビルドパイプラインを定義する。

json
{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, "test": { "dependsOn": ["^build"], "outputs": [] }, "test:unit": { "dependsOn": ["^build"], "outputs": [] }, "test:integration": { "dependsOn": ["^build", "db#migrate"], "outputs": [] }, "lint": { "dependsOn": [], "outputs": [] }, "typecheck": { "dependsOn": ["^build"], "outputs": [] }, "dev": { "cache": false, "persistent": true }, "db#migrate": { "dependsOn": [], "outputs": [], "cache": false }, "db#generate": { "dependsOn": [], "outputs": ["node_modules/.prisma/**"] } } }

パッケージ間の依存関係

依存グラフ:

shared  ←─── frontend
  ↑          backend ←── gtm/health-score
  │                  ←── gtm/crm-sync
  │                  ←── gtm/analytics
  │                  ←── gtm/notifications
  │
  └─── db ←── backend
             ←── gtm/health-score
             ←── gtm/analytics

sharedは全パッケージから参照される。dbはデータアクセスが必要なパッケージから参照される。GTMパッケージはbackenddbに依存するが、frontendには直接依存しない。フロントエンドはAPI経由でGTMデータにアクセスする。

Turborepoのビルドキャッシュにより、shareddbに変更がない場合、GTMパッケージのビルドはキャッシュから復元される。日常的な開発でfrontendだけを変更している場合、GTMパッケージのビルドは一瞬で完了する。

GTMパッケージとプロダクトパッケージの境界

モノレポにGTMパッケージを置くことで統合性は高まるが、パッケージ間の境界が曖昧になるリスクもある。設計上の境界を明確にしておく。

境界の原則

1. GTMパッケージはプロダクトのコアロジックに依存しない

GTMパッケージが参照してよいのはpackages/shared/の型定義とpackages/db/のスキーマだけだ。プロダクトのbackend/に定義されたビジネスロジック関数を直接呼び出してはならない。

許可される依存:
  gtm/* → shared (型定義)
  gtm/* → db (Prismaクライアント)

禁止される依存:
  gtm/* → backend/src/services/*  (プロダクトのビジネスロジック)
  gtm/* → frontend/*             (UIコンポーネント)

2. イベント駆動で疎結合に連携する

プロダクトとGTMの間のデータの受け渡しは、直接の関数呼び出しではなくイベントで行う。

typescript
// packages/backend/src/services/user-service.ts // プロダクトのユーザーサービスはGTMのことを知らない export async function handleUserLogin(userId: string, tenantId: string): Promise<void> { // プロダクトのログイン処理 await updateLastLoginAt(userId) // GTMイベントの発行(プロダクトはイベントを出すだけ) await publishEvent({ type: 'user.login', tenantId, userId, timestamp: new Date(), metadata: { deviceType: 'desktop', browser: 'Chrome', }, }) }
typescript
// packages/gtm/analytics/src/event-handler.ts // GTMのイベントハンドラーはイベントを受け取るだけ export async function handleGTMEvent(event: GTMEvent): Promise<void> { // イベントをDBに保存 await prisma.gTMEvent.create({ data: { tenantId: event.tenantId, type: event.type, userId: 'userId' in event ? event.userId : null, metadata: JSON.stringify(event), }, }) // ヘルススコアの再計算が必要かチェック if (shouldRecalculateHealthScore(event)) { await scheduleHealthScoreRecalculation(event.tenantId) } }

3. packages/shared/ がインターフェース契約を定義する

GTMパッケージとプロダクトパッケージの間の全てのデータ構造はpackages/shared/に定義する。新しいイベント型を追加するときはshared/src/events.tsを更新し、プロダクト側とGTM側の両方でビルドが通ることを確認する。

typescript
// packages/shared/src/index.ts -- 公開するインターフェースの一覧 export type { Tenant, TenantSettings, CRMIntegrationConfig } from './tenant' export type { HealthScoreInput, HealthScoreOutput, HealthScoreWeights, RiskFactor, Recommendation } from './health-score' export type { GTMEvent, UserLoginEvent, FeatureUsedEvent, PlanUpgradeAttemptedEvent } from './events'

この構造により、GTMパッケージの開発者はプロダクトのコードを理解していなくても、shared/の型定義さえ把握すればGTM機能を開発できる。逆にプロダクトの開発者は、shared/の型に従ってイベントを発行すればGTM側の処理に関知しなくてよい。

開発ワークフロー

プロダクトエンジニアとGTMエンジニアの協業

モノレポの利点は、異なる役割のエンジニアが同じリポジトリで作業できることだ。しかし、レビューの観点は役割によって異なる。

プロダクトエンジニアのPRレビュー観点:

観点確認内容
ユーザー体験UIの整合性、レスポンス速度、エラーハンドリング
ビジネスロジック計算の正確性、エッジケース、データ整合性
セキュリティ認証・認可、入力バリデーション、XSS対策
パフォーマンスN+1クエリ、不要な再レンダリング、バンドルサイズ

GTMエンジニアのPRレビュー観点:

観点確認内容
CRMデータ整合性同期されるフィールドの型と値の範囲
イベント網羅性新機能に対応するGTMイベントが定義されているか
通知の適切性営業チームへの通知が過剰/不足していないか
テナント分離GTMの処理がテナント境界を超えていないか
レート制限CRM APIのレート制限を考慮した実装か

PRのCODEOWNERSでGTMパッケージの変更にはGTMエンジニアのレビューが必須、プロダクトパッケージの変更でイベント関連のコードが含まれる場合はGTMエンジニアにもレビューを依頼する設定にする。

# CODEOWNERS
packages/gtm/**                @gtm-engineering-team
packages/shared/src/events.ts  @gtm-engineering-team @product-team
packages/shared/src/health-score.ts @gtm-engineering-team
packages/backend/src/middleware/tenant.ts @product-team @gtm-engineering-team

テスト戦略

GTMパッケージのテストは3層構造で設計する。

1. 単体テスト: packages/gtm/内で完結

ヘルススコアの計算ロジック、リスク検出、通知ペイロードの生成。外部依存を全てモックし、純粋関数として検証する。

typescript
// packages/gtm/health-score/src/__tests__/calculator.test.ts import { describe, it, expect } from 'vitest' import { calculateHealthScore } from '../calculator' describe('calculateHealthScore', () => { it('全メトリクスが良好な場合、80以上のスコアを返す', () => { const result = calculateHealthScore({ tenantId: 'tenant-1', period: '30d', metrics: { loginFrequency: 0.4, featureAdoptionRate: 0.7, apiCallTrend: 1.3, supportTicketCount: 2, dataVolumeGrowthRate: 1.1, }, }) expect(result.score).toBeGreaterThanOrEqual(80) expect(result.riskLevel).toBe('low') expect(result.riskFactors).toHaveLength(0) }) it('ログイン頻度が極端に低い場合、criticalリスクを検出する', () => { const result = calculateHealthScore({ tenantId: 'tenant-2', period: '30d', metrics: { loginFrequency: 0.005, featureAdoptionRate: 0.3, apiCallTrend: 0.8, supportTicketCount: 5, dataVolumeGrowthRate: 0.9, }, }) expect(result.score).toBeLessThan(40) expect(result.riskFactors).toContainEqual( expect.objectContaining({ type: 'login_decline', severity: 'critical', }) ) }) it('カスタム重み付けが反映される', () => { const input = { tenantId: 'tenant-3', period: '30d' as const, metrics: { loginFrequency: 0.1, featureAdoptionRate: 0.1, apiCallTrend: 2.0, supportTicketCount: 0, dataVolumeGrowthRate: 1.5, }, } const defaultResult = calculateHealthScore(input) const apiHeavyResult = calculateHealthScore(input, { apiUsage: 0.5, loginFrequency: 0.1, }) // APIを重視する重み付けでは、APIトレンドが良好なのでスコアが上がる expect(apiHeavyResult.score).toBeGreaterThan(defaultResult.score) }) })

2. 統合テスト: packages/gtm/ + packages/db/

Prismaクライアントを介したデータの読み書きを含むテスト。テスト用のPostgreSQLインスタンスを使い、テナント分離が正しく動作することを検証する。

3. E2Eテスト: 全パッケージ横断

「プロダクトでユーザーがログインする → GTMイベントが発行される → ヘルススコアが再計算される → CRMにスコアが同期される → 営業チームに通知が送られる」という一連のフローを通しで検証する。CRM APIはモックサーバーを使う。

MCPサーバーとの統合

packages/mcp-servers/はClaude Code Agent TeamからGTMデータに安全にアクセスするためのインターフェース層だ。MCPサーバーの詳細で掘り下げるが、構造だけ示す。

typescript
// packages/mcp-servers/analytics/src/index.ts // 分析データ参照用MCPサーバー import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' const server = new McpServer({ name: 'gtm-analytics', version: '1.0.0', }) server.tool( 'get_tenant_health_score', { tenantId: z.string(), period: z.enum(['7d', '30d', '90d']).optional(), }, async ({ tenantId, period }) => { const score = await getLatestHealthScore(tenantId, period ?? '30d') return { content: [{ type: 'text', text: JSON.stringify(score, null, 2), }], } } ) server.tool( 'get_upsell_opportunities', { tenantId: z.string(), }, async ({ tenantId }) => { const signals = await detectUpsellSignals(tenantId) return { content: [{ type: 'text', text: JSON.stringify(signals, null, 2), }], } } ) server.tool( 'get_churn_predictions', { riskLevel: z.enum(['high', 'imminent']).optional(), }, async ({ riskLevel }) => { const predictions = await getChurnPredictions(riskLevel) return { content: [{ type: 'text', text: JSON.stringify(predictions, null, 2), }], } } )

MCPサーバーをGTMパッケージと同じモノレポに置くことで、型定義の共有とデプロイの同期が保証される。

このアーキテクチャで何が変わったか

このモノレポ構成を実際に運用してみると、「GTMをプロダクトに組み込む」という言葉の意味がより具体的にわかった。

packages/gtm/をプロダクトと同列に置いたことで、新機能のPRに「GTMイベントの追加」が含まれるのが当たり前になった。以前は機能リリースの数週間後にGTMチームが「あの機能のイベント入れてほしい」と言いに来ていた。今はリリースPRのCODEOWNERSレビューで自然に確認される。

packages/shared/の型定義が契約として機能することも実感している。プロダクト側がUser型にindustryフィールドを追加したとき、CRM同期のコードが即座にコンパイルエラーになった。以前だったらそのまま本番に出て、CRMにindustryなしのデータが流れ続けていたはずだ。

Product-Embedded型のGTMエンジニアとして、このアーキテクチャの設計と進化に責任を持っている。プロダクトエンジニアとの協業、営業チームとの対話を通じて、プロダクトに埋め込まれたGTM機能の精度を上げていく。

次の記事ではヘルススコアによるチャーン予兆検知の実装に入る。packages/gtm/health-score/の計算ロジックを具体的なアルゴリズムとテストコードとともに示す。


参考資料

$ echo $TAGS
#TypeScript#モノレポ#BtoB SaaS#マルチテナント#Turborepo#Hono