mybook

Land & Expand — 小さく入って大きく育てる

「ソウタ、今四半期の数字、見た?」

カイが Slack で送ってきたリンクを開くと、Arclight AI の四半期レビュー資料が表示された。新規顧客獲得数は前四半期比 +12%。一見するとよい数字だ。

「悪くないと思いましたけど」

ソウタが返すと、カイはすぐにビデオ通話を立ち上げた。

「数字だけ見ればそう見える。でもな、新規獲得のCAC――顧客獲得コストは前年比で 35% 上がってる。一方で、既存顧客からのエクスパンション収益は 2 倍に伸びてる」

カイが画面共有で 2 つのグラフを並べた。

「新規顧客のコンバージョン率は 5 から 20%。既存顧客のアップセル・クロスセルのコンバージョン率は 60 から 70%。どっちに注力すべきかは明らかだろ?」

ソウタは息を飲んだ。新規開拓の 3 倍以上の確率で成約するなら、既存顧客の拡大に技術リソースを割くのは理にかなっている。

「今日からお前には、カスタマーヘルススコアシステムを作ってもらう。技術的なシグナルから拡大のチャンスを自動検出する仕組みだ」


エクスパンションの 4 つの型

カイがホワイトボードツールを開き、4 つの拡大パターンを描いた。

Loading diagram...

1. アカウント拡大 (Account Growth)

「最初は開発チーム 5 人で導入した顧客が、半年後にはデータサイエンスチーム 20 人にも広がる。これがアカウント拡大だ」

2. 利用量拡大 (Usage Growth)

「API コール数が月 10 万から 50 万に増える。従量課金モデルなら、利用量の増加がそのまま収益増加になる」

3. アップグレード (Upgrade Growth)

「Starter プランから Enterprise プランへの移行。機能制限に当たったとき、上位プランへの移行を自然に提案できるかが鍵だ」

4. クロスセル (Cross-sell Growth)

「うちの AI プラットフォームを使ってる顧客に、データパイプラインツールやモニタリングサービスも使ってもらう」

INFO

既存顧客のコンバージョン率 60-70% に対して、新規顧客は 5-20%。既存顧客へのエクスパンション投資は、新規開拓の 3 倍以上の ROI が見込める。


NRR が企業価値を決める

「ソウタ、SaaS 企業の評価で最も重要な指標が何か知ってるか?」

「ARR とか成長率ですか?」

「それも大事だが、投資家が最初に見るのは NRR――Net Revenue Retention だ」

NRR の計算式

NRR = (開始MRR + 拡大MRR - 縮小MRR - チャーンMRR) / 開始MRR × 100

たとえば月初 MRR が 1,000 万円の顧客群で、拡大が +200 万円、縮小(ダウングレード)が -50 万円、解約が -30 万円なら:

NRR = (1,000 + 200 - 50 - 30) / 1,000 × 100 = 112%

NRR が 100% を超えていれば、新規顧客を 1 社も獲得しなくても収益が成長していることを意味する。

セグメント別ベンチマーク

セグメントNRR 中央値特徴
Enterprise SaaS118%大規模拡大が起きやすい
Mid-market108%バランス型の成長
SMB97%チャーンが拡大を上回りやすい

NRR と企業価値の関係

NRREV/Revenue 倍率
120% 以上9.3x
100-120%5.8x
100% 未満3.1x

「NRR 120% 以上の企業は、100% 未満の企業に比べて 3 倍の評価を受ける。技術チームが NRR に貢献できるなら、それは直接的に企業価値を高めることになる」

WARNING

SMB セグメントでは NRR が 100% を下回ることが多い。SMB 中心のビジネスモデルでは、エクスパンション戦略だけでなくチャーン防止も同時に取り組む必要がある。

エクスパンション収益の重要性の高まり

年度新規 ARR に占めるエクスパンション比率
202225%
202333%
202440%

「エクスパンションが新規 ARR の 40% を占めるようになった。もはや"おまけ"じゃない。成長戦略の中核だ」


