UPSIDER Tech Blog

1つのコードベースで複数ブランドを安全に動かす - Prisma と CI で守るデータ分離

はじめに

こんにちは、UPSIDER で法人カード管理画面の開発をしている宮本です。

UPSIDER では、法人カード基盤を外部提供し、パートナー企業が自社ブランドで法人カードサービスを提供できるプラットフォームづくりを進めています。

このようなマルチブランド展開では、管理画面アプリケーションにも複数ブランドへの対応が求められます。

一方で、ブランドごとにコードベースを分けてしまうと、機能追加・不具合修正・セキュリティ対応のたびに同じ変更を複数箇所へ反映する必要があります。最初は小さな差分でも、時間が経つほどコードは分岐し、修正漏れや仕様差分が生まれやすくなります。

そこで私たちは、コードは1つに保ちつつ、データベースとデプロイ単位はブランドごとに分離する構成を選びました。

この記事では、その中でも特に難しかった Prisma schema / migration / Prisma Client の分離 と、それを CI でどう守っているかを紹介します。

コードは共有したい。でもデータは混ぜたくない

複数ブランド対応でまず考えたのは、次の2つを同時に満たすことでした。

  • 共通機能は1つのコードベースで開発したい
  • ブランドごとのデータは強く分離したい

コードを共有する理由はシンプルです。認証、カード管理、明細、通知、権限管理など、多くの機能はブランドが違っても共通です。ここをブランドごとに fork すると、開発速度と品質の両方が落ちます。

一方で、データは別です。法人カード管理画面は、企業情報・メンバー情報・カード情報・明細情報など、分離すべきデータを多く扱います。アプリケーション側の認可チェックだけに頼るのではなく、ブランドごとにデータベース自体を分けることで、より強い境界を作る必要がありました。

ただし、DB を分ければそれだけで安全になるわけではありません。接続先の取り違えを防ぐことは、デプロイ設定や Secret 管理の責務です。この記事で扱うのは、そのうえで 分離した DB ごとの schema / migration / Prisma Client をどうズラさずに運用するか です。

この前提で、接続先とデプロイ単位を含めた全体像は以下のようになります。

flowchart TB
    common["共通コードベース"]

    appA["ブランドA向けアプリ"]
    appB["ブランドB向けアプリ"]

    dbA["ブランドA専用DB"]
    dbB["ブランドB専用DB"]

    common --> appA
    common --> appB
    appA --> dbA
    appB --> dbB

この構成にすると、共通ロジックは再利用しながら、データの境界は物理的に分けられます。

ただし、ここで新しい問題が出てきます。

DB を分けるなら、Prisma schema と migration をどう管理するのか です。

なぜ完全共通 schema だけでは足りないのか

まず検討したのは、1つの Prisma schema を全ブランドで共有する構成です。全ブランドの DB に同じ migration を適用できれば、管理コストを最小限に抑えられます。

しかし開発が進むと、特定ブランド専用のテーブルやカラムが必要になるケースが出てきます。schema が完全共通だと、使わないはずの他ブランドの DB にまで、それらの構造が作られてしまいます。

flowchart TB
    change["ブランドAだけで必要なテーブルを追加したい"]
    schema["完全共通 schema に追加する"]
    migrate["同じ migration を全ブランドの DB に適用する"]

    dbA["ブランドA DB<br/>必要なテーブルとして作られる"]
    dbB["ブランドB DB<br/>ブランドA向けテーブルも作られてしまう"]

    change --> schema
    schema --> migrate
    migrate --> dbA
    migrate --> dbB

これの何が問題かというと、本当にそのブランドに必要なデータ構造がどれなのか が分かりづらくなる点です。関係ないテーブルが混ざることでスキーマ境界が曖昧になり、将来的に特定ブランドだけを独立して修正したり、切り出したりすることが難しくなります。

ブランドごとのデータ構造の独立性を担保し、変更の影響を閉じる。この考えから、Prisma schema 自体をブランドごとに分離する方針を選びました。

採用した構成: schema は分け、共通変更は一元管理する

最終的に採用したのは、Prisma schema はブランドごとに分離し、全ブランドに適用したい DB 変更は1か所で作ってから各ブランドへ同期する方式です。

この記事では、この「全ブランドに入れたい DB 変更を最初に作る場所」を共通 migration と呼びます。

構成のイメージは以下のようになります。

