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

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が読み込まれ、画面に反映されます。

Slotの差し替え方
差し替え方は4パターンです。
| モード | 動作 |
|---|---|
| replace | デフォルトをカスタムにまるごと置き換え |
| prepend | デフォルトの前にカスタムを足す |
| append | デフォルトの後ろにカスタムを足す |
| wrap | カスタムでデフォルトを外側から包む |

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

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

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 | 差し替わる |
| ON | OFF | その slot だけデフォルトに戻る |
| OFF | 何でも | 全部デフォルトに戻る |
おわりに
個社カスタマイズは放っておくとcoreが条件分岐で埋まり、誰も触りたくないコードになります。 Slotパターンを入れてからは、coreの見通しを保ったまま個社対応を進められるようになりました。
仕組み自体は単純なので、同じ課題を抱えている方の参考になればうれしいです。