分层与组装根
YouTeacher 的内容服务——负责博客文章、分类、文件附件——建立在 DDD + Clean Architecture 之上。整套目录结构只为一条规则服务:依赖永远只指向内层,朝着 domain 收敛。
四层与依赖规则
源码在 src/ 下分成四个环:
- domain——业务规则、不变式、实体行为、值对象校验。不依赖任何外部东西。没有 Prisma,没有 Fastify,没有 DTO。
- application——编排 domain 的 use case,协调聚合,掌管事务边界。只依赖 domain,通过 port 触达基础设施。
- infrastructure——实现这些 port 的适配器:Prisma 仓储、S3/MinIO 存储、Redis 缓存、鉴权与日志。
- interfaces——HTTP 入口(Fastify 插件、路由、Zod schema)。每个路由解析输入、调用一个 use case、序列化响应。
项目把这条规则标为 CRITICAL:interfaces → application → domain,infrastructure 通过实现 application 的 port 挂在它下面。domain 不依赖任何东西;application 只依赖 domain;interfaces 从不直接碰 infrastructure。CLAUDE.md 甚至逐条列出典型违规——在路由里聚合数据、把业务规则塞进仓储、把 PrismaClient import 进 domain 实体——这些正是这条规则要防的失败模式。
Port 与 adapter
这是依赖倒置的落地:高层代码依赖抽象,具体适配器在组装时才选定。
有个值得一提的细节:port 并不都放在同一处。StoragePort 和 IdGenerator 声明在 application/ports/——比如 StoragePort 是一个聚焦的接口,只有 uploadFeaturedImage、uploadAttachment、delete、download,返回一个朴素的 { url, key },S3/MinIO 的实现由 infrastructure 提供。而 PostRepository 接口是从 domain(@/domain/post)里 import 的。也就是说仓储契约归 domain 所有,存储和 id 生成的契约则外移一环,落在 application 层。接口隔离是刻意的——仓储 port 不带任何上传方法。
infrastructure 适配器保持"笨"
PrismaPostRepository implements PostRepository,只干一件事:纯数据访问,加上 Prisma 记录与 domain 实体之间的映射。它私有的 toDomain 方法通过 Post.reconstitute / Attachment.reconstitute 重建 Post(及其 Attachment 子实体),因此 Prisma 类型绝不越过仓储边界。save 在单个 Prisma 事务里做 upsert 加附件同步,遇到连接池超时会重试后再放弃——这是基础设施层的韧性,不是业务规则。这里没有 domain 逻辑;诸如"这篇文章是否已公开上线"之类的判断属于 domain 实体,不属于查询。
组装根
接线发生在 bootstrap/,与它所组装的各层分开。
bootstrap.ts(bootstrapDatabase)是持久化的组装根:校验数据库 URL,确保 schema 存在,然后构造 Prisma 仓储——post、category、page——给每个注入同一个PrismaClient。它返回一个装着这些仓储的朴素对象,供应用其余部分使用。createApp.ts(createApp)搭建 Fastify 实例:注册 CORS、一个能容忍空 body 的自定义 JSON content-type 解析器(这样无 body 的POST /publish也能解析)、service-auth 插件、health 与 env-check 端点、一个给路径加 API base 前缀的rewriteUrl钩子,以及一个记录日志并返回结构化 404 的 not-found handler。
组装根的价值在于构造集中在一处。因为契约由 domain 定义、由 infrastructure 实现,每个仓储实现都可互换,而接线层是唯一知道插进来的是哪个具体适配器的代码。
本节内容
- youteacher_content ——内容服务整体
- youteacher ——本服务所属的平台