FDE がエクスパンションを技術で駆動する 6 つのパス

「具体的に、エンジニアが NRR に貢献する方法を教えてください」

  • API 利用量の可視化と最適化提案 — 利用パターンを分析し、効率的な使い方を提案。信頼を獲得しつつ利用量が増える
  • カスタムインテグレーション構築 — 既存システムとの連携でプロダクトの"粘着性"を高め、他部門への展開を促進
  • パフォーマンス改善 — レスポンスタイムやバッチ処理の最適化で、より多くのユースケースでの利用を可能に
  • 新機能の早期アクセスプログラム — 主要顧客への先行提供でアップグレードのモチベーションを高める
  • テクニカルワークショップ — 顧客企業のエンジニアに高度な使い方を教え、利用量の自然増加を促す
  • プロダクトフィードバックの構造化 — 技術的実現可能性とビジネスインパクトを整理してプロダクトチームに渡す

エクスパンションシグナルの検出

「拡大のチャンスは、顧客が口に出す前に技術的なシグナルとして現れる。これを見逃さないシステムを作る」

シグナル意味アクション
API レート制限の接触利用量が上限に迫っている上位プランの提案
未契約機能の API 探索新機能への関心デモ・トライアルの提供
新しい IP アドレスからのアクセス別部門・拠点での利用開始アカウント拡大の提案
Webhook 設定の増加インテグレーション拡張中カスタム連携の支援
エラーレート上昇 + 利用量増スケール課題アーキテクチャ支援
ドキュメントの大量閲覧新ユースケースの検討中プロアクティブな連絡

カスタマーヘルススコアシステムの構築

ソウタは Rails プロジェクトを開いた。Arclight AI のカスタマーサクセス基盤に、ヘルススコアシステムを組み込む。

データモデル

# db/migrate/20260628_create_customer_health_scores.rb
class CreateCustomerHealthScores < ActiveRecord::Migration[7.2]
  def change
    create_table :customer_health_scores do |t|
      t.references :customer, null: false, foreign_key: true
      t.integer :overall_score, null: false, default: 0
      t.integer :usage_score, null: false, default: 0
      t.integer :engagement_score, null: false, default: 0
      t.integer :support_score, null: false, default: 0
      t.integer :expansion_potential, null: false, default: 0
      t.string :health_status, null: false, default: "neutral"
      t.jsonb :score_breakdown, null: false, default: {}
      t.jsonb :expansion_signals, null: false, default: []
      t.date :scored_on, null: false
      t.timestamps
    end
 
    add_index :customer_health_scores, %i[customer_id scored_on], unique: true
    add_index :customer_health_scores, :health_status
    add_index :customer_health_scores, :expansion_potential
  end
end

ヘルススコアモデル

# app/models/customer_health_score.rb
class CustomerHealthScore < ApplicationRecord
  belongs_to :customer
 
  HEALTH_STATUSES = %w[critical at_risk neutral healthy thriving].freeze
 
  validates :health_status, inclusion: { in: HEALTH_STATUSES }
  validates :overall_score, :usage_score, :engagement_score,
            :support_score, :expansion_potential,
            inclusion: { in: 0..100 }
 
  scope :latest, -> { order(scored_on: :desc).limit(1) }
  scope :thriving, -> { where(health_status: "thriving") }
  scope :high_expansion, -> { where("expansion_potential >= ?", 70) }
 
  def expansion_ready?
    health_status.in?(%w[healthy thriving]) && expansion_potential >= 60
  end
 
  def risk?
    health_status.in?(%w[critical at_risk])
  end
end

ヘルススコア計算サービス

