初めに
最近、RAG・NL2SQL・AI Agent の3つの業務アプリを並行して作っています。
最初はそれぞれ別のリポジトリで、別のペースで開発していました。機能を作るのは速かったのですが、しばらくすると次のような状態になっていました。
- ボタン・ページヘッダー・入力欄が、製品ごとに3〜4回ずつ再実装されている
- ある製品だけダークテーマがあり、他の製品はライト専用
- 「OCI 認証」「データベース設定」「ユーザー・ロール管理」のような、どの製品にも必要な画面と API がそれぞれ微妙に違う形で存在する
- 同じ「保存に失敗した」でも、製品によって Toast だったり、ページ上部のバナーだったりする
AI で実装が速くなった分、ズレも速く増えるのを実感しました。
そこで、4つのリポジトリを1つの monorepo にまとめ、共通の UI/UX と共通機能を platform/ に寄せました。この記事では、その 「どうやって共通化したか」 の仕組みを整理します。特定の製品の紹介ではなく、複数の AI アプリ(あるいは複数の業務アプリ)で UI/UX と共通機能を揃えたい方の参考になれば嬉しいです。
結論
先に結論を書くと、効いたのは次の6つです。
-
依存の向きを
platform → 各製品の一方向に固定する(製品同士はコードで依存しない) - 見た目はトークン + 共有コンポーネントだけが持つ。製品は「ナビ・文言・データ・enum の対応表」だけを持つ
- ルールは lint で機械的に守る。lint の設定もコピーせず、正本を相対パスで参照する
- 画面の「振る舞い」も UX 契約として文書化する(メッセージの出し方・ページの型・作業状態の保持)
- 共通機能は「画面は package、API は router factory」で提供し、製品固有の部分は依存注入で渡す
- 製品間の連携は HTTP(MCP + 短命のサービストークン)にする
個人的には、2 と 5 の「製品が持ってよいもの」を明文化したことが一番大きかったと思います。
全体像はこうなります。
以下、順番に説明します。
1. monorepo の構成と「依存の向き」
ここで解決したいこと
共通部品を作っても、製品側で「ちょっと足りないから自前で作る」が始まると、すぐに元に戻ります。なので、最初に依存の向きを決めました。
production-ready-suite/
├── platform/
│ ├── packages/
│ │ ├── ui/ # 共有 UI(React コンポーネント + トークン CSS)
│ │ ├── system-settings/ # 共通のシステム設定画面・ユーザー/ロール管理画面
│ │ ├── backend_core/ # FastAPI の共通基盤(pr_backend_core)
│ │ └── system_settings_backend/ # 共通の設定 API・認証(pr_system_settings)
│ ├── docs/
│ │ ├── design-system/ # 見た目の正本
│ │ └── ux-contracts/ # 振る舞いの正本
│ └── contracts/mcp/ # 製品間 API(MCP ツール)の契約
├── rag/ { backend/, frontend/ }
├── nl2sql/ { backend/, frontend/ }
└── agent/ { backend/, frontend/ }
ルールはシンプルです。
-
platform/→ 各製品 の一方向だけ - 製品同士はコードで import しない(連携は HTTP API)
- 逆流させない:製品で見つけた改善は、製品に実装してから昇格させるのではなく、platform 側で決めてから入れる
パッケージは publish しない(相対パス参照)
monorepo なので、npm / PyPI に publish せず、相対パスでソースを直接参照しています。
// rag/frontend/package.json(抜粋)
{
"dependencies": {
"production-ready-system-settings": "file:../../platform/packages/system-settings",
"production-ready-ui": "file:../../platform/packages/ui"
}
}
# nl2sql/backend/pyproject.toml(抜粋)
[tool.uv.sources]
production-ready-backend-core = { path = "../../platform/packages/backend_core", editable = true }
production-ready-system-settings-backend = { path = "../../platform/packages/system_settings_backend", editable = true }
version pin がないので、platform を壊すと3製品が同時に壊れます。これはデメリットでもありますが、「壊れたらその PR で3製品の利用箇所も直す」ことができるので、段階移行や互換レイヤーがほとんど要りません。その代わり、後述する CI で3製品を同時に検証します。
2. デザイントークン:色は「役割」で持ち、テーマは color-scheme 一本にする
ここで解決したいこと
製品ごとに :root { --primary: ... } と .dark { ... } を持っていると、必ず片方だけ更新される事故が起きます。
3層のトークン
トークンは層に分け、コンポーネントが参照してよい層を決めました。
名前は「色」ではなく「役割」にします(--color-<category>-<role>[-<state>])。
/* platform/packages/ui/src/styles/tokens/colors.css(イメージ) */
:root { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
[data-theme="auto"] { color-scheme: light dark; } /* OS 設定に追従 */
:root, [data-theme], [data-surface] {
/* light-dark() で、1回の宣言でライト / ダーク両方を解決する */
--color-surface: light-dark(var(--neutral-0), var(--neutral-900));
--color-fg-muted: light-dark(var(--neutral-600), var(--neutral-350));
--color-danger-fg: light-dark(var(--red-700), var(--red-300));
}
ポイントは、.dark { 全トークンを書き直す } をやめて color-scheme + light-dark() に一本化したことです。テーマ切り替えは <html> の属性を変えるだけです。
document.documentElement.dataset.theme = "dark"; // "light" | "dark" | "auto"
ライト専用だった製品も、共有トークンに乗り換えた時点でダークテーマが無償で付いてきました。
製品側の globals.css は3行だけ
製品の globals.css は、トークンを「読み込むだけ」にします。
/* <製品>/frontend/src/globals.css */
@import "tailwindcss";
@import "production-ready-ui/styles.css";
/* 共有コンポーネントが使う utility クラスを Tailwind v4 のスキャン対象に入れる */
@source "../node_modules/production-ready-ui/dist";
@source "../node_modules/production-ready-system-settings/dist";
/* ここから下は「画面固有のレイアウト」だけ。色・型・余白・角丸・影は書かない */
Tailwind v4(@tailwindcss/postcss)では、共有 CSS を main.tsx から JS で import すると別の Tailwind ルートとして処理され、@theme inline や @source が効かず、共有ユーティリティが生成されませんでした。CSS は globals.css から @import するのが確実です。
3. 共有コンポーネントと「画面の型」
1画面の書き方を1つに決める
コンポーネントを共有するだけでなく、画面の骨格も1つに決めました。
import {
AppShell, Sidebar, PageHeader, PageBody, Section,
Tabs, DataTable, Pagination,
} from "production-ready-ui";
import { RefreshCw, Upload } from "lucide-react";
export function DocumentListScreen() {
return (
<AppShell sidebar={<Sidebar sections={navSections} currentPath={path} />}>
<PageHeader
title="文書一覧"
subtitle="アップロード済み文書の状態を確認します。"
// JSX の <Button> は渡さない。データで渡すと、並び順(danger → secondary → primary)を部品が決める
actions={[
{ id: "upload", kind: "primary", label: "文書アップロード", icon: Upload },
{ id: "reload", kind: "secondary", label: "再読込", icon: RefreshCw, loading: reloading },
]}
tabs={<Tabs value={view} onChange={setView} items={tabItems} />}
/>
<PageBody>
<Section title="文書">
<DataTable columns={columns} rows={rows} stickyHeader />
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</Section>
</PageBody>
</AppShell>
);
}
ここで意識したのは、**「自由に組める部品」より「迷わない部品」**にすることです。例えば次のようなことは、部品側が決めてしまいます。
| 製品で決めさせないこと | 部品側の決め方 |
|---|---|
| ヘッダーのボタンの並び順 |
actions を配列で受け取り、右端を primary に自動で並べる |
| 処理中のボタンの表示 | ラベルは変えず、icon がスピナーに置き換わる(loading) |
| 余白 |
PageBody が持つ。<div className="px-8 py-6"> は書かない |
| 操作部品の高さ |
size="sm" | "md" | "lg"(32 / 36 / 40px、タッチ端末は 44px) |
| 入力欄の幅 |
width="xs" | "sm" | "md" | "lg" | "full"(入る値の長さで選ぶ) |
| 一覧の高さ |
DataTable の visibleRows(共通の定数)を超えたら表の中でスクロール |
製品が持ってよいもの・持たないもの
ここが一番大事なところです。責任の境界を表にして、全員(人も AI も)が見える場所に置きました。
packages/ui が持つ |
各製品が持つ |
|---|---|
| 色・型・余白・角丸・影・モーションの全トークン |
nav-config.ts(ナビ構造) |
| すべてのコンポーネントと状態(hover / focus / disabled) |
i18n.ts(業務の文言) |
| ARIA・キーボード操作・スキップリンク | データ取得・状態管理・権限 |
| テーマ(light / dark / auto) | ドメイン enum → コンポーネント prop の対応表 |
| 日本語の行組版 | 画面固有の業務レイアウト |
共有部品は業務の語彙を知りません。例えば RAG の文書の状態を StatusBadge で出すとき、対応表は製品が持ちます。
// RAG 側(製品が持つ対応表)
const FILE_STATUS: Record<FileStatus, StatusBadgeProps["variant"]> = {
INDEXED: "success",
PARSING: "warning",
FAILED: "danger",
QUEUED: "neutral",
};
<StatusBadge variant={FILE_STATUS[file.status]} label={t(`fileStatus.${file.status}`)} />
packages/ui に日本語の業務の言葉を足さない、ラベルは翻訳済みの文字列を渡す、というのが原則です。
「1製品しか使わない部品」はどうするか
判断基準は1つだけにしました。
他の2製品がこれを欲しがるか? 欲しがるなら
packages/uiに入れる。
NL2SQL のオントロジーのグラフのように1製品しか使わない部品は、製品側に置いてよいことにしています。
4. ルールは lint で守る(設定もコピーしない)
ここで解決したいこと
ドキュメントにルールを書いても、それだけでは守られません。特に AI に実装させると、#1a73c1 や style={{ marginTop: 2 }} を平気で書いてきます。
adherence の lint を1つ作り、3製品が参照する
遵守ルールの正本を platform/docs/design-system/adherence.oxlintrc.json に置き、AST のセレクタで検出します。
{
"jsPlugins": ["./design-system-plugin.mjs"],
"overrides": [
{
"files": ["src/**/*.ts", "src/**/*.tsx"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["production-ready-ui/**", "!production-ready-ui/styles.css"],
"message": "共有 UI パッケージはルートから import する。内部パス(dist/** 等)に依存しない。"
}]
}],
"design-system/restricted-syntax": ["error",
{
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}(?![\\w-])/]",
"message": "生の hex 色は使わない。色トークン(bg-surface / var(--color-*))を使う。"
},
{
"selector": "JSXAttribute[name.name='style'] Literal[value=/\\b\\d+px\\b/]",
"message": "inline style に生の px を書かない。トークンか Tailwind のユーティリティを使う。"
}
]
}
}
]
}
製品側はコピーせず相対パスで読み込みます。ESLint を使う製品では、同じ JSON を import して no-restricted-syntax に渡しています。
// agent/frontend/eslint.config.js(抜粋)
import adherence from "../../platform/docs/design-system/adherence.oxlintrc.json" with { type: "json" };
const { rules: adherenceRules } = adherence.overrides[0];
export default [
{
files: ["src/**/*.{ts,tsx}"],
rules: {
// 製品固有のルールは、adherence のセレクタを残したまま「末尾に足す」。
// 同じルール名で上書きすると、adherence のセレクタが消えるので注意。
"no-restricted-syntax": [...adherenceRules["design-system/restricted-syntax"], ...productRules],
"no-restricted-imports": adherenceRules["no-restricted-imports"],
},
},
];
今 lint で検出しているのは、例えば次のようなものです。
- 生の hex /
rgba()などの色の関数、inline style の生の px - 削除済みの旧トークン名(
bg-card/var(--primary)など) -
<table>の手書き(→DataTable)、ネイティブの<select>(→SelectField) -
PageHeaderのactionsに JSX を渡す -
loading中にボタンのラベルを差し替える -
animate-spinやLoader2で独自のスピナーを作る(→Spinner) -
focus:ring-*で独自のフォーカス表示を作る
正当な例外は // oxlint-disable-next-line <rule> に理由コメントを添えて局所的に外します。ルール自体を緩めたいときは、platform の Issue を立てて adherence 側を変えます。
新規コードにこの lint を通すのが、ドリフトを止める唯一の現実的な手段です。
これは設計ドキュメントに書いた一文ですが、実際にその通りだったと思います。
5. 見た目だけでなく「振る舞い」も契約にする(UX 契約)
ここで解決したいこと
部品が同じでも、使い方がバラバラだと UX は揃いません。例えば「保存に失敗した」を製品 A は Toast、製品 B はページ先頭のバナーで出す、という状態です。
そこで、デザインシステム(見た目)とは別に、**UX 契約(画面の振る舞い)**を platform/docs/ux-contracts/ に置きました。
| 文書 | 決めていること |
|---|---|
buttons.md |
ボタンの役割・配置・命名、ページ / オブジェクト / 行の操作の置き場所 |
messaging.md |
通知の6チャネル(Toast / FieldError / FormStatus / Banner / ConfirmDialog / State views)の使い分け |
page-archetypes.md |
ページの型(一覧→エディタ / マスタ詳細 / ツール / ダッシュボード)とチャット画面の骨格 |
workspace-state.md |
画面遷移・再読込で作業状態(検索語・ページ番号・タブ)を保持するか |
cross-cutting.md |
認可の server-side 強制、i18n と E2E の locator など |
例えば「操作の結果をどこに出すか」はこう決めています。
チャット画面も、RAG・NL2SQL・Agent で骨格を共通にしました。違うのは「回答の本文」と「対象の選択」くらいで、それは**スロット(子要素)**で製品から渡します。
共通(packages/ui) |
製品 |
|---|---|
履歴パネル・会話の欄・入力欄の行・Enter / IME の判定・自動スクロール・処理の段階(ChatProgress)・評価ボタン |
会話の取得 API・送信 / 停止の API・回答の本文(RAG の引用、NL2SQL の SQL、Agent のツール実行)・文言 |
個人的には、「IME の変換中の Enter で送信しない」のような細かい振る舞いこそ共通化する価値が高いと思います。3製品で別々に実装すると、必ずどれか1つで漏れます。
// 共通の判定。event.key === "Enter" だけで書かない
import { isSubmitEnter } from "production-ready-ui";
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (isSubmitEnter(event)) { // Shift+Enter は改行、IME 変換中は送らない
event.preventDefault();
submit();
}
}
6. 共通機能:画面は package、API は router factory
ここで解決したいこと
UI 部品だけでなく、機能そのものも3製品で同じものがあります。
- システム設定(OCI 認証・アップロード保存先・モデル・データベース・外観)
- ユーザー管理・ロール管理・ログイン・セッション
- データベースの状態確認(DB が止まっていたら案内を出すゲート)
- システムテーブルの作成・更新
これらは「画面+API」のセットで platform に1つだけ置き、製品は薄いラッパーだけを持つようにしました。
Frontend:画面の実体は共有パッケージ
驚くほど薄くなります。RAG / NL2SQL / Agent の3製品とも、OCI 認証の画面はほぼこれだけです。
// <製品>/frontend/src/components/settings/OciSettingsClient.tsx
import { OciSettingsPage } from "production-ready-system-settings";
import { ApiError, api } from "@/lib/api";
/** OCI 認証設定。画面の実体は platform の共有パッケージ。 */
export function OciSettingsClient() {
return (
<OciSettingsPage
api={api}
errorMessage={(error) => (error instanceof ApiError ? error.message : undefined)}
/>
);
}
製品ごとに違う部分(API クライアント・エラーの型・製品名の入る文言・権限)だけを props で渡します。文言は共通の既定値を持ちつつ、製品の i18n 辞書にあるキーだけ上書きできるようにしています。
// DB ゲート:設定画面以外を開く前に、データベースが使えるかを確認する共通部品
import {
DATABASE_GATE_MESSAGES,
DatabaseGate as SharedDatabaseGate,
} from "production-ready-system-settings";
/** 共通の文言を、製品の辞書にある値で上書きする(製品名の入る文言など) */
function databaseGateMessages() {
return Object.fromEntries(
Object.keys(DATABASE_GATE_MESSAGES)
.filter((key): key is I18nKey => key in ja)
.map((key) => [key, t(key)]),
);
}
export function DatabaseGate({ children }: { children: ReactNode }) {
const { hasPermission } = useAuth();
return (
<SharedDatabaseGate
api={api}
routes={{ databaseSettings: "/settings/database", systemTables: "/settings/system-tables" }}
messages={databaseGateMessages()}
canManageDatabase={canOpenRoute("/settings/database", hasPermission)}
canManageSystemTables={canOpenRoute("/settings/system-tables", hasPermission)}
>
{children}
</SharedDatabaseGate>
);
}
ナビの項目も共有パッケージが提供し、製品は自分のナビに差し込むだけです。
// <製品>/frontend/src/components/layout/nav-config.ts(抜粋)
import { SYSTEM_SETTINGS_NAV_ITEMS, USER_ROLE_NAV_ITEMS } from "production-ready-system-settings";
export const NAV_SECTIONS = [
// ... 製品の業務のセクション ...
{
titleKey: "nav.section.userRoles",
items: USER_ROLE_NAV_ITEMS.map((item) => ({ ...item, permission: USER_ROLE_MENU_PERMISSIONS[item.key] })),
},
{
titleKey: "nav.section.settings",
items: SYSTEM_SETTINGS_NAV_ITEMS.map((item) => ({ ...item, permission: SYSTEM_SETTINGS_MENU_PERMISSIONS[item.key] })),
},
];
Backend:router を返す関数(router factory)にする
Backend 側は、共通パッケージが APIRouter を返す関数を提供し、製品はそれを include_router するだけにしました。製品ごとに違う部分(設定の取得、接続処理、権限の依存)は関数として注入します。
# platform/packages/system_settings_backend/src/pr_system_settings/oci.py(簡略化)
def build_oci_router(
*,
get_settings: Callable[[], Any],
env_file: Callable[[], Path],
write_dependencies: Sequence[params.Depends] = (),
) -> APIRouter:
"""`/oci*` の router を作る。製品側で `/settings` 配下に include する。"""
router = APIRouter()
write = list(write_dependencies)
@router.get("/oci", response_model=ApiResponse[OciSettingsData])
def get_oci_settings() -> ApiResponse[OciSettingsData]:
return ApiResponse(data=_oci_settings_data(get_settings()))
@router.patch("/oci", response_model=ApiResponse[OciSettingsData], dependencies=write)
def update_oci_settings(payload: OciSettingsUpdate) -> ApiResponse[OciSettingsData]:
settings = get_settings()
_persist_oci_settings(settings, payload, env_file()) # 保存に成功してから
settings.oci_region = payload.region # runtime に反映する
return ApiResponse(data=_oci_settings_data(settings))
return router
# nl2sql/backend/app/features/settings/router.py(抜粋)
# テストで差し替えられるよう、lambda で「呼び出し時に」module の値を参照する
router.include_router(
build_oci_router(
get_settings=lambda: get_settings(),
env_file=lambda: app_settings.PLATFORM_ENV_FILE,
)
)
# DB 設定も共通。ただし「接続そのもの」と pool の後始末は製品のものを渡す
router.include_router(
build_database_router(
get_settings=lambda: get_settings(),
env_file=lambda: app_settings.PLATFORM_ENV_FILE,
test_connection=lambda candidate: test_oracle_connection(candidate),
on_saved=lambda _settings: close_oracle_pool(),
extra_readiness=deepsec_readiness, # NL2SQL 固有の判定だけ足す
)
)
DB の状態 API(GET /api/ready/database)も同じ形です。判定の順序と応答の形は共通、接続の仕方と準備状態の確認は製品、という分担です。
画面側の案内(「DB が停止中です」「システムテーブルを作成してください」)は、共通の DatabaseGate が status ごとに出し分けます。製品はもう、DB が止まったときの画面を自分で作りません。
FastAPI アプリ自体も共通の factory で作る
各製品の main.py は、pr_backend_core.create_app で薄く構成します。CORS・例外 → 共通のレスポンス形式・/api/health・/api/ready・/metrics・request-id 付きの JSON ログは、共通の factory が持ちます。
# <製品>/backend/app/main.py(イメージ)
from pr_backend_core.app import create_app
from app.api.router import api_router
from app.readiness import readiness_checks
from app.settings import get_settings
settings = get_settings()
app = create_app(
service_name=settings.service_name,
version=settings.app_version,
cors_origins=settings.cors_origins,
api_router=api_router, # 業務のルーター(/api 配下)
readiness_checks_getter=readiness_checks, # /api/ready に業務の依存チェックを足す
)
7. 設定と DB オブジェクトの命名:接頭辞で「誰のものか」を分ける
ここで解決したいこと
3製品が同じ OCI テナンシー・同じ Oracle のスキーマを使うので、設定とテーブルが混ざらないことと、共通の設定を1か所で管理することの両方が必要でした。
共通 .env と製品の .env
環境変数名は手で書かず、Settings の属性名から規則で決めます。共通の属性の一覧(PLATFORM_SETTING_FIELDS)に入っていれば PLATFORM_、それ以外は製品の接頭辞です。
# platform/packages/backend_core/src/pr_backend_core/config/env.py(簡略化)
PLATFORM_SETTING_FIELDS = frozenset({
"oci_region", "oci_compartment_id", "oci_auth_mode", # OCI 認証
"upload_storage_backend", "object_storage_region", # アップロード保存先
"oracle_user", "oracle_password", "oracle_dsn", # データベース
# ...
})
def settings_env_name(prefix: str, field: str) -> str:
if field in PLATFORM_SETTING_FIELDS:
return platform_env_name(field) # → PLATFORM_ORACLE_DSN
return product_env_name(prefix, field) # → NL2SQL_ORACLE_DRIVER_MODE
# nl2sql/backend/app/settings.py(抜粋)
class Settings(BaseServiceSettings):
# 共通 .env → 製品の backend/.env の順に読む。環境変数は .env より優先
model_config = product_settings_config(prefix="NL2SQL_", backend_dir=BACKEND_DIR)
oracle_dsn: str = "" # → PLATFORM_ORACLE_DSN
oracle_driver_mode: str = "thin" # → NL2SQL_ORACLE_DRIVER_MODE
テーブルも同じ規則で、共通の仕組み(ユーザー・ロール・セッション)は PLATFORM_、製品固有は RAG_ / NL2SQL_ / AGENT_ で始めます。NL2SQL のように「データベースの中の業務テーブルを一覧する」製品では、他製品の接頭辞のテーブルを業務データとして扱わないようにしています。
8. 認証・権限:ユーザーとロールは共通、権限は製品ごと
ここで解決したいこと
1つのユーザー・ロールで3製品を使えるようにしたい一方、「RAG の管理者」が NL2SQL の権限まで持ってしまうような製品をまたぐ権限昇格は防ぎたい、という要件がありました。
- ロールの基本情報は共通のロール管理が扱う。共通のロール API は権限を変更しない
- ロールに付ける権限は製品ごとの権限管理が扱う
- 製品が権限テーブルを追加したら、共通側の登録(どの製品のテーブルか)と
PLATFORM_ROLESへの FK を合わせて用意する
権限の一覧は「左のナビ」を正本にする
地味ですが効いた工夫です。権限管理の画面に並ぶメニュー権限のグループ・並び順・名前を、backend のカタログではなく製品のナビ設定から作るようにしました。
import { arrangePermissionsByNav, permissionNavSections } from "production-ready-system-settings";
// ナビの構造(セクション → 項目)を、権限一覧のグループにそのまま使う
const sections = permissionNavSections(NAV_SECTIONS, t);
const permissions = arrangePermissionsByNav(catalogFromBackend, sections);
これでナビを変えれば権限の一覧も自動で追従します。ナビとメニュー権限・backend のカタログの code がずれたら、各製品のテストが検出します。
9. 製品間の連携は HTTP(MCP + 短命のサービストークン)
ここで解決したいこと
Agent は、RAG の検索や NL2SQL の SQL 生成をツールとして使います。ここで Agent が RAG の Python コードを import すると、依存の向きが崩れます。
そこで、呼び先は POST /api/mcp(MCP の Streamable HTTP)を公開し、Agent は Run を実行した利用者を sub にした短命のトークンで呼ぶことにしました。
ポイントは2つです。
- 画面用の Cookie / CSRF の API を機械から呼ばない。MCP の path だけでサービストークンを受け付ける
- 呼び先は画面と同じ service 層で権限を判定する。Agent 経由だからといって、利用者が画面で見られないナレッジを検索できてはいけない
# 呼び出し元(Agent):利用者の sub と監査用の claim を付けて、60 秒の token を作る
token = issue_service_token(
settings.app_service_token_secret, # 共通 .env の PLATFORM_SERVICE_TOKEN_SECRET
subject=user_uuid,
audience="rag",
issuer="agent",
claims={"run_id": run_id, "agent_id": agent_id},
)
# 呼び先(RAG):共通の McpServer / McpTool でツールを宣言する
McpServer(
name="production-ready-rag",
version=settings.app_version,
tools=[
McpTool(
name="rag_search",
description="検索・回答プロファイルのナレッジベースを検索し、根拠と回答を返します。",
input_model=SearchInput, # Pydantic で入力を検証
output_model=SearchOutput, # outputSchema も契約に含める
handler=search,
permissions=(SEARCH_PERMISSIONS,),
),
],
)
ツールの契約(名前・inputSchema・outputSchema)は platform/contracts/mcp/<製品>-tools.json を正本にし、呼び先は「実装と契約の一致」、呼び出し元は「送る引数と読む項目が契約に収まるか」をそれぞれテストしています。コードでは依存しないけれど、契約ファイルで静的に結ぶイメージです。
10. CI:platform を変えたら、影響する製品を全部検証する
version pin をしない代わりに、CI で守ります。
- 変更のあった製品の job だけを動かし、
platform/の変更は影響を受ける3製品すべてを動かす - 必須 check は
CI OKの1つだけ(skip された job は成功扱い)にして、job を足しても ruleset を触らなくて済むようにする - e2e は、PR では smoke と「差分に影響を受ける spec」だけを実行し、全件は nightly で回す
共有部品を直した PR で、3製品の画面のテストが同時に走るので、「ある製品だけ壊れていた」に merge 前に気づけます。
11. AI エージェントにもルールを読ませる
最後に、AI でコードを書く前提ならやっておきたいことです。
ここまでのルール(依存の向き・禁止事項・製品が持ってよいもの)は、リポジトリ直下と各ディレクトリの AGENTS.md に書き、Claude Code / Codex が作業のたびに自動で読むようにしています。
## デザインシステム / UI(platform が正本)
- UI に触る変更の前に、platform/docs/design-system/ARCHITECTURE.md を読む。
- 製品側でコンポーネントやトークンを新規実装しない。必要になったら packages/ui へ入れる Issue を立てる。
- 製品が持てるのは次だけ。
- ナビ構造(nav config)と業務コピー(i18n)
- データ取得・状態管理・権限
- ドメイン enum → コンポーネント prop の対応表
- 画面固有の業務レイアウト
AI は「近くにあるコードを真似る」ので、一度ズレたコードが残っていると、それを真似て増やします。だからこそ、
- ルールを
AGENTS.mdに書く(AI が読む) - 守れているかを lint とテストで検出する(AI が気づく)
- ズレた既存コードは、触るときに直す
の3点セットが効いたと思います。
実運用チェックリスト
自分たちで運用してみて、最低限押さえたいと思った項目です。
構成
-
依存の向きは
platform → 製品の一方向になっているか - 製品同士がコードで import していないか(連携は HTTP)
- 「他の2製品が欲しがるか」で置き場所を決めているか
UI / UX
-
製品の
globals.cssに色・余白・.darkを書いていないか -
テーマ切り替えは
data-themeだけで完結しているか -
共有部品を再実装していないか(
PageHeader/Button/DataTable/SelectField) - 操作の結果の出し方(Toast / FormStatus / Banner)が UX 契約どおりか
- adherence の lint を3製品がコピーせず参照しているか
共通機能
- 共通の画面は package、API は router factory になっているか
- 製品固有の部分(接続・権限・文言)は依存注入 / props で渡しているか
-
設定と DB オブジェクトの名前に
PLATFORM_/ 製品の接頭辞が付いているか - 共通のロール API が製品の権限を変更しないか
製品間連携・CI
- 機械からの呼び出しで画面用の Cookie / CSRF の API を使っていないか
- 呼び先が画面と同じ権限・対象範囲で判定しているか
- platform の変更で、影響する製品の job がすべて動くか
まとめ
複数の AI アプリで UI/UX と共通機能を揃えるために、やったことを整理すると次の通りです。
| 層 | 共通化の仕組み | 製品に残すもの |
|---|---|---|
| 見た目 | 3層のトークン + light-dark()、共有コンポーネント |
ナビ・文言・enum の対応表 |
| 振る舞い | UX 契約(メッセージ・ページの型・作業状態) | 画面固有の業務レイアウト |
| ルールの強制 | adherence の lint(相対パスで参照)、AGENTS.md | 製品固有の追加ルール(別名で足す) |
| 共通機能(画面) |
system-settings の画面部品 |
api・権限・文言を渡す薄いラッパー |
| 共通機能(API) |
build_*_router の router factory、create_app
|
接続・準備状態の確認・権限の依存 |
| 設定・DB |
PLATFORM_ / 製品接頭辞の命名規則 |
製品だけが使う設定・テーブル |
| 製品間連携 | MCP + 短命のサービストークン + 契約ファイル | ツールの実装 |
| 検証 | 変更パスで判定する統合 CI | 製品のテスト |
共通化というと「共通部品を作ること」だと思いがちですが、実際に効いたのは、製品が持ってよいものを狭く決め、それ以外を機械的に検出することでした。
AI で実装が速くなるほど、ズレも速く増えます。だからこそ、正本を1か所に置き、依存の向きを固定し、守れているかを機械に見張らせることが大事だと思います。
同じように複数のアプリを並行して作っている方の参考になれば嬉しいです。
是非お試しください!