flowchart TB
    commonSchema["共通 schema / 共通 migration"]
    brandASchema["ブランドA schema / migrations"]
    brandBSchema["ブランドB schema / migrations"]

    commonSchema -->|"共通 migration を同期"| brandASchema
    commonSchema -->|"共通 migration を同期"| brandBSchema

    brandASchema -->|"ブランドA固有 migration を追加可能"| brandASchema
    brandBSchema -->|"ブランドB固有 migration を追加可能"| brandBSchema

共通のテーブル変更は、まず共通 migration として作ります。その後、スクリプトで各ブランドの migration ディレクトリへコピーします。

一方で、ブランド固有の変更は、そのブランドの schema / migration にだけ追加します。

この方式には、完全共通 schema と完全分離 schema の中間のような性質があります。

方式 メリット デメリット
完全共通 管理が簡単 ブランド固有テーブルも全 DB に作られる
完全分離 分離が明確 共通変更を毎回複数箇所に手作業で反映する必要がある
schema 分離 + 共通 migration 管理 分離と再利用のバランスがよい 同期漏れを防ぐ仕組みが必要

私たちは3つ目を選びました。

理由は、Prisma の標準的な migration 運用を大きく崩さずに、ブランド固有の schema 差分を許容できるためです。

Prisma Client もブランドごとに分ける

schema を分けると、Prisma Client も分ける必要があります。

Prisma Client は schema から生成されるコードです。ブランド固有の model があるなら、生成される Client もブランドごとに異なります。

ここで分けたいのは、共通コードとブランド固有コードです。

  • 共通コード: 共通 schema の model だけを扱う
  • ブランド固有コード: ブランド固有 model も扱う

両方が同じ生成 Client を前提にすると、次のようになります。

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

new CommonRepository(prisma);
new BrandAOnlyRepository(prisma);

共通コードだけであれば、これでも問題は起きにくいです。しかし、ブランド固有コードまで同じ Client に依存すると、その model がどの schema 由来の Client に存在するのかが曖昧になります。

一方で、共通コードまでブランドごとに複製したいわけではありません。そこで、ブランド別に生成した Client を起点にしつつ、共通コードには共通 Client 型へ寄せた Client を渡す構成にしました。

ブランド別 Client は、共通 schema にブランド固有 schema を足した スーパーセット です。実行時には共通コードから使える API を持っていますが、生成元が違うため、TypeScript の型としてはそのまま共通 Client 型へ代入できない場面があります。

そこで、型変換をアプリケーションの組み立て層に集約しました。

  • 共通コードには、共通 Client 型へ寄せた Client を渡す
  • ブランド固有コードには、そのブランドの Prisma Client を直接渡す
  • 型変換はアダプタ関数に閉じ込め、各所で個別にキャストしない

そのため、Prisma Client の出力先をブランドごとに分け、各アプリケーションが自分の Client を参照するようにしました。

ここでの asSharedClient は、ブランド別 Client を共通コードが期待する Client 型として扱うための薄いアダプタです。ブランド固有の API を隠し、共通コードから見える操作を共通 schema の範囲に絞る役割を持たせています。

import { PrismaClient as BrandAPrismaClient } from "prisma-client-brand-a";

const brandAPrisma = new BrandAPrismaClient();

new CommonRepository(asSharedClient(brandAPrisma));
new BrandAOnlyRepository(brandAPrisma);

Client と repository の関係は以下のようになります。

flowchart TB
    appA["ブランドA App"]
    appB["ブランドB App"]

    clientA["ブランドA Prisma Client<br/>共通 schema + A 固有 schema"]
    clientB["ブランドB Prisma Client<br/>共通 schema + B 固有 schema"]

    adapter["共通 Client 型へ寄せるアダプタ"]
    commonRepo["共通 Repository"]
    brandRepo["ブランド固有 Repository"]

    appA --> clientA
    appB --> clientB

    clientA --> adapter
    clientB --> adapter
    adapter --> commonRepo

    clientA --> brandRepo

重要なのは、共通コードの中に if (brand === '...') のような分岐を増やさないことです。

ブランド差分を共通コードの条件分岐で吸収し始めると、コードベースはすぐに読みにくくなります。共通コードはあくまでブランド非依存に保ち、差分はアプリケーションの組み立て側、つまり DI や設定で吸収するようにしました。

CI や Docker ビルドでも、ビルド時点で正しい Client が成果物に含まれていることを担保する必要があります。単に DB を分けるだけでなく、schema から生成されるコードも分ける。ここまでやって初めて、複数ブランドのアプリケーションを同じリポジトリで安定してビルド・実行できます。

この設計方針を置いておくと、責務を整理しやすくなります。

  • 共通コードはどのブランドでも使える
  • ブランド固有の処理はブランド別アプリケーションに閉じる
  • DB 接続や外部サービス設定は起動時に注入する