# app/services/health_score_calculator.rb
class HealthScoreCalculator
  WEIGHTS = { usage: 0.35, engagement: 0.30, support: 0.20, expansion: 0.15 }.freeze
 
  def initialize(customer)
    @customer = customer
    @period = 30.days.ago..Time.current
  end
 
  def calculate
    scores = {
      usage_score: calculate_usage_score,
      engagement_score: calculate_engagement_score,
      support_score: calculate_support_score,
      expansion_potential: calculate_expansion_potential
    }
 
    overall = WEIGHTS.sum { |key, weight| scores[:"#{key}_score"] || scores[:"#{key}"] || 0 * weight }
    overall = (scores[:usage_score] * WEIGHTS[:usage] +
               scores[:engagement_score] * WEIGHTS[:engagement] +
               scores[:support_score] * WEIGHTS[:support] +
               scores[:expansion_potential] * WEIGHTS[:expansion]).round
 
    CustomerHealthScore.create!(
      customer: @customer, overall_score: overall,
      health_status: determine_status(overall),
      expansion_signals: ExpansionSignalDetector.new(@customer, @period).detect,
      scored_on: Date.current, **scores
    )
  end
 
  private
 
  def calculate_usage_score
    metrics = @customer.api_usage_metrics.where(recorded_at: @period)
    return 0 if metrics.empty?
 
    utilization = (metrics.average(:request_count).to_f /
                   @customer.plan.daily_api_limit.to_f * 100).clamp(0, 100)
 
    case utilization
    when 70..100 then 90
    when 40..69 then 70
    when 20..39 then 50
    else 20
    end
  end
 
  def calculate_engagement_score
    active = @customer.users.where(last_active_at: @period).count
    total = @customer.users.count
    return 0 if total.zero?
 
    adoption = (active.to_f / total * 100).round
    used = @customer.feature_usages.where(used_at: @period).distinct.count(:feature_name)
    available = Feature.available_for(@customer.plan).count
    breadth = available.positive? ? (used.to_f / available * 100).round : 0
 
    [adoption * 0.5 + breadth * 0.5, 100].min.round
  end
 
  def calculate_support_score
    tickets = @customer.support_tickets.where(created_at: @period)
    return 85 if tickets.empty?
 
    resolved = tickets.where(status: "resolved").count
    critical = tickets.where(severity: "critical").count
    rate = tickets.count.positive? ? (resolved.to_f / tickets.count * 100) : 100
 
    [rate - critical * 10, 0].max.round
  end
 
  def calculate_expansion_potential
    signals = ExpansionSignalDetector.new(@customer, @period).detect
    signal_score = [signals.length * 15, 60].min
 
    utilization = ((@customer.api_usage_metrics.where(recorded_at: @period)
                    .average(:request_count).to_f /
                    @customer.plan.daily_api_limit.to_f) * 100).clamp(0, 100)
    growth_bonus = utilization > 80 ? 30 : 0
 
    renewal_bonus = @customer.current_contract&.ends_at&.< (90.days.from_now) ? 10 : 0
 
    [signal_score + growth_bonus + renewal_bonus, 100].min
  end
 
  def determine_status(score)
    case score
    when 80..100 then "thriving"
    when 60..79 then "healthy"
    when 40..59 then "neutral"
    when 20..39 then "at_risk"
    else "critical"
    end
  end
end

「重要なのは重み付けだ。Usage を 35% と最も高くしているのは、利用量が最も客観的で予測力のある指標だからだ」


エクスパンションシグナル検出器

次にソウタは、拡大シグナルを自動検出するサービスを実装した。

