SaaSの個社カスタマイズをSlotパターンで設計した話

Namiki Chikusa

Namiki Chikusa

SENDAI事業部 プロダクトCTO

こんにちは、SENDAI事業部でプロダクトCTOを務めている千種です。

SENDAIはSaaSですが、導入企業ごとに画面や機能をカスタマイズして提供しています。A社にはホーム画面を専用に作り、B社には独自のレポートページを追加する、という具合です。

共通基盤(core)は全テナントで同じものを使いつつ、特定の会社だけ見た目や振る舞いを変える。この記事では、その設計判断と実装の仕組みを紹介します。

よくあるカスタマイズ手法と課題

SaaSで個社対応をやろうとすると、まず思いつくのは if (company === 'X') のような条件分岐をcoreに足すことです。手っ取り早いですが、会社が増えるたびに分岐が増えてcoreが読めなくなります。テストも分岐の組み合わせで爆発します。

次に検討しがちなのが、会社ごとにリポジトリをフォークする方法です。分離はできますが、coreのバグ修正や機能追加を全フォークに反映するコストが膨大で、バージョンが乖離すると共通の改善が届かなくなります。

会社ごとにサーバーやDBを分けるマルチインスタンス方式もありますが、インフラコストとデプロイ・監視の運用負荷が会社数に比例して増えます。

DBに設定値を持たせるテーブル方式は比較的軽量ですが、単純なON/OFFや文字の差替えには使えても、画面レイアウトの変更や専用ページの追加には対応できません。

SENDAIではSlotという仕組みでこれらを回避しました。

  • インフラは共通 全テナントが同じサーバー・同じDBを使う
  • core に条件分岐を書かない カスタマイズは専用のディレクトリに隔離する
  • 1 リポジトリ・1 デプロイ フォークせず、全テナント分を同じデプロイで反映する
  • DB スイッチで即時 ON/OFF 再デプロイなしで切り替えられ、障害時はすぐ戻せる

カスタマイズで DB スキーマの変更が必要になる場合(そのテナントだけが使うテーブルの追加など)は、共通 DB 上にテナント専用テーブルを作ることになります。テーブルが増えるほどマイグレーションの管理コストは上がるため、まずは既存テーブルの設定値や viewConfig で吸収できないかを先に検討しています。

Slot とは

Slotは、画面へ「ここは差し替え可能」という拡張ポイントを埋め込むパターンです。Vue.jsの <slot> やWeb ComponentsのShadow DOM slotと同じ着想ですが、SENDAIではテナント単位で動的に切り替えられるよう拡張しています。

coreの画面に <Slot> コンポーネントで枠だけを置き、枠に何をはめるかは会社ごとのmanifestで定義します。core側には条件分岐を書きません。

ファイル構成はこうなっています。coreとcustomizationsが分離しているのがポイントです。

## フロントエンド
frontend/src/
├── shared/customization/          ← Slot 基盤(原則触らない)
│   ├── types.ts
│   ├── context.tsx                ← CustomizationProvider / useSlot
│   ├── Slot.tsx                   ← <Slot> コンポーネント
│   └── manifest-schema.ts
│
├── customizations/                ← 会社別カスタマイズ
│   └── <COMPANY_CODE>/
│       ├── manifest.json          ← slot / page / api / viewConfig 定義
│       ├── components/            ← slot 差替用コンポーネント
│       └── pages/                 ← 新規ページ

## バックエンド
backend/app/
├── customizations/                ← 会社別バックエンドカスタマイズ
│   ├── router_registry.py         ← カスタムルーター登録(唯一の core → customizations 接点)
│   └── <COMPANY_CODE>/
│       ├── domain/                ← ドメインモデル・VO
│       ├── application/           ← ユースケース・DTO
│       ├── infrastructure/        ← リポジトリ実装
│       └── presentation/

できること

会社ごとの manifest.json に宣言できるカスタマイズは5種類です。この記事ではslotsを中心に扱います。

セクションできること
slots既存画面の一部を差し替える
pages会社専用ページとサイドバーメニューを追加する
apis会社専用のバックエンド API を追加する
viewConfig一覧の列を会社単位で隠す
additionalTabs既存ページに会社専用タブを足す

DBのスイッチがONの会社だけmanifestが読み込まれ、画面に反映されます。

DB のスイッチ + manifest の宣言が、画面の Slot に反映される流れ

Slotの差し替え方

差し替え方は4パターンです。

モード動作
replaceデフォルトをカスタムにまるごと置き換え
prependデフォルトの前にカスタムを足す
appendデフォルトの後ろにカスタムを足す
wrapカスタムでデフォルトを外側から包む

4 つのモード。青緑がカスタムコンポーネント

replaceの例です。A社はホーム画面をまるごと自社仕様に置き換えています。他の会社の画面は変わりません。

replace の例。破線の枠が Slot の範囲

appendの例です。「ボタンを1個足したいだけ」ならこれで済みます。

append の例。デフォルトはそのまま、ボタンだけ追加

wrapは見た目を変えないモードです。既存画面の外側にContext Providerを差し込むときに使います。

使い方

ステップ 1:coreの画面に枠を置きます。デフォルトUIをchildrenとして包むだけです。渡したいデータがあれば slotProps に載せます。

import { Slot } from '@/shared/customization';

<Slot name='dashboard.header' slotProps={{ period }}>
  <DefaultHeader period={period} />  {/* 差し替えがなければこれが出る */}
</Slot>

{/* 追加専用の枠は中身を空にしておく(図4 のボタンの差し込み口) */}
<Slot name='dashboard.toolbar-actions'><></></Slot>

ステップ 2:会社フォルダのmanifest.jsonに「どの枠に、何を、どのモードで」を書きます。

{
  "version": 1,
  "slots": {
    "home.content": {
      "mode": "replace",
      "component": "./pages/CustomHome"
    },
    "dashboard.toolbar-actions": {
      "mode": "append",
      "component": "./components/CustomToolbarActions"
    }
  }
}

ステップ 3:はめ込むコンポーネントを作ります。slotPropsのキーがpropsで届きます。

'use client';

// default export が必須
export default function CustomToolbarActions() {
  return <Button>データ更新</Button>;
}

export default を忘れると実行時エラーになります。core のコードには手を入れず、必要なモジュールを import して使います。

ON / OFF の切り替え

スイッチは2段階です。会社全体の customization_enabled が親スイッチ、slotごとの featureKey が子スイッチです。どちらもDBの値なので、再デプロイなしで切り替えられます。

親スイッチ子スイッチ(featureKey)結果
ONなし、または ON差し替わる
ONOFFその slot だけデフォルトに戻る
OFF何でも全部デフォルトに戻る

おわりに

個社カスタマイズは放っておくとcoreが条件分岐で埋まり、誰も触りたくないコードになります。 Slotパターンを入れてからは、coreの見通しを保ったまま個社対応を進められるようになりました。

仕組み自体は単純なので、同じ課題を抱えている方の参考になればうれしいです。

Namiki Chikusa

Namiki Chikusa

SENDAI事業部 プロダクトCTO

// we are hiring

STARUPで一緒に働きませんか?

こうした技術的な挑戦を一緒に楽しめる仲間を募集しています。

採用情報を見る →