この構成にしておくと、新しいブランドが増えたときも、既存の共通ロジックを壊さずに展開できます。

本当に怖いのはスキーマのズレ

schema と migration を分けると、次に怖くなるのは スキーマのズレ です。

たとえば、共通テーブルにカラムを1つ追加したとします。

この変更は全ブランドに必要です。しかし、片方の schema だけ更新して、もう片方の schema 更新を忘れたらどうなるでしょうか。

ローカルでは動くかもしれません。片方のブランドだけを見ている PR では気づけないかもしれません。しかし、別ブランドの検証やデプロイで突然落ちる可能性があります。

migration も同じです。

共通 migration を作ったのに、ブランド別 migration への同期を忘れると、環境によって DB の形がズレます。

この同期漏れをどう検知するかが、次の課題でした。

CI で migration の同期漏れを落とす

共通 migration は、全ブランドに入れる DB 変更のコピー元として管理します。各ブランドの migration ディレクトリには、そこから同期した migration と、ブランド固有の migration が並びます。

そして PR では、同期済みであることを CI でチェックします。

flowchart TD
    A["共通 migration を追加"] --> B["同期スクリプトを実行"]
    B --> C["ブランド別 migration にコピー"]
    C --> D["PR 作成"]
    D --> E["CI で同期状態をチェック"]
    E -->|"OK"| F["レビューへ"]
    E -->|"NG"| G["同期漏れとして失敗"]

このチェックがあることで、開発者は「同期したつもり」ではなく、「同期されていないと CI が落ちる」状態にできます。

具体的には、次のような観点を CI で確認しています。

  • 共通 migration が各ブランドの migration ディレクトリに存在するか
  • 同じ名前の migration の中身が一致しているか
  • ブランド固有ではない migration が、ブランド側にだけ存在していないか
  • 共通 schema の enum / model / field が、ブランド別 schema に反映されているか

共通 migration とブランド固有 migration の判定は、migration 名の命名規約に寄せています。ブランド固有の migration にはブランド識別子を含め、同期スクリプトと CI の両方が同じ規約で判定します。

GitHub Actions 側では、migration や schema に関係するファイルが変わった PR だけでチェックを走らせます。

on:
  pull_request:
    paths:
      - "packages/backend/prisma/migrations/**"
      - "apps/brand-*/backend/prisma/migrations/**"
      - "packages/backend/prisma/schema.prisma"
      - "apps/brand-*/backend/prisma/schema.prisma"
      - "scripts/check-*-sync.ts"

jobs:
  check-prisma-sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm db:check-migration-sync
      - run: pnpm db:check-schema-sync

migration の同期チェックでは、共通 migration を基準にして、各ブランド側の migration を比較します。

const brandKeys = ["brand_a", "brand_b"];

function isBrandSpecificMigration(name: string): boolean {
  return brandKeys.some((key) => name.includes(`_${key}_`));
}

const commonMigrations = listMigrations("packages/backend/prisma/migrations")
  .filter((name) => !isBrandSpecificMigration(name));

for (const brand of brands) {
  const brandMigrations = listMigrations(`apps/${brand}/backend/prisma/migrations`);

  assertNoMissingMigration(commonMigrations, brandMigrations);
  assertSameMigrationContent(commonMigrations, brandMigrations);
  assertNoUnexpectedCommonMigration(commonMigrations, brandMigrations);
}

schema の同期チェックでは、共通 schema の定義がブランド別 schema に含まれているかを見ます。

一方で、ブランド固有の model や field は許容します。完全一致ではなく、共通 schema からブランド別 schema への片方向の同期チェックにしているのがポイントです。

const commonSchema = parseSchema("packages/backend/prisma/schema.prisma");

for (const brand of brands) {
  const brandSchema = parseSchema(`apps/${brand}/backend/prisma/schema.prisma`);

  assertCommonEnumsIncluded(commonSchema, brandSchema);
  assertCommonModelsIncluded(commonSchema, brandSchema);
  assertCommonFieldsIncluded(commonSchema, brandSchema);
}

CI が守れる範囲と守れない範囲

このチェックは、あくまでリポジトリ上の schema / migration ファイルの同期漏れを検知するものです。

たとえば、migration 履歴をすべて適用した結果が schema.prisma と一致するか、ブランド固有 migration が実際の DB 状態に対して適用可能か、接続先の設定が正しいかまでは、このチェックだけでは保証しません。

