关于 backend-architecture
面向 TypeScript 和 Node.js 后端开发的框架无关技能,帮助团队围绕清晰职责组织路由、服务、仓库、集成及外部系统边界。
主要功能
- 01分层后端边界
- 02路由校验与授权
- 03服务工作流指导
- 04仓库持久化实践
- 05外部系统适配器
适用场景
- 01组织新的后端包结构
- 02重构路由与服务职责
- 03分离数据库访问和外部集成
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ we-are-singular/ skills --skill backend-architecture安装后在 Claude Code 中输入/backend-architecture确认已加载。
详细介绍
后端架构
在进行 TypeScript 和 Node.js 后端开发时使用此技能。它不依赖特定框架:可将相同职责映射到本地框架,应用于 Fastify、Express、Hono、Workers、队列、CLI、SDK、数据库包、集成模块和服务包。
第一步
修改代码前:
- 先阅读仓库指南:
AGENTS.md、包 README、现有约定、本地测试设置和附近的代码。 - 检查当前包布局、导入、命名、服务入口、数据库/仓库代码、辅助函数和测试。
- 明确时优先遵循仓库现有约定。对于新代码、含糊的代码或需要进行架构清理的代码,默认使用此技能。
- 搜索后再创建新的辅助函数、服务、架构、仓库、中间件或共享类型。合并相似代码,不要再添加一个近似重复项。
- 编辑架构或 DTO 前,先确定契约和生成文件的权威来源。
默认原则:及早划定边界,保持实现精简。
尽早确定应用或包的分层结构,并始终如一地遵循;不要让每个用例都自行发明一套洋葱式架构。每个边界内的代码应尽可能直接地履行其职责。
核心原则
- 后端代码使用 TypeScript。优先采用严格、明确的领域模型,而非宽泛的对象。
- 采用渐进式披露:让顶层用例编排按领域逻辑线性展开,清晰呈现正常流程,并将解析、进程、协议和存储机制放入负责这些内容的边界中。
- 在真正需要复用前,让实现保持简单且局部化。
- 按领域职责命名:
MailboxRepository、ProvisioningService、createRepository、WebhookCryptoService。 - 避免使用
Manager、Handler、Processor或Util等含糊名称,除非框架或领域角色本身确实含糊。 - 由唯一的所有者管理状态及其不变量。让该所有者执行领域操作,而不是暴露状态供调用方协调。
- 仓库支持时,使用 ESM 和 Node 内置模块的
node:导入方式。 - 明确标记生成文件;除非仓库明确要求,否则不要手动编辑这些文件。
- 优先采用能隐藏实质复杂性的深层模块,而不是浅层辅助函数、汇总导出文件和单一用途的间接层。
- 避免“意大利面式 DRY”:只有在能简化调用点并明确所有权时,集中化才有价值。
- 没有依据时,不要臆造未来状态或防御性机制。对于尚未实现的行为,留下明确的 TODO;安全、数据完整性、清理工作和有文档说明的运行时契约仍是必需的。
- 不要让仅限服务端的包进入浏览器包;如果意外的浏览器导入会造成危险,请添加明确的运行时保护。
- 遵循仓库本地的格式、lint 规则和导入顺序,不要按个人偏好行事。
架构边界
将函数式核心/命令式外壳作为思考模型:让领域决策独立于 I/O 机制,并由应用工作流协调仓库和集成。在编写代码前,先映射这些职责。
路由、控制器与中间件
路由/控制器应负责传输层关注点:
- HTTP 方法、路径、状态码、请求/响应结构。
- 调用方和请求状态、身份验证及守卫配置。
- 请求解析和架构验证。
- 请求级策略,用于决定用例是否可以启动,以及如何启动。
- 将可信输入委派给服务或存储库。
- 将预期的领域结果映射为传输层响应。
如果框架支持,请优先采用由配置驱动的路由声明。在路由配置中声明身份验证、守卫、输入/输出架构和速率限制,可以让处理程序保持精简,并使授权一目了然,便于审查。以下 Fastify 风格示例仅用于说明;在其他框架中也应保持相同的结构,而不是照搬具体 API:
fastify.post("/mailboxes", {
config: {
auth: ["bearer"],
input: { schema: CreateMailboxRequest },
output: { schema: MailboxDTO },
guards: [hasActiveOrganization(), scopeAllows("mailbox:create")],
},
handler: async (request, reply) => {
const input = CreateMailboxRequest.parse(request.body)
const mailbox = await fastify.provisioning.createMailbox(input)
return reply.code(201).send(mailbox)
},
})
如果框架不支持由配置驱动的路由,则应通过在处理程序顶部调用守卫和验证器,再委派给服务,来保持相同的分离原则。
async function provisionMailboxController(mailbox: MailboxDraft) {
await userGuard(mailbox.user.id, "mailbox.create")
const provisioned = await provisionMailbox(mailbox)
return MailboxSchema.parse(provisioned)
}
第二个示例假设 MailboxDraft 已经是经过解析的可信请求输入。它以不使用路由 DSL 的方式表达了相同的边界:控制器负责 ACL 和公共 DTO 解析,而 provisionMailbox 接收可信输入,不了解传输状态。
在每个应用或框架边界统一传输响应和错误封装。不要为每条路由临时设计响应格式。
前端应用也可能有后端层:服务端操作、中间件、集成、计划任务、路由处理程序,以及仅限服务端使用的包。应像对待独立 API 一样,以相同的边界原则处理它们。
中间件/插件/钩子应是精简的组合层:
- 构建请求上下文。
- 连接共享依赖项。
- 执行身份验证、守卫、验证、速率限制、CORS、安全标头、跟踪和日志记录。
- 为应用/服务端装饰服务、存储库、客户端或请求辅助工具。
不要在中间件中放置冗长的业务工作流。应将工作流移至服务,并让中间件专注于组合和横切行为。
守卫与授权
守卫是可组合的授权检查,在路由处理程序之前运行。它们将“谁可以执行此操作”与“此操作的具体内容”分离开来。
- 让每个守卫专注于一项授权关注点:成员身份、角色、作用域、套餐限制、资源所有权。
- 为常见的守卫类型使用具名工厂函数:身份、权限、资源所有权、套餐/限制和条件守卫。
- 如果可用,优先在路由/配置级别声明守卫。否则,应在处理程序顶部、业务工作开始前运行守卫。
- 失败时默认拒绝:受守卫保护的路由也应声明身份验证,并尽可能在启动或注册路由时捕获缺失的身份验证配置。
- 始终一致地使用框架常规的拒绝方式。不要强迫所有代码库都采用同一种
throw或reply模式。 - 如果透露跨租户资源的存在会泄露信息,则返回
404而非403。
插件与模块生命周期
插件和模块负责将基础设施接入服务端。它们应保持精简、声明依赖项,并按可预测的顺序注册。
- 在组合根(服务端引导程序)中按顺序注册插件。顺序很重要:数据库先于存储库,存储库先于服务,服务先于身份验证,身份验证先于守卫。
- 如果框架支持,请显式声明依赖项。
- 每个插件都应使用其负责的服务或客户端装饰服务端/应用,然后停止。不要在插件注册过程中串接业务逻辑。
- 使用框架的类型扩展或类型化的局部变量/上下文,确保在调用位置安全地使用装饰后的服务。
- 在测试中,有意替换或跳过非必要的基础设施。不要让 CORS、速率限制、调度器或监听器增加后端测试的噪声。
集成与运行时上下文
框架集成应负责接入运行时上下文和生命周期:
- 注册中间件/钩子。
- 注入带类型的本地变量/上下文。
- 创建长期存在的客户端和仓库提供器。
- 在框架需要时注入生成的环境类型。
- 仅在失败无碍且会记录日志的情况下,运行非关键迁移或启动检查。
确保集成模块具备幂等性。在开发服务器中,HMR 或模块重新求值可能会创建重复的调度器、客户端或监听器。如果运行时支持,请使用明确的防护措施和释放/清理钩子。
服务
服务负责应用工作流:
- 协调仓库、外部客户端、工作进程、队列、邮件发送器和 SDK。
- 执行涉及持久化和外部操作的业务顺序。
- 决定如何从外部故障中恢复。
- 围绕重要的生命周期步骤、状态转换和故障记录日志。
服务可以是单一用途的操作,也可以是编排器。它们接收可信输入,返回内部或领域输出,并负责持久业务规则和工作流顺序——而非调用方状态、传输策略或公开响应格式。
通过组合意图明确的操作,让编排器保持扁平、易读:
async function provisionMailbox(mailbox: MailboxDraft) {
const reserved = await mailboxes.reserve(mailbox)
const provisioned = await mailServer.provision(reserved)
await mailboxes.activate(reserved.id, provisioned)
return provisioned
}
服务可以调用外部系统,仓库则不应调用。
当服务协调持久化和外部系统时,应根据事实来源安排操作顺序,并通过明确的清理措施处理故障:
- 先在本地预留,再进行外部配置。 如果外部调用失败,应清理本地记录或将其标记为失败。这可以防止产生孤立的外部资源。
- 轮换/更新时,先更新事实来源。 轮换令牌时,先更新外部提供商,再将新哈希持久化到本地。如果外部更新失败,旧的本地值仍然有效。
- 对非关键工作使用非阻塞副作用。 对于不应阻塞主工作流的欢迎邮件、分析或通知,采用触发后不等待结果的方式,并在捕获异常时记录警告。
如果有意让副作用非阻塞,应在代码中明确体现,并始终附加捕获异常并记录警告的日志处理。
对于有状态的服务、注入了依赖项的服务、具有生命周期或日志上下文的服务,或包含多个公开工作流方法的服务,请使用类。纯转换和一次性行为请使用函数。
仓库
仓库应具备足够的职责,使其存在具有意义。只封装 db.table.create(data) 的仓库通常只会增加无谓的代码。
将数据库客户端视为原始基础设施:Prisma、Drizzle、SQL 构建器或任何其他客户端对象。将仓库视为围绕该客户端构建的智能持久化层。在测试中,可以直接使用客户端构造单个仓库;而应用通常会接收一个仓库工厂依赖图。
仓库负责持久化行为:
- 查询和查询组合。
- 默认值和存储层验证。
- 实体状态转换。
- 持久化工作流的数据库事务。
- 将多个数据库记录作为一次存储操作创建、更新或删除。
- 由数据库支持的审计/发件箱记录,以及父子记录更新。
- 分页和排序默认值。
- 在返回原始行前,将其解析为持久 DTO 或 Schema。
仓库的副作用必须仅限于数据库。不要在仓库中调用外部 API、队列、邮件发送器、SDK、HTTP 客户端或文件系统。
事务通常属于仓库的实现细节。对于真正跨仓库的工作流,可以使用服务级事务,但不要默认给每个方法都添加 tx 参数。只有在工作流确有需要时才添加事务支持。
当 BaseRepo 或共享仓库辅助工具能够消除实际重复时,请使用它们,例如处理分页、findOrThrow、行解析、审计默认值、slug 辅助逻辑或通用事务辅助逻辑。保持其简单且面向存储。不要把它变成通用业务框架。
外部系统
将第三方 API、SDK、队列、邮件提供商、支付提供商和基础设施客户端封装在你自己的服务或适配器后面。
路由和核心业务逻辑应调用应用边界,而不是直接调用供应商 SDK。这样可以将配置、日志记录、重试、错误映射、测试替身和未来的替换集中在一处。
对于 SDK 风格的包:
- 在生成的客户端或协议客户端上提供一个小型门面。
- 将协议回调 API 规范化为基于 promise 的应用方法。
- 在客户端边界设置截止时间/超时。
- 对预期的远程结果返回类型化的领域响应。
- 在适当情况下,避免在面向用户的响应中暴露内部传输错误。
- 支持对底层客户端进行依赖注入,以便测试使用类型化模拟对象。
- 如果客户端拥有套接字或进程,则提供显式的
disconnect/清理方法。
当外部操作存在调用方需要分支处理的预期失败模式时,使用可辨识的结果类型:
type ProvisionResult<T> = { ok: true; data: T; warnings?: string[] } | { ok: false; error: string }
这样可以避免为预期结果使用 try/catch,并让调用方的分支逻辑更明确。异常应留给意外的基础设施故障。
当外部客户端或服务具有可归组的子领域时,使用带类型化子模块的门面类,让这些子模块共享通用传输或运行时上下文。直接对这些子模块进行单元测试,但让应用的其余部分与门面交互。
class MailServer {
readonly mailboxes: MailServerMailbox
readonly domains: MailServerDomain
constructor(config: MailServerConfig) {
const ctx = createSharedContext(config)
this.mailboxes = new MailServerMailbox(ctx, config.mailboxDefaults)
this.domains = new MailServerDomain(ctx)
}
}
文件和命名
命名大体上采用严格规范:
- 文件名使用 kebab-case:
webhook-dispatch.service.ts、organization-domain.repo.ts。 - 在有助于识别文件类型时,使用相应后缀:
.service.ts、.repo.ts、.plugin.ts、.module.ts、.schema.ts、.dto.ts、.test.ts。 - 如果目录或仓库约定已能明确表明文件的作用,则省略后缀,例如
routes/api/mailbox.ts。 - 优先按功能/领域分组,避免按技术类型随意堆放。
- 避免过深的嵌套,除非每一层都代表实际的归属关系。
常见结构包括 routes/services/plugins/lib/schemas、domains/mailbox 等领域文件夹,或 spec/db/api/utils 等包边界。优先遵循仓库现有的结构。重点在于归属关系,而非具体的文件夹名称。
类型、架构和契约
在信任边界使用运行时架构:
- HTTP 请求体、参数、查询字符串和响应。
- Webhook 和外部事件。
- 环境变量/配置值。
- 进入公共 DTO 的数据库行。
- 队列/任务负载。
架构的放置方式:
- 将特定路由的请求架构放在路由/模块附近。
- 尽早集中管理稳定的领域契约、DTO、数据库表定义、身份验证/配置规范、共享事件负载和公共 API 结构。
- 如果某个类型适用于整个应用、对安全敏感、属于配置的一部分或被多个包使用,则将其集中管理。
- 如果某个类型仅由一个功能/领域负责,则将其保留在该功能/领域中。
- 如果某个类型很小,且仅用于一个函数或文件,则将其内联。
- 只有在源文件变得难以阅读时,才创建局部的
types.ts或辅助文件。
在负责数据的边界处解析数据:在控制器中解析请求和公共响应,在仓库中解析持久化行,在集成层中解析提供商负载。将解析后可信的值传入内部,而不是继续在系统中传递原始值:
const CreateMailboxRequest = z.object({
localPart: z.string().optional(),
name: z.string().optional(),
})
route.post("/mailboxes", async (request) => {
const input = CreateMailboxRequest.parse(request.body)
return mailboxService.create(input)
})
在可行的情况下,仓库应返回经过解析的稳定结构:
class MailboxRepository {
async getById(id: string) {
const row = await db.query.mailbox.findFirst({ where: { id } })
return row ? MailboxDTO.parse(row) : null
}
}
当字段表示互斥状态时,使用可辨识联合类型。让非法组合难以构造,而不是仅通过可空字段约定来说明。
默认使用返回类型推断。仅当显式返回类型能让公共契约、导出工厂、公共类方法、异步边界行为更清晰,或仓库本地 lint 规则要求时,才添加显式返回类型。
单一事实来源
对于需要长期稳定的结构,应使用一个规范契约来源。良好的模式:
- 数据库表定义生成插入/查询 schema 和原始 DB 类型。
- DTO schema 扩展或收窄生成的表 schema。
- 控制器在响应前通过公开 DTO 解析服务结果。
- SDK 客户端将生成的协议类型规范化为应用自有的响应类型。
不良模式:
- 在 YAML、JSON Schema、Zod 和 TypeScript 中手动维护相同的 API 结构。
- 将请求/响应类型复制到每一层,而不是派生或导入。
- 从多个相互竞争的来源生成代码,却没有明确记录由谁负责。
如果需要多种契约格式,应从规范来源生成次要格式。不要手动维护并行的事实来源。
代码生成
如果代码库使用构建步骤来生成 schema、仓库工厂、barrel 导出或路由树,应将生成流程视为基础设施。
- 使用
.gen.ts后缀或头部注释明确标记生成文件。除非流程明确要求,否则绝不要手动编辑生成的输出。 - 保持生成器脚本简单且按顺序执行。使用带日志的命名步骤,便于追踪故障。
- 从一个规范来源生成次要产物:从表定义生成 schema,从 DTO schema 生成公开类型,从已实现的模块生成工厂索引,或从路由文件生成路由树。
- 如果过期输出会导致使用方出错,应将生成流程纳入常规构建/测试流程。
- 在拥有生成器的包附近记录重新生成命令。
- 添加实体时,先更新手写源文件,再重新生成派生文件。
类、依赖注入与工厂
当对象拥有依赖项、状态、生命周期、日志上下文或一组内聚的公开方法时,使用类。服务不必仅仅因为参与了所选架构就必须是类。
如果后端服务、仓库、适配器、外观和模块适合由对象持有,则应在组合阶段构建一次。每个进程、测试应用、工作进程或请求作用域,优先使用一个应用对象图。除非本地代码库明显采用这种风格,否则避免使用由模型驱动的类,让每个数据库行都成为带有行为的对象。默认避免继承;仅在稳定的基础设施机制确实能消除重复时使用。
当行为是纯函数、无状态,或更适合表示为守卫/辅助函数/转换函数时,使用函数。
将状态转换封装在拥有不变量的类、服务或模块中;调用方应请求执行领域操作,而不是协调内部变更。
默认使用具体依赖:
class BillingService {
constructor(
private readonly users: UserRepository,
private readonly payments: PaymentService,
private readonly logger: Logger,
) {}
}
仅当依赖项有意设计得非常精简时,才使用范围狭窄的依赖形状:
type TokenSigner = {
sign(payload: TokenPayload): string
}
class SessionService {
constructor(private readonly tokenSigner: TokenSigner) {}
}
默认避免命名端口/接口。它们会增加另一份可能发生偏移的契约。只有在存在多个实现、需要包独立性,或具体注入无法妥善解决的可测试性问题时,才引入它们。
工厂应用于标准装配,而非简单的对象创建。仓库工厂可以接收原始数据库客户端并返回智能仓库实例;使用 ReturnType 推导对象图类型,而不是手动重复定义。
以下情况适合使用工厂:
- 需要一致地构建许多依赖项。
- 测试需要可重复的应用/仓库/服务对象图。
- 框架插件/模块会为运行时添加共享对象。
- 配置和生命周期必须集中管理。
配置 DSL、注册表与高级抽象
当某个领域存在重复配置、执行上下文和许多变体时,设计高级抽象。抽象应足够具体,让调用点比直接编写代码更简单。
适用场景:
- Agent/工具/提供方注册表。
- 图表或报告配置 DSL。
- 带类型步骤的作业流水线。
- 数据填充器和测试固件加载器。
- 基于生成式客户端构建的 SDK 门面。
- 用于连接中间件和类型化本地变量的框架集成。
规则:
- 如果需要强类型推断和简洁的编写方式,优先使用
defineX()辅助函数。 - 配置对象应保持声明式,执行包装器应保持精简。
- 将静态配置与运行时上下文分开。
- 使用注册表提升可发现性,并支持类型安全的查找。
- 对未知键,注册表应抛出清晰的错误。
- 只有在能简化调用点时,才支持类别或分组。
- 不要只测试每个变体,还要为解析器/工厂添加测试。
示例:
export function defineTool<const T extends ToolConfig>(config: T): T {
return config
}
const TOOL_CONFIG = {
catalog: {
search: defineTool({ name: "Catalog search", input, output }),
},
chart: {
cartesian: defineTool({ name: "Cartesian chart", input, output }),
},
} as const
export function getTools(keys: Array<ToolKey | ToolCategory>) {
const selected = resolveToolConfigs(TOOL_CONFIG, keys)
return (context: ToolContext) => Object.fromEntries(selected.map((tool) => [tool.id, tool.withContext(context)]))
}
以下情况应避免使用 DSL:
- 只有一两个变体。
- 抽象通过复杂的类型技巧掩盖了简单代码。
- 配置已变成另一处行为真相来源,而实际行为却定义在别处。
- 解析后,每个变体仍然需要大量自定义分支处理。
工作流流水线
对于多步骤工作流,如果步骤顺序、共享状态、中止行为或流式传输/输出控制很重要,应优先采用显式流水线。
使用:
- 用于累积工作流数据的可变类型化执行状态。
- 签名统一的小型步骤函数。
- 由编排器集中处理错误。
- 当步骤可以停止流水线时,使用显式的
next()/abort()或等效流程控制。 - 为流写入器、锁或事务等独占资源设置专用控制对象。
每个步骤不应各自发明一套 try/catch 策略。应由编排器处理意外错误,让步骤返回类型化结果,或中止预期失败。
辅助函数与共享工具
当辅助函数能命名一个真实概念、隐藏实质性复杂度,或稳定的重复用法已经出现时,再提取它。相比串联多个浅层辅助函数,优先采用一个深入处理的操作。先搜索现有实现。
添加辅助函数前:
- 查找行为重叠的现有辅助函数。
- 检查新辅助函数能否整合过去和现在的类似用例。
- 使用明确具体的名称。避免让
utils.ts逐渐塞入互不相关的函数。 - 涉及解析、规范化、安全、加密、日期、ID 或边界情况时,添加有针对性的测试。
不要为每个微小函数单独创建文件,也不要仅为消除一行重复代码而提取辅助函数。对于微小且只使用一次的逻辑,先内联处理,直到提取能让调用方更清晰或避免实质性重复。
环境与配置
保持环境配置简单、类型明确且集中管理。优先使用大写环境变量键、显式默认值,以及用于派生运行时标志的计算 getter:
export const env = {
NODE_ENV: process.env.NODE_ENV ?? "development",
PORT: Number(process.env.PORT) || 3000,
DATABASE_URL: process.env.DATABASE_URL ?? "",
get isDev() {
return this.NODE_ENV === "development"
},
get isTest() {
return this.NODE_ENV === "test" || process.env.VITEST === "true"
},
} as const
对于小范围配置,普通配置对象就足够了。当配置范围较大、缺失值会导致难以调试的故障、值会跨包传递,或框架要求配置经过验证时,使用运行时模式验证。对于少量显而易见的变量,不要添加完整的环境变量验证层。
数据填充器与测试数据
数据填充器属于后端基础设施。应将其视为代码,而不是一次性脚本。
优质的数据填充器:
- 当外键或依赖关系很重要时,明确指定表的顺序。
- 当重置行为不同时,将自增表与无 ID/联结表分开处理。
- 将序列化的 JSON 值转换回
Date等运行时类型。 - 提供
seed()和clear()入口。 - 支持仅填充部分表/文件,以便进行有针对性的测试。
- 将数据库特定的重置行为保留在数据填充器中,而不是让每个测试各自处理。
谨慎处理静默回退。文件缺失时返回空数据可能很方便,但这应是有意制定的测试数据策略,而不是意外地隐藏错误。
注释与文档
注释应说明意图、不变量、执行顺序、安全性以及外部行为。
以下情况使用 JSDoc:
- 公共服务、存储库、工厂、模块和复杂辅助函数。
- 执行顺序或失败语义不明显的公共方法。
- 涉及安全的代码。
- 外部系统行为。
- 数据库转换和一致性规则。
以下情况谨慎使用行内注释:
- 复杂的解析逻辑、伪 URL、身份验证/会话行为、提供方接线、回退方案、临时变通方法和有意使用的权宜之计。
- 说明为何顺序很重要。
- 说明为何有意采用非阻塞操作。
- 说明回退方案为何安全。
- 说明某个看似意外的分支为何存在。
避免使用复述代码的注释。
好的示例:
/**
* Rotate the token by updating the external provider before storing the new hash.
* If the provider update fails, the old token remains valid locally.
*/
async rotateToken(id: string) {
// External provider is source of truth for live auth, so update it first.
}
不好的示例:
// Set the name field to the name from the input.
user.name = input.name
错误处理
使用守卫子句,在执行正常流程前拒绝无效情况。只有当当前层负责恢复策略时才添加本地 try/catch;否则,让错误传递到现有边界。
应明确处理预期的领域结果:
- 缺失属于正常情况时,返回
null。 - 当调用方需要分支处理时,返回带类型的结果/状态对象。
- 对于具有预期失败模式的外部操作,使用
{ ok: true; data } | { ok: false; error }判别联合结果。 - 使用 DTO 状态字段表示持久化的工作流状态。
- 在路由/控制器边界将预期失败映射为传输错误。
以下情况应抛出错误:
- 程序员错误。
- 不可能出现的状态。
- 调用方不应视为普通缺失的基础设施或外部系统操作失败。
- 如果继续执行会破坏状态的持久化失败。
如果带类型的返回值更清晰,就不要用通用异常掩盖预期的业务结果。
仅在有意采用尽力而为的部分列表界面中使用宽容解析;此时,丢弃无效项目必须是产品层面的决定,并且要记录包含足够上下文的失败信息。对于规范数据库读取、管理视图、涉及安全的数据以及持久化契约,应明确报错,而不是掩盖格式错误的数据。
在传输、操作或控制器边界,使用共享的 getErrorMessage 风格工具,将未知错误转换为面向用户的消息。保留明确的框架/领域错误,不要将其包装成通用消息。
日志记录与可观测性
日志记录的一致性体现了后端是否得到良好维护。使用项目的日志包或全局日志记录模式。如果两者都不存在,应先提出引入日志方案,再考虑创建本地日志风格。
指南:
- 优先使用可注入或共享的日志记录器,以便服务继承请求/模块上下文。
- 使用
trace和debug记录详细的工作流步骤。 - 尽量少用
info记录生命周期事件和重要状态变更。 - 使用
warn记录可恢复的意外情况。 - 使用
error记录需要关注的操作失败。 - 包含有用的 ID 和上下文,不要记录原始机密或大型请求正文。
- 让日志级别决定详细程度。不要仅为了让实现保持安静而移除有用的 debug/trace 日志。
测试
默认采用测试辅助开发:
- 后端变更时,添加或更新测试。
- 对于 bug、回归、存在风险的分支、契约、安全、加密、身份验证/防护机制和持久化状态转换,先编写测试。
- 对于实现方式仍在变化的探索性工作,避免围绕频繁变动的内容固化测试。实现方式稳定后,为最终的分支、契约和失败场景添加或更新行为测试。
- 不要测试 TypeScript 已经保证的内容。
- 通过负责该行为的稳定边界测试可观察行为。不要测试单行辅助函数或偶然的实现顺序。
默认的后端测试布局:
tests/
unit/
integration/
helpers/
seed/
__snapshots__/
如果仓库已有就近测试或其他明确惯例,请遵循现有做法。
测试选择:
- 使用虚假依赖对纯辅助函数、防护机制、加密、验证和服务逻辑进行单元测试。
- 使用框架的注入/测试客户端对路由进行集成测试,包括 ACL、委派、DTO 解析和错误映射。
- 使用可信输入和可观察输出来测试服务编排器;仅当操作顺序属于契约时才断言其顺序。
- 使用种子数据和真实 DTO 解析对持久化行为进行仓库测试。
- 仅对稳定的响应、DTO 和错误契约使用快照。
- 使用明确的匹配器规范化生成的 ID、时间戳、随机值和外部值。
- 除非仓库另有惯例,否则尽量减少嵌套的
describe块。
测试模拟模式:
- 构建上下文工厂(
fakeCtx()、fakeRequest()、createTestApp()),返回具有合理默认值的类型化测试替身。让每个测试只覆盖自己关心的部分。 - 对于已支持依赖注入(DI)的 SDK 和服务,优先使用通过构造函数注入的模拟客户端。
- 仅在代码库已有此类用法,或构造函数注入会扭曲生产代码时,才使用模块级模拟。
- 将模拟状态保持在最小范围,并在测试之间重置。
- 对具有许多输入/输出组合的纯函数,使用表驱动测试(
it.each或小型辅助函数)。
应做与不应做
应做:
- 尽早确定应用的边界,并在各个用例中始终如一地遵循。
- 保持控制器扁平:解析请求状态和防护机制,委派可信输入,然后解析公开 DTO。
- 让服务专注于其输入、输出、持久化业务规则和明确命名的领域操作。
- 在类、依赖注入和工厂有助于明确归属、生命周期或构造过程时使用它们。
- 将复杂机制下沉到意图清晰、抽象深入的接口之后。
不应做:
- 将类、继承、依赖注入或工厂本身视为架构。
- 创建只负责转发调用的控制器、服务、仓库或辅助函数链。
- 将传输层身份验证或请求状态传入可复用服务,也不要让这些服务返回公开 DTO。
- 不要在缺乏恢复职责的情况下,用嵌套分支或局部
try/catch块掩盖正常流程。 - 不要让单个用例绕过或自行重造应用选定的洋葱架构。
Review Checklist
审查后端代码时,请检查:
- 传输、工作流、持久化和外部系统的职责是否彼此分离,控制器和服务边界处是否清晰呈现了常规执行路径?
- 控制器是否负责调用方状态、ACL、请求/响应解析和传输映射?
- 服务是否接收可信输入,并负责可复用操作、持久化业务规则和工作流编排?
- 守卫是否可组合、在路由边界接线,并采用默认拒绝策略?
- 持久化契约是否集中管理,端点本地的 schema 是否保留在本地?
- 是否只有一个契约事实来源,还是手动重复定义了多份结构?
- 生成的文件是否明确标记,并由手写源文件生成?
- 仓储是否足够丰富,同时仅限于数据库副作用?
- 外部系统是否由服务/适配器封装,并使用可区分的结果类型?
- 生成的/协议客户端是否隐藏在应用自有的门面之后?
- 插件是否按依赖顺序注册,并显式声明?
- 注册表/工厂是否确实有用且经过测试,还是仅仅为了抽象而抽象?
- 名称是否符合具体领域,文件是否放在后续读者会查找的位置?
- 日志是否包含上下文、由级别驱动,并与项目保持一致?
- 预期内的失败是否明确处理,意外失败是否允许浮现?
- 测试是否覆盖行为、契约、持久化状态转换和高风险分支?
- 测试是否使用上下文工厂来构造模拟对象,而非临时编写对象字面量?