团队之前为了追求所谓的“微服务化”,把一个好端端 Node.js 大单体强行拆成了五个独立的 Repository。没过三个月,苦果就来了:每个服务都要读写同一张用户表,结果每次修改用户表的 Schema,五个仓库的 Prisma Schema 都得同步手动改一遍。稍微漏掉一个,线上运行就直接崩溃报字段缺失。更别提 JWT 校验、Redis 客户端、日志配置这些基础模块,在每个仓库里都被复制粘贴了一份,改个 Bug 要在五个仓库里提五个 PR。简直是本末倒置!上周我实在忍不了这种心智负担,干脆花了两天时间把这堆烂摊子合并到了一个基于 pnpm workspaces 的 Monorepo 架构里。

重构过程中,最大的痛点在于怎么在多个独立的 NestJS 服务之间优雅地共享 Prisma Client 及其生成的 TypeScript 类型,同时保证各个服务在打包(Build)时不会引入不必要的依赖。

经过几次踩坑,我们最终确立了清晰的架构边界:在根目录下创建 packages 目录,把公共的数据库访问、类型定义和全局配置封装成独立的内部 npm 包,让各业务服务通过 workspace:* 的方式进行本地依赖。

下面是我们最终落地的 pnpm-workspace.yaml 配置,以及在公共数据库包 packages/database 中,通过自定义生成路径(output)让所有子服务共享同一份 Prisma Schema 和 Client 的关键配置:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

公共数据库包 packages/database/package.json 的具体声明如下:

{
  "name": "@mono/database",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "build": "tsc"
  },
  "dependencies": {
    "@prisma/client": "^5.12.1"
  },
  "devDependencies": {
    "prisma": "^5.12.1",
    "typescript": "^5.4.5"
  }
}

而我们在 packages/database/prisma/schema.prisma 中,特别通过指定生成路径,避免了 Prisma 默认生成到 node_modules 导致多包环境下类型找不到的玄学问题:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/client"
}

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Product {
  id          String   @id @default(uuid())
  title       String
  price       Float
  stock       Int
  createdAt   DateTime @default(now())
}

packages/database/src/index.ts 中,我们直接导出生成的 Client:

import { PrismaClient } from './generated/client';

export * from './generated/client';

export const db = new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});

而在具体业务服务(比如 apps/user-service)中,我们只需要在 package.json 里添加依赖:

{
  "name": "user-service",
  "dependencies": {
    "@mono/database": "workspace:*"
  }
}

通过这种方式,任何服务如果需要操作数据库,直接从 @mono/database 导入已经实例化好的 db 或者相关的 Prisma 类型。当我们需要修改表结构时,只需要在 packages/database 下运行 pnpm db:migrate,所有引用了该包的业务服务在编译时就会自动获取最新的 TypeScript 类型提示,再也不会出现线上环境与数据库 Schema 不一致导致的低级 Bug。

另外,针对不同微服务可能需要不同环境变量的问题,我们在公共配置包 @mono/config 里利用 zod 做了强类型的校验。凡是缺少必要环境变量的服务,在启动阶段就会直接报错抛出,决不允许带着未定义的 Undefined 裸奔上线。

目前这套 Monorepo 跑下来,开发体验提升了不止一个档次。唯一比较纠结的是,随着业务越来越复杂,Prisma Schema 的行数已经奔着上千行去了,单文件维护开始变得臃肿。大家在 Monorepo 里一般是怎么拆分 Prisma Schema 的?是用第三方工具拼接,还是干脆上多 schema 模式?