その領域まで守るなら、Prisma CLI の一般的なコマンドである prisma migrate diff を使って、migrations ディレクトリと schema.prisma の datamodel を比較する検証や、デプロイ設定・Secret 管理側のガードを別途組み合わせる必要があります。

マルチブランド対応では、考えるべき対象が増えます。DB、schema、migration、Client、環境変数、デプロイ設定など、対象も多岐にわたります。すべてをレビュー時の注意力だけで守るのは難しいです。

そのため、同期漏れなど機械的に判定できるものは CI で検知するようにしました。

共通化と分離の境界

ここまで分離の話をしてきましたが、すべてを分ければよいわけではありません。

完全に分けてしまうと、共通機能の修正が重くなります。

たとえば、認証まわりのセキュリティ修正が必要になったとします。ブランド別にコードが分かれていると、すべてのコードベースへ同じ修正を入れなければなりません。レビュー、テスト、リリースも増えます。

一方で、すべてを共通化すると、ブランド固有の事情が共通コードへ漏れていきます。if (brand === '...') が増え、共通コードの見通しが悪くなります。

私たちが意識したのは、共通化するものと分離するものをレイヤーごとに決めることでした。

レイヤー 方針
ドメインロジック できるだけ共通化
DB インスタンス ブランドごとに分離
Prisma schema ブランドごとに分離
共通 migration 一元管理して同期
Prisma Client ブランドごとに生成
ブランド固有機能 ブランド別アプリケーション側に閉じる
CI スキーマのズレや同期漏れを検知

この線引きがあると、設計判断で迷ったときに戻る場所ができます。

導入して得られた効果

実際にこの構成にしてよかったことは、大きく3つあります。

1つ目は、レイヤーごとの責務を説明しやすくなったことです。

接続先の分離、schema の分離、migration の同期、Client の生成先をそれぞれ別の責務として説明できます。どこを物理的に分け、どこを検証対象にするのかを整理しやすくなりました。

2つ目は、ブランド固有の変更を入れやすくなったことです。

特定ブランドでだけ必要なテーブルや機能を、他ブランドの DB へ持ち込まずに追加できます。これは今後の展開を考えると重要です。

3つ目は、運用ルールを明文化できたことです。

「共通 migration を作ったら同期する」「共通 schema の変更はブランド別 schema にも反映する」といったルールを、前述のチェックに落とし込めました。

たとえば、共通 schema に enum や model を追加したのに、ブランド別 schema への反映を忘れた場合、schema 同期チェックが不足を検知します。PR の段階でズレに気づけるのは大きいです。

設計・運用上の難しさ

一方で、分離したことで難しくなった部分もあります。

一番つらいのは、共通 schema の変更時に考える対象が増えることです。

1つの schema だけを見ればよかった頃に比べると、ブランドごとの schema、migration、生成 Client、CI の通り方まで確認する必要があります。

また、DB を分けると、migration の適用順序や同期状態もブランドごとに意識する必要があります。このあたりは、単に schema を分けるだけでは解決しません。CI、レビュー観点、運用ルールを含めて設計する必要があります。

また、ブランドごとの schema に差分を許容する以上、「これは共通に置くべきか」「ブランド固有に閉じるべきか」を毎回考える必要があります。

この判断を曖昧にすると、共通コードにブランド固有の事情が漏れたり、逆に共通化すべきものがブランドごとに重複したりします。

分離の設計は、一度決めたら終わりではありません。機能追加のたびに、境界を守る判断を続ける必要があります。

まとめ

複数ブランド対応では、「コードを分けるか、共通化するか」だけが論点になりがちです。

しかし実際に重要だったのは、何を共有し、何を分離するかをレイヤーごとに決めることでした。

私たちは、コードベースは1つに保ちながら、DB・Prisma schema・Prisma Client・デプロイ単位はブランドごとに分ける構成を選びました。そして、共通 migration の同期漏れやスキーマのズレを機械的に検知できる形にしました。

運用してみると、分離方針そのものよりも、日々の変更でその方針を崩さない仕組みの重要性を感じます。

マルチブランド対応で怖いのは、コードの共通化そのものではありません。

本当に怖いのは、接続先やスキーマの境界が曖昧になることと、DB schema のズレに気づけなくなることです。

接続先の分離はデプロイ設定で守り、schema / migration / Client のズレは Prisma の構成と検証で守る。これが、1つのコードベースで複数ブランドを安全に動かすために、私たちが採ったアプローチです。

最後まで読んでいただき、ありがとうございました。

We Are Hiring

herp.careers

herp.careers

UPSIDER Engineering Deckはこちらです。

speakerdeck.com