Profile 服务 —— DDD/CQRS 架构与依赖装配
profile 服务是 YouTeacher 上数据最丰富的一面 —— 教师档案、雇主(学校)档案、招聘者档案,以及那些用来把「查看联系方式」拦在门后的解锁记录。它内部按四个同心层来组织,而所有东西在启动时于同一处被缝合起来。本节讲的是这个形状和这次缝合,而不是某一个具体功能。
四层
依赖方向朝内:外层认识内层,反之绝不。
- 领域层(Domain) —— 核心。实体类自带行为(
TalentProfile、EmployerProfile、RecruiterProfile),值对象承载规则(Quota),跨聚合的逻辑放在领域服务里(UnlockService)。一份 2026-01 的审查(DDD_AUDIT.md)记录了从贫血模型刻意抽身的过程:employer.canUnlockTalent()、talent.isVisibleTo('school')、quota.hasRemaining()这类业务方法,如今是长在领域对象身上,而不是泄漏进 handler。关键在于,Repository 接口就住在这一层(domain/talent/TalentRepository.ts等)—— 领域声明它需要怎样的持久化,由基础设施去实现。这就是把依赖倒置做成了结构本身。 - 应用层(Application) —— 薄薄的、按用例划分的 handler,在每个聚合下以 command(和 query)组织,例如
application/employer/commands/UnlockTalent.ts及其招聘者版本。它们只负责编排:通过 Repository 接口加载聚合、调用领域方法、落库。审查明确指出,两个 unlock command 都委托给共享的UnlockService,而不是各自把规则重抄一遍。 - 基础设施层(Infrastructure) —— 满足领域接口的具体适配器:跑在 PostgreSQL 上的 Prisma repository、一个 S3 兼容的文件存储、一个 Redis 支撑的限流器、一个 BullMQ 人才同步适配器,以及连向 auth 和 talent-search 服务的 HTTP 客户端。
- 接口层(Interfaces) —— Fastify 控制器,每个聚合一个注册函数(
registerProfileRoutes、registerTalentRoutes、registerEmployerRoutes……),外加 service-auth 插件与错误处理。
UnlockService:一个「做对了」的领域服务
UnlockService 是观察这套领域风格最清楚的窗口。它判断某个雇主或招聘者能否解锁某位老师,靠的是依次向聚合自身发问三件事 —— 查看方是否已验证/已通过、该人才是否对这一类查看方可见、是否还有解锁额度 —— 然后返回一个带类型的 { canUnlock, error } 结果,每种失败都配一个稳定的 error code。没有 SQL,没有 HTTP,没有框架:纯粹是领域对象上的规则。正因为雇主和招聘者两条 unlock command 都调用它,这道门禁只可能被定义一次。
组合根:createApp
所有装配都发生在 bootstrap/createApp.ts。它被刻意设成唯一知道「怎样构造一个具体依赖」的地方:
initializeDependencies构造每一个适配器 —— Prisma repository、文件存储、限流器、人才同步队列适配器、auth 与 talent-search 的 HTTP 客户端,以及基于会话的UserAuthHelper。每一个都带一个??兜底:如果调用方传进来一个(AppDependencies),就用传进来的。注释道出了原因 —— 「inject to avoid shared singletons in tests」 —— 于是测试可以塞进假实现,拿到一个隔离的 app。- 随后
registerAllRoutes把这些已经建好的依赖穿进每个控制器注册函数。控制器从不自己newrepository,它们只接收自己会用到的那几个 port。 - Fastify 实例本身注册了 CORS、cookie、multipart 上传和 service-auth 插件;装了一个宽容的 JSON body 解析器;并设了一个错误处理器,把
HttpError和 Zod 校验失败映射成干净的状态码,其余一律收敛成通用的 500(不向调用方泄漏栈信息)。
这正是「把 Repository 接口放进领域层」在实践上的回报:整张依赖图从外部在一个函数里由外向内组装,而内层对 Prisma、Redis、S3、Fastify 一无所知。
配置作为一份朝内的契约:config/routes.ts
每一个路由路径 —— 以及每一个下游服务路径 —— 都通过一个 requireEnv 辅助函数从环境变量读取,缺变量就在启动时抛错。路径整条存放(例如完整的 profile-me 路径),而不是由 base 加后缀拼出来。一个 PUBLIC_ROUTES 列表点名那几个免服务间认证的端点(健康检查、技能、几个公开查询)。该服务用一枚签名的服务凭据来认证入站调用,并通过 auth 服务从会话里解析出当前用户;本节只描述这个两层形状 —— 凭据的具体机制归 dual-auth-service-jwt-user-session 与 auth。配置采用 fail-fast:配置错的部署会在启动时就死掉,而不是在生产环境里 404。
持久化:PrismaTalentRepository 作为一个代表性适配器
Prisma repository 实现领域接口,只负责一件翻译:行 ↔ 领域类实例(mapToTalentProfile 返回的是真正的 TalentProfile,而不是一个普通对象)。有两个习惯值得一提:
- 事务包住多表读写。 取一位人才连同其技能与证书,或是更新技能,全都跑在
$transaction里,于是一份档案永远不会由半写状态拼出来。 - 删除在应用代码里级联。 Prisma schema(
schema.prisma)在表之间没有定义外键关系,所以删除一位人才时会先显式移除其技能关联、证书和解锁记录,再删档案本身。schema 其余部分是直白的 snake_case 映射(@@map("talent_profiles")、@map(...)),额度以雇主与招聘者模型上的普通整数列来跟踪,并在(talentId, viewerId, viewerType)上有一个唯一约束来防重复解锁。
为什么这样建
这套分层不是装饰。Repository 接口落在领域层,使 createApp 成为唯一「把真实世界插进来」的接缝 —— 这恰恰是测试台能换进假实现的原因,也是「谁可以解锁一位老师」这条规则能被表达一次、写在一个小小的纯类里、而不散落在各个控制器中的原因。