architecture-ddd-cqrs-wiring

Profile 服务 —— DDD/CQRS 架构与依赖装配

profile 服务是 YouTeacher 上数据最丰富的一面 —— 教师档案、雇主(学校)档案、招聘者档案,以及那些用来把「查看联系方式」拦在门后的解锁记录。它内部按四个同心层来组织,而所有东西在启动时于同一处被缝合起来。本节讲的是这个形状和这次缝合,而不是某一个具体功能。

四层

依赖方向朝内:外层认识内层,反之绝不。

  • 领域层(Domain) —— 核心。实体类自带行为(TalentProfileEmployerProfileRecruiterProfile),值对象承载规则(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 控制器,每个聚合一个注册函数(registerProfileRoutesregisterTalentRoutesregisterEmployerRoutes……),外加 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 把这些已经建好的依赖穿进每个控制器注册函数。控制器从不自己 new repository,它们只接收自己会用到的那几个 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 成为唯一「把真实世界插进来」的接缝 —— 这恰恰是测试台能换进假实现的原因,也是「谁可以解锁一位老师」这条规则能被表达一次、写在一个小小的纯类里、而不散落在各个控制器中的原因。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →