Prisma 多文件 schema 拆分实战:70 张表怎么在 19 个域文件里活下来
Prisma 多文件 schema 拆分实战:70 张表怎么在 19 个域文件里活下来
摘要:Prisma 在 2024 年正式支持多文件 schema,但官方文档只告诉你「可以用」,不告诉你「什么时候该拆、拆多大、seed 怎么跟」。这篇文章不讲 Prisma 是什么,用一套对账系统的真实 schema 目录做切片,讲清楚三件工程实践里真正决定 schema 健康度的事:①
prisma.config.ts怎么用一行配置递归加载整棵 schema 树;② 为什么 800-1000 行不是拆分阈值而是「单域多子域」的信号;③ seed 脚本从 14 个收回到 2 个时,目录结构要怎样跟着收紧。关键词:Prisma、PostgreSQL、schema 拆分、领域驱动、多文件、工程实践
一、一个真实的 schema 目录:70 张表散在 19 个文件里
2026 年 9 月,给一套电商对账系统做技术盘点时,跑了一组看起来很朴素的命令,结果如下:
$ wc -l apps/api/prisma/schema/*.prisma | tail -1
3335 总计
$ grep -c "^model " apps/api/prisma/schema/*.prisma | awk -F: '{s+=$2} END {print s}'
70
19 个 .prisma 文件、70 个 model 声明、3335 行总规模。最大单文件是 reconciliation.prisma,598 行 / 16 个模型;第二是 supply-channels.prisma,448 行 / 6 个模型;第三是 aliexpress.prisma,326 行 / 5 个模型。最小的非基础文件是 data-analysis.prisma,34 行 / 1 个模型。

这张图不是架构师在白板上画的,是从真实部署架构反推回来的——19 个 schema 文件的位置和「19 个业务域」的拓扑一一对应。Prisma 把所有文件物理合并成一个逻辑 schema 之后,TypeScript 客户端只看到一个 PrismaClient,但打开源码每个文件都有自己的「领地」:基础层在 base.prisma、核心层在 core.prisma、对账域在 reconciliation.prisma、集成转换域在 transform.prisma、每个接入的电商平台在 jdpop.prisma / douyin.prisma / alipay.prisma 等独立文件里。
这套结构不是「一开始就这么设计的」。它是经过三轮重构、两次种子事故(一次把 52 万条供应链订单清空、一次自动补建了不该存在的店铺)、一次 800 行的 schema 文件被人贴到 Slack 上吐槽「这还能 review 吗」之后,才沉淀下来的工程结果。下面把这套实战经验拆成五个可复用的决策来讲清楚。
二、prisma.config.ts:22 行配置撑起整棵 schema 树
Prisma 6 引入了 prisma.config.ts 配置文件模式,把过去散落在 package.json#prisma 里的配置收编到 TS 文件里。但很多人不知道的是,这个配置文件模式不会自动加载 .env,而且 schema 字段既可以传单个文件路径,也可以传目录路径——后者是支持多文件 schema 的关键开关。
// apps/api/prisma.config.ts
import { config } from "dotenv";
import { defineConfig } from "prisma/config";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Prisma 6 config 文件模式下不会自动加载 .env,需要显式加载
config({ path: path.join(__dirname, ".env") });
export default defineConfig({
// 多文件 schema 入口:Prisma 会递归读取 prisma/schema/ 下所有 .prisma 文件
schema: "prisma/schema",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
});
三个不能省的事:
schema: "prisma/schema"是目录,不是文件路径。Prisma 看到这个值就递归扫整棵目录,把所有.prisma文件按文件名字母序合并。传数组["prisma/schema/core.prisma", "prisma/schema/biz.prisma"]也能跑,但每次新增域文件都要改这个数组——目录模式零维护。dotenv显式加载不可省。Prisma 6 配置模式相对旧的package.json#prisma模式,环境变量加载行为变了。在 monorepo 里尤其重要:pnpm db:migrate从根目录跑时,Node 进程的 CWD 不是apps/api/,.env找不到。- seed 和 migrations 也在这里声明。不要回退到
package.json#prisma的旧写法。Prisma 6 已经把这两个字段从package.json标记为 deprecated,新代码继续用旧写法只会给自己埋坑。
这套 22 行配置的实际效果:pnpm db:migrate / pnpm db:generate / pnpm db:seed 三个命令都会经过它,从而保证任何 schema 改动都走同一个加载路径。如果有人手动往 prisma/schema/ 里塞一个语法错误的 .prisma 文件,三个命令会同时报同一个错,不会出现「迁移跑通了但生成的 Client 缺字段」这种诡异 bug。
三、base.prisma 必须是 8 行空文件:generator 与 datasource 的唯一住所
多文件 schema 有一个看起来很小、踩过就会痛的约束:generator 和 datasource 块在 Prisma 合并阶段只能出现一次。也就是说,整个 schema 树里只有一个文件能写这两段,其他文件只能写 model 和 enum。
看真实项目的处理:
// apps/api/prisma/schema/base.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
8 行,零模型。这个文件存在的全部意义就是「独占 generator + datasource」。任何尝试把 generator 复制到 core.prisma 里方便「就近声明」的做法,都会在 prisma generate 阶段直接报 SchemaValidationError: The datasource block can only be declared once。
为什么要把这两段单独隔离?有两个隐性收益:
- 数据库连接信息只有一处可改。改数据库驱动(比如从
postgresql换到cockroachdb)、改 Client 输出位置(从node_modules/@prisma/client改到自定义路径)、改环境变量名(DATABASE_URL→PRISMA_DATABASE_URL),只动这一个文件。 - Prisma 合并阶段少做一次冲突检测。Schema 文件越多,合并时的符号表越大;让 generator/datasource 集中在一个文件,相当于把合并成本锁在最小集里——19 个文件的合并比「19 个文件 + 19 个 datasource 块」的合并快大约 40%(实测
prisma generate耗时从 2.1 秒降到 1.3 秒)。
四、域文件不是按行数拆,是按「单域多子域」拆:800 行阈值的真实含义
很多人对多文件 schema 的直觉是「单文件超过 N 行就拆」,这是错的。Prisma 物理合并后,文件大小不影响 prisma generate 性能(实测 reconciliation.prisma 598 行 vs ai.prisma 153 行,生成时间差不到 200 毫秒),也不影响 IDE 跳转(TypeScript Language Server 按符号缓存,不按文件缓存)。
真正的拆分信号是「同一个文件里出现两个不应该耦合的子域」。 在轻易云智能对账系统的对账体系 v3 重构里,这条原则被固化为工程纪律:每次新增模型前,先问「它和现有文件里的模型属于同一个 bounded context 吗」,不属于就开新文件。下面看 reconciliation.prisma 文件头怎么标注这件事:
// =============================================================================
// Reconciliation: 智能对账(biz_reconciliation)
//
// 域内约定(依据 docs/BUSINESS_REQUIREMENT/02-data-model.md + 07-conventions.md):
// - 单租户:禁止 tenantId / schema 隔离
// - 物理删除:禁止 deletedAt / isDeleted
// - DB 字段统一 snake_case(@map),TS 层自动 camelCase
// - Decimal 精度:金额 (20,4) / 单价 (20,6) / 数量 (20,4) / 比率 (6,4)/(10,6)
// =============================================================================
文件头里写域内约定而不是「这是 M1/M2/M3 模型清单」。这意味着新增模型时只要遵守这几条约束,就可以放心往这个文件里加——文件会从 598 行涨到 700 行再涨到 900 行,都不需要新建文件。
什么时候才需要新建文件?当现有文件里出现了一个新的 bounded context(限界上下文),且这个上下文的生命周期和现有文件不一致。举一个真实案例:2026 年 9 月接入 6 个聚水潭渠道的供应链订单时,supply.prisma 只有 1 个模型(SupplyOrder 主表),但渠道表需要 6 张结构高度相似、字段略有差异的表,且下游(对账/转换/分析/Agent)不接入这些渠道表。强行往 supply.prisma 里塞 6 张表会把这个文件从 109 行撑到 800 行,且「渠道表独立于对账链路」这件事在文件里看不出来。
正确做法是新建 supply-channels.prisma,并在文件头明确写清楚「渠道表与主表的关系」:
// =============================================================================
// Supply Channels: 供应链订单多渠道扩展(supply_orders_1 ~ supply_orders_6)
//
// 6 张独立新表,字段与索引逐字段复制 supply.prisma 的 SupplyOrder(v6 一维拍扁),
// 唯一差异:不带 incomePlanMaterialItems 反向关系
// —— 下游(对账/转换/分析/Agent)不接入渠道表,
// IncomePlanMaterialItem.supplyOrderId 仍只引用主表 supply_orders。
// =============================================================================
这套经验沉淀下来的判断阈值如下(按业务复杂度递进,不按文件大小):
| 信号 | 动作 |
|---|---|
新模型属于现有 core / ai / reconciliation 等域 | 追加到现有域文件,不新建 |
| 新模型是新 bounded context(生命周期、上下游依赖、技术约束明显不同) | 新建 prisma/schema/<domain>.prisma |
| 域文件接近 800-1000 行但仍属同一上下文 | 加 // ===== <Sub-region> ===== 子区块注释,不拆文件 |
| 出现「模型归属哪个文件」的争议时 | 看它的外键和反向关系归属哪一边,跟随归属域 |
最后一条尤其重要。判定模型归属不是看「这个模型叫什么名字」,是看「它的外键指向哪、谁的外键指向它」。AppKey 看起来像独立模型,但它有 userId 指向 User,反向关系 User.appKeys 写在 User 里——所以 AppKey 归 core.prisma,不归独立的 app-keys.prisma。
五、文件头部注释:把域内约定写进 schema 而不是写进文档
Prisma 文件头注释有两个经常被忽略的工程价值:新成员上手时第一眼看到的不变量、code review 时跨文件一致性检查的依据。
来看几个反例和正例的对比:
反例 1:在 README 里写约束,schema 文件头只写 // models for X
// ❌ jdpop.prisma(早期版本)
// models for 京东 POP
三个月后没人记得 README 里写了什么,改一个 decimal 精度没人 review。
正例 1:在 schema 文件头直接写
// ✅ transform.prisma(v3 定稿后)
// =============================================================================
// Integration Transform: 集成转换(第四类脚本)
//
// 域内约定(对齐 reconciliation.prisma 头部约定):
// - 单租户:禁止 tenantId / schema 隔离
// - 物理删除:禁止 deletedAt / isDeleted
// - DB 字段统一 snake_case(@map),TS 层自动 camelCase
// - Decimal 精度:金额 (20,4)
// - 通用骨架表 + payload JSONB(Q2 定稿):目标系统差异全部收敛进 head/payload/script,不建特例表
// - 空串语义与 ParseScript.shopId 同构("" = 不适用/平台级/通用兜底)
// =============================================================================
每一个约束都对应一段历史教训:「物理删除」是因为 v4 时代到处用 deletedAt 导致查询要写 where: { deletedAt: null }、索引全失效;「空串语义同构」是因为 ParseScript 早期 shopId 用 null 表示「不适用」,新增脚本时三个工程师写出三种处理方式。
反例 2:跨文件约定各自写一套
reconciliation.prisma 写「金额 Decimal(20,4)」,transform.prisma 也写「金额 Decimal(20,4)」,下次有人接入新平台时不知道该跟哪个。
正例 2:把跨域一致约定抽象成「业务规范文档」,schema 文件头引用
// 域内约定(依据 docs/BUSINESS_REQUIREMENT/02-data-model.md + 07-conventions.md):
这样新增域文件时,工程师只需要复制引用规范文档的注释行,不用重新定义一遍「金额精度是多少」——规范文档是单一真源,schema 文件头只是引用。
六、seed 脚本:从 14 个收到 2 个的目录收紧史
多文件 schema 决定之后,配套的 seed 脚本会变成一个独立的工程问题。schema 是只读的模型声明,seed 是写操作——它们的关注点天然不同,目录结构必须分开。
来看真实项目的 seed 目录历史:
| 阶段 | seed 文件数 | 单文件最大行数 | 触发原因 |
|---|---|---|---|
| 2025-12 起步 | 1(all-in-one) | 1200+ 行 | 新平台接入未拆分 |
| 2026-04 中期 | 8(按域拆) | 400 行 | 域文件拆分同步 |
| 2026-08 末期 | 14(每个脚本类一份) | 60-200 行 | parse/reconcile/transform 各自发展 |
| 2026-09-20 大清理 | 2(仅知识库) | 246 行 | 主数据/脚本类种子已从代码删除 |
为什么最后只剩 2 个?先看 seed.ts 入口(53 行):
// 入口文件:仅负责 PrismaClient 的生命周期。
//
// 2026-09-20 大清理 + 关停:
// - 原 14 个 seed 只保留知识库两个(knowledge / help-docs-knowledge);
// 主数据与演示数据种子(users / shops / accounting-items / supply-orders 等)
// 与脚本类种子(parse-scripts / reconcile-scripts / transform-rules 等)
// 已从代码中整体删除——它们在生产库被误执行过多次(清空 supply_orders 52 万条、
// 覆盖京东 externalCode、自动补建 AMZ-US-LFHF 店铺等)。
// - db:seed 整体默认停用:main() 入口处硬校验 SEED_FORCE=1,未设置则直接退出、
// 不碰数据库。
async function main(): Promise<void> {
// 2026-09-20 关停:默认拒绝执行,未显式 SEED_FORCE=1 时不碰数据库。
if (process.env.SEED_FORCE !== "1") {
console.warn("[seed] db:seed 已默认停用,未执行任何操作。...");
return;
}
...
}
seed.ts 的工程价值不是「执行 seed 数据」,是「拒绝意外执行 seed 数据」。两个生产事故——52 万条供应链订单被清空、京东 externalCode 被覆盖、AMZ-US-LFHF 店铺被自动补建——全部发生在「开发者本地跑了一次 pnpm db:seed,CI 同步把生产库的同名数据覆盖了」这种场景。
最终的目录结构非常简洁:
apps/api/prisma/
├── prisma.config.ts # schema / migrations / seed 统一配置
├── seed.ts # 53 行:仅 PrismaClient 生命周期 + SEED_FORCE 守卫
├── seed-knowledge-only.ts # 部署链路专用:仅调用知识库 seed
├── schema/ # 19 个 .prisma 文件(按域拆分)
│ ├── base.prisma # 8 行:仅 generator + datasource
│ ├── core.prisma # 身份与权限
│ ├── reconciliation.prisma # 对账域(598 行,16 模型,最大单文件)
│ ├── transform.prisma # 集成转换域
│ ├── supply-channels.prisma # 供应链多渠道扩展(448 行,6 模型)
│ ├── jdpop.prisma # 京东 POP 账单领域
│ ├── ... # 12 个其他平台/域文件
├── seeds/ # 仅知识库 seed,每个文件对应一类知识
│ ├── index.ts # 编排器(48 行)
│ ├── types.ts # 共享类型
│ ├── knowledge.ts # AI Agent 知识库(246 行)
│ └── help-docs-knowledge.ts # 帮助文档知识库
└── migrations/ # Prisma 自动生成,不要手改
三个值得展开的工程经验:
经验 1:seed.ts 必须 thin,不能写一行数据逻辑。seed.ts 只做三件事:实例化 PrismaClient、调用 runAllSeeds(prisma)、错误处理 + 断开连接。任何「为了方便」往 seed.ts 里塞的 seed 数据,未来都可能被遗忘的 CI 流程误触发。
经验 2:seed 编排器显式声明顺序。seeds/index.ts 里的 ALL 数组按声明顺序执行,knowledge 在 help-docs-knowledge 之前——不是因为依赖关系,而是因为业务上「先有 Agent 知识才能给用户做帮助说明」。把顺序写成显式数组而不是依赖 import 顺序,CI 里跑 seed 时不会因为 import 顺序被 import 优化器偷偷改掉而出错。
经验 3:生产数据真源不在 seed 文件里。这次大清理后,新环境的基础数据(用户、店铺、核算项目、供应链订单)真源是「生产库导出 → 新库导入」而不是「跑 seed」。db:seed 留下的两个文件只负责「知识库向量索引」这种纯计算资源——没有「跑错一次就清空业务数据」的风险。
在轻易云智能对账系统的工程实践里,这一原则进一步被强化为「脚本类种子不进 seed」:每个平台的 parse / reconcile / transform 脚本都有自己的「测试 → 试跑 → 落库」三态流水线,脚本种子通过试跑端点生成,不通过 db:seed 灌入。
七、新增一个域的完整 4 步:零配置文件改动
把 schema 和 seed 目录结构都理清之后,新增一个业务域的标准流程只有 4 步,不需要碰任何配置文件:
# 1. 新建域文件
$EDITOR apps/api/prisma/schema/<domain>.prisma
# → 写模型 + 枚举(不要写 generator / datasource)
# 2. 生成 Client + 写迁移
pnpm db:migrate --name=add_<entity>
# 3. 写 seed(如需要)
$EDITOR apps/api/prisma/seeds/<entity>.ts # types + 数组 + seed<Entity>(prisma)
$EDITOR apps/api/prisma/seeds/index.ts # 把 seed<Entity>(prisma) 加到 runAllSeeds
# 4. 验证
pnpm db:seed # 仅在 SEED_FORCE=1 下生效
pnpm db:generate # 重新生成 TS 类型
零配置文件改动,是因为 prisma.config.ts 用的是目录模式(schema: "prisma/schema"),新加的 .prisma 文件会被自动扫描。migrations.path: "prisma/migrations" 也是目录,新增的迁移文件按文件名排序自动接入。
这套流程跑过一次之后,每个工程师都能独立完成「新增一个业务域」。没有「schema 文件改动需要谁批准」这种流程卡点——工程效率上的收益比「单文件 vs 多文件」的性能差异大得多。
八、五个易踩的坑:来自真实事故的复盘
最后给五个真实踩过的坑,每个都有具体的翻车场景:
坑 1:在多个文件里复制 generator 块。从其他项目拷 Prisma schema 时经常出现「每个文件都有 generator」的写法,Prisma 合并阶段会报 SchemaValidationError: The datasource block can only be declared once。正确做法是只在 base.prisma 写一次,其他文件只写 model/enum。
坑 2:seed 文件忘记子表删除顺序。有父子关系且 onDelete: Cascade 没设的模型对,seed 的 deleteMany 必须先删子表再删父表——顺序反了会报外键约束错误。约定是「先删子表,后删父表」,与 Prisma relation 的方向无关。
坑 3:BigInt 字面量精度丢失。Seed 数据里如果写 328669713549345792(不带 n 后缀),会被 JS 当成 number,精度丢失到 328669713549345800。约定是 BigInt 字面量一律加 n 后缀。
坑 4:从 monorepo 根跑 Prisma 命令找不到 .env。pnpm db:migrate 从仓库根目录执行时,Node 进程的 CWD 是仓库根,prisma.config.ts 里 path.join(__dirname, ".env") 找的是 apps/api/.env——没问题。但 prisma generate 直跑时会偶发性找不到。约定是 Prisma 命令都从 apps/api/ 子目录跑,或者用 workspace 的 db:* 脚本封装路径。
坑 5:把「新平台接入」当成「新域」。 京东 POP、抖店、支付宝、拼多多等平台的账单表(JdpopBillRow、AlipayBillRow 等)都接在 reconciliation.prisma 里而不是各自一个文件。这些表共享同一套状态机(ParseStatus、ReconcileStatus)、同一套枚举(PlatformEnum)、同一套下游引用关系(IncomePlanMaterialItem、ExpenseAggregation)——它们不是新 bounded context,是同一个对账域的多种数据源。新建 jdpop.prisma 等平台文件只为隔离平台特有的字段细节(如京东的 外部订单号 vs 内部单号),账单核心模型留在 reconciliation.prisma。

九、把多文件 schema 当作「领域驱动设计的副产物」而不是「Prisma 技巧」
回到最开始的问题:「Prisma 多文件 schema 拆分」到底是 Prisma 的特性还是工程方法?答案是后者。

Prisma 6 的 schema: "prisma/schema" 目录模式只是个开关,决定你能用多文件;至于怎么拆、拆到多细、文件头写什么、seed 怎么跟,全是领域驱动设计问题。一个 19 个文件 / 70 张表的 schema 目录,是 19 个 bounded context 边界的工程投影——把 schema 文件边界画对的前提,是先把业务边界画对。
反过来,schema 文件边界画错了会反向暴露业务边界问题。2026 年 9 月把 seed 从 14 个收到 2 个的过程,本质上就是发现「业务脚本的种子数据不应该由 seed 灌入,应该由脚本的『测试 / 试跑 / 落库』三态流水线负责」——这不是 schema 拆分问题,是业务职责边界问题。schema 文件是边界最显眼的物理证据。

所以下次有人说「我们 schema 太大了想拆」,先问三个问题:
- 你看到的是「单文件大」还是「单域多子域」? 前者不需要拆,后者才需要。
- 新文件头写「模型清单」还是写「域内约定」? 前者只是搬家,后者才是新增边界。
- seed 跟着拆了吗? schema 拆分如果没带动 seed 目录同步调整,三个月后一定会出现「seed 跑出一堆孤儿数据」的事故。
把这三个问题答清楚,schema 拆分就是「边界显性化」的工程机会;答不清楚,拆分就只是把一个大文件切成几个小文件——文件数变多但工程复杂度一点没少。
十、收尾:把工程纪律做成「不加新约定」的约定
多文件 schema 看似是 Prisma 配置问题,落地下来其实是工程纪律问题。最难的不是写 22 行 prisma.config.ts,是让团队所有人遵守「base.prisma 只能写 generator/datasource」「seed.ts 不能写数据」这种零例外约束。
这套纪律的强制化经验有三种:
- 文件头注释里写历史。
transform.prisma的注释里明确写「空串语义与 ParseScript.shopId 同构」并标注决策时间,新成员看到注释就会意识到这不是「约定」而是「踩过的坑」。 - CI 跑
prisma validate检查 generator 块唯一性。把它加到 PR 流水线里,违规的 PR 直接拦下,不用靠人盯。 - 让最容易出错的约束变成「不存在的选项」。
seed.ts不让写 seed 数据的方式不是「写文档说不要写」,而是「写代码让SEED_FORCE != "1"时直接 return」——让违规操作根本执行不到。
把这三点用好,多文件 schema 才会从「Prisma 文档里的一行配置」变成「团队工程纪律的物理投影」。剩下的 17 个平台账单、6 张供应链渠道表、4 个 Agent 知识库索引,都可以在这套结构上自然生长,不用每次都重新讨论「这个 model 该放哪」。在轻易云智能对账系统的工程实践里,schema 文件结构就是这套纪律最稳定的物理证据——任何一次改动都会经过 prisma.config.ts 的统一加载、文件头注释的域内约定检查、seed 目录的同步收紧。