architecture-and-layering

服务角色与六边形 + CQRS 架构

人才服务(talent service)是 YouTeacher 的一个模块,负责人才搜索、雇主/招聘方的邀请(invitation)、人才解锁(unlock)以及联系(contact)。它的代码按经典的六边形架构(端口与适配器)切分,命令与查询分离(CQRS),并由单一的 bootstrap 步骤在启动时把具体实现装配到一起。

四个分层

源码把关注点分成四个自外向内的环,外面再包一层 config 与 bootstrap。

  • 领域层(src/domain/ —— 纯业务对象,看不到任何框架或数据库。Invitation 聚合根是最清楚的例子:私有构造函数;静态 create 工厂负责生成 id、用 Node 自带的 crypto 铸造一个密码学随机 token、并计算过期时间(默认七天);fromProps 用于从存储重建,toProps 用于写回;状态迁移方法(markAsViewedacceptdecline)负责守住规则 —— accept/decline 在不满足 canRespond 时抛错,markAsViewed 只在 pending 状态下生效。isExpiredisDuplicate 也在这里。这个文件不 import 任何基础设施。
  • 应用层(src/application/ —— 命令与查询处理器(handler),按功能分组(invitation/unlock/search/contact/upsert/)。每个功能都把 commands/queries/ 分开:SendInvitationAcceptInvitationDeclineInvitationUpdateInvitationSendContactUnlockTalent 是命令;GetInvitationByTokenGetMyInvitationsGetSentInvitationsGetTalentDetailSearchTalentsGetUnlockedTalents 是查询。一个 handler 通过构造函数拿到它的协作对象,在 execute 里只做一件事。比如 UnlockTalentHandler 拿到一个 UnlockRepository,先查是否已解锁(若已存在则返回 alreadyUnlocked —— 天然幂等),否则构造领域对象 TalentUnlock 并持久化它的 toProps()
  • 基础设施层(src/infrastructure/ —— 满足应用层所声明接口的具体适配器:PrismaInvitationRepositoryPrismaContactRepositoryPrismaUnlockRepository 用 Prisma 实现仓储端口;还有 DatabaseBootstrapperUserAuthHelper 和文件存储。只有这一层知道数据库和外部系统的存在。
  • 接口层(src/interfaces/rest/ —— Fastify 交付层:TalentsHttpController 和用于服务间调用的 InternalController,外加一个 serviceAuthPlugin。控制器把 HTTP 翻译成 handler 调用,再翻译回去。

端口归谁所有

依赖箭头一律指向内侧。应用层拥有它所需要的接口 —— InvitationRepositoryContactRepositoryUnlockRepository 都放在 src/application/… 下,SearchCachePort 这类端口也是。基础设施层去实现它们。正是这一层反转,让领域层和 handler 得以对 Prisma 一无所知:handler 依赖的是 UnlockRepository,而不是 PrismaUnlockRepository。部分协作对象是可选的 —— FileStorageSearchCachePort 以可选参数传入,缺席时代码会优雅降级(附件下载 handler 只在文件存储存在时才构造)。

路由来自环境变量

src/config/routes.ts 通过一个 requireEnv 助手从环境变量读取每一条路径,变量缺失时直接抛错。路由路径(search、detail、invitations、unlock、contact、内部 users 端点、API base)因此不是写死的 —— 它们是配置,配置出错的部署会在启动时快速失败,而不是去服务一条错误的路径。

Bootstrap:装配根

src/bootstrap/ 下的三个文件组装出可运行的服务,它们是唯一让具体类与抽象端口相遇的地方:

  1. bootstrap.ts —— bootstrapDatabase(prisma) 确保表存在(DatabaseBootstrapper.ensureTables)并构造三个 Prisma 仓储。生产入口(main.ts)和测试走的是同一段代码。
  2. createHandlers.ts —— 接收仓储、一个 TalentSearchService,以及可选的缓存端口和文件存储端口,返回整套 handler,为每个 handler 注入其依赖。仓储接口就在这里被绑定到具体实例。
  3. createApp.ts —— 构建 Fastify 实例:注册 CORS 和 service-auth 插件;安装单一的错误处理器,把 HttpErrorZodError 转成结构化的 { code, message } 响应(其余一律转成通用的 500,原始堆栈永远不会到达客户端);暴露 health 与 env-check 端点;注册人才控制器。内部控制器只有在三个仓储都齐备时才挂载。一个 rewriteUrl 钩子把进来的路径按配置的 API base 归一化。

这种形状的收益:领域层可以独立测试,handler 可以对着假仓储运行,替换某个持久化适配器只是 bootstrap 里的一行改动 —— 领域层与应用层里没有任何东西需要动。

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 →

architecture-and-layering