# app/services/expansion_signal_detector.rb
class ExpansionSignalDetector
  Signal = Data.define(:type, :severity, :description, :detected_at, :metadata)
 
  def initialize(customer, period = 30.days.ago..Time.current)
    @customer = customer
    @period = period
    @signals = []
  end
 
  def detect
    check_rate_limit_proximity
    check_new_access_sources
    check_feature_exploration
    check_usage_growth_trend
 
    @signals.sort_by { |s| %w[low medium high].index(s.severity) }.reverse
  end
 
  private
 
  def check_rate_limit_proximity
    recent = @customer.api_usage_metrics.where(recorded_at: @period)
                      .order(recorded_at: :desc).limit(7)
    return if recent.empty?
 
    limit = @customer.plan.daily_api_limit
    days_over = recent.count { |m| m.request_count > limit * 0.8 }
    return unless days_over >= 3
 
    @signals << Signal.new(
      type: "rate_limit_proximity", severity: "high",
      description: "過去7日間で#{days_over}日がAPIレート制限の80%を超過",
      detected_at: Time.current,
      metadata: { plan_limit: limit, peak_usage: recent.maximum(:request_count) }
    )
  end
 
  def check_new_access_sources
    recent_ips = @customer.access_logs.where(accessed_at: @period).distinct.pluck(:ip_address)
    previous_ips = @customer.access_logs
                            .where(accessed_at: 60.days.ago..@period.first)
                            .distinct.pluck(:ip_address)
    new_ips = recent_ips - previous_ips
    return unless new_ips.size >= 3
 
    @signals << Signal.new(
      type: "new_access_sources", severity: "medium",
      description: "#{new_ips.size}個の新しいIPアドレスからアクセスを検出(新部門の可能性)",
      detected_at: Time.current,
      metadata: { new_ip_count: new_ips.size }
    )
  end
 
  def check_feature_exploration
    premium = Feature.premium_only.pluck(:name)
    explored = @customer.feature_access_attempts
                        .where(attempted_at: @period, feature_name: premium)
                        .distinct.pluck(:feature_name)
    return if explored.empty?
 
    @signals << Signal.new(
      type: "feature_exploration", severity: "high",
      description: "上位プラン機能 #{explored.join(', ')} へのアクセス試行を検出",
      detected_at: Time.current,
      metadata: { explored_features: explored }
    )
  end
 
  def check_usage_growth_trend
    current = @customer.api_usage_metrics.where(recorded_at: 30.days.ago..Time.current).sum(:request_count)
    previous = @customer.api_usage_metrics.where(recorded_at: 60.days.ago..30.days.ago).sum(:request_count)
    return if previous.zero?
 
    growth = ((current - previous).to_f / previous * 100).round
    return unless growth >= 50
 
    @signals << Signal.new(
      type: "usage_growth", severity: growth >= 100 ? "high" : "medium",
      description: "API利用量が前月比 +#{growth}% で成長中",
      detected_at: Time.current,
      metadata: { growth_rate: growth, current: current, previous: previous }
    )
  end
end

ヘルススコアの計算フローを図にまとめる。

Loading diagram...

API エンドポイントの実装

ソウタは、カスタマーサクセスチームが使うダッシュボード向けの API を実装した。

# app/controllers/api/v1/customer_health_controller.rb
module Api
  module V1
    class CustomerHealthController < ApplicationController
      before_action :authenticate_api_key!
      before_action :set_customer, only: :show
 
      # GET /api/v1/customers/:customer_id/health
      def show
        score = @customer.customer_health_scores.latest.first
        return render json: { error: "ヘルススコアが未計算です" }, status: :not_found unless score
 
        render json: {
          customer_id: @customer.id, customer_name: @customer.name,
          scored_on: score.scored_on, overall_score: score.overall_score,
          health_status: score.health_status,
          scores: { usage: score.usage_score, engagement: score.engagement_score,
                    support: score.support_score, expansion_potential: score.expansion_potential },
          expansion_ready: score.expansion_ready?,
          signals: score.expansion_signals
        }
      end
 
      # GET /api/v1/customers/expansion_opportunities
      def expansion_opportunities
        opportunities = CustomerHealthScore
          .where(scored_on: Date.current).high_expansion
          .joins(:customer).order(expansion_potential: :desc).limit(20)
 
        render json: {
          date: Date.current, count: opportunities.size,
          opportunities: opportunities.map { |s|
            { customer_id: s.customer_id, customer_name: s.customer.name,
              expansion_potential: s.expansion_potential, signals: s.expansion_signals,
              current_plan: s.customer.plan.name, mrr: s.customer.current_mrr }
          }
        }
      end
 
      private
 
      def set_customer
        @customer = Customer.find(params[:customer_id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "顧客が見つかりません" }, status: :not_found
      end
    end
  end
end

INFO

expansion_opportunities エンドポイントは、カスタマーサクセスチームが毎朝確認する「今日アプローチすべき顧客リスト」として使われる。技術シグナルがビジネスアクションに直結する好例だ。


AWS によるリアルタイムアラートパイプライン

「日次バッチだけだと見逃すシグナルがある。リアルタイムで閾値を超えたら即座にアラートを飛ばす仕組みも作ろう」

Loading diagram...

CloudWatch カスタムメトリクスの送信

# app/services/usage_metrics_publisher.rb
class UsageMetricsPublisher
  def initialize
    @cloudwatch = Aws::CloudWatch::Client.new(region: "ap-northeast-1")
    @namespace = "ArclightAI/CustomerUsage"
  end
 
  def publish_api_usage(customer_id:, request_count:, plan_limit:)
    utilization = (request_count.to_f / plan_limit * 100).round(2)
    dims = [{ name: "CustomerId", value: customer_id.to_s }]
 
    @cloudwatch.put_metric_data(
      namespace: @namespace,
      metric_data: [
        { metric_name: "ApiRequestCount", dimensions: dims,
          timestamp: Time.current, value: request_count, unit: "Count" },
        { metric_name: "PlanUtilization", dimensions: dims,
          timestamp: Time.current, value: utilization, unit: "Percent" }
      ]
    )
  end
end

CloudWatch Alarm の設定

# lib/tasks/setup_alarms.rake
namespace :cloudwatch do
  desc "高利用率顧客向けアラームを設定"
  task setup_expansion_alarms: :environment do
    cloudwatch = Aws::CloudWatch::Client.new(region: "ap-northeast-1")
    sns_arn = ENV.fetch("EXPANSION_ALERT_SNS_ARN")
 
    Customer.active.find_each do |customer|
      cloudwatch.put_metric_alarm(
        alarm_name: "HighUtilization-#{customer.id}",
        namespace: "ArclightAI/CustomerUsage",
        metric_name: "PlanUtilization",
        dimensions: [{ name: "CustomerId", value: customer.id.to_s }],
        statistic: "Average", period: 3600, evaluation_periods: 6,
        threshold: 80.0, comparison_operator: "GreaterThanOrEqualToThreshold",
        alarm_description: "#{customer.name}のAPI利用率が80%を超過",
        alarm_actions: [sns_arn], treat_missing_data: "notBreaching"
      )
    end
 
    puts "全アクティブ顧客のアラームを設定完了"
  end
end

WARNING

CloudWatch Alarms は evaluation_periods を短くしすぎるとノイズが増える。API 利用率のアラートは 6 時間(period: 3600 x evaluation_periods: 6)以上で設定するのがベストプラクティス。


振り返り

夜、ソウタはその日のコードを見返していた。ヘルススコア、シグナル検出、CloudWatch アラート。すべてが「既存顧客をもっと成功させる」という一点に向かっている。

カイから最後にもらったメッセージを思い出した。

「FDE の仕事は、顧客に新しいものを売りつけることじゃない。顧客が気づく前に、もっと価値を受け取れるタイミングを見つけることだ。それを技術で検出して、適切な人に適切なタイミングで届ける。結果として、売上が伸びる」

Land & Expand。小さく入って大きく育てる。新規獲得に比べて 3 倍のコンバージョン率。NRR 120% 以上の企業が受ける 9.3 倍の評価。エクスパンション収益が新規 ARR の 40% を占める時代。

ソウタは、エンジニアリングが "コストセンター" ではなく "レベニューエンジン" になれることを、データとコードの両面から実感した。

INFO

FDE の価値は、技術スキルとビジネスインパクトの掛け算にある。ヘルススコアシステムのような仕組みは、1 人の FDE の判断力を組織全体にスケールさせる手段だ。

次章では、顧客との信頼を維持しながらプロダクトの限界と向き合う「トレードオフの交渉術」について学ぶ。