Backend 架构 Pattern
读者:新成员、跨 repo 切换的工程师、需要解释 retaintive backend 如何工作的 PM / founder / AI Agent。
本文讲 backend 运行机制和 repo 内代码分层。系统级 repo 地图见 系统架构总览,通话/SMS/Lead 数据管道见 Backend 数据管道。
TL;DR
retaintive 有两类 backend,不应该混成一个东西:
两个 backend 都遵守同一个方向:业务规则不应该依赖具体 vendor / framework / database client。差别是抽象深度不同。
1. 基础词汇
Web vs API
apps/web 是前端应用。它被 build 成 HTML/JS/CSS,部署在 Cloudflare Pages。用户浏览器下载这些静态文件后,JavaScript 在浏览器里运行。
apps/api 是 backend API。它被 build 成 Lambda bundle,部署在 AWS Lambda,通过 API Gateway 暴露成 studio-api*.retaintive.ai。
前端调用后端的本质是 HTTP:
API backend vs processing backend
apps/api 是前端查询 API。它不负责从 RingCentral 下载录音,也不负责调用 Deepgram / OpenRouter 做 AI 分析。
这些后台处理在 callytics-infrastructure/lambda/*:
Route / Service / Repository
在 server-side API 里,不建议用 MVC 描述 backend。更准确的是:
2. studio-api request lifecycle
一次用户访问 Dashboard 的 API 请求,大致是这个链路:
这条链路解释了几个常见疑问:
- 为什么前端能传数据给后端? 因为浏览器发 HTTP request,不是因为 frontend/backend 在同一个 repo。
- 为什么后端能读数据库? 因为 API Lambda 部署时配置了环境变量、secret、network、IAM 和 database connection。
- auth 在哪里发生? Cognito 发 token;前端 API client 附上 access token;API middleware 验证 token;route/helper 再检查 store-level 权限。
- API 和业务处理 Lambda 的关系是什么? API 通常读 processing Lambda 写好的结果;processing Lambda 不由浏览器直接调用。
本地代码里当前同时存在两套前端 API client:
3. studio-api 的 Layered 架构
studio-website-monorepo/apps/api 选择 layered,是因为它主要是 read-heavy API:复杂查询、筛选、排序、分页、少量写入。目标结构是 routes/ → services/ → repositories/,但迁移仍在进行:简单 v3 Neon read route 仍会直接在 route 里写 parameterized SQL。
3.1 Route 层负责什么
Route 是 HTTP 边界,应该处理:
- 读取
c.req.query()/c.req.json() - 用 Zod 做 input validation
- 从 middleware 读取
auth - 调用 service/helper
- 把结果转成 JSON response
- 把错误交给统一 error handler
Route 不应该承载复杂业务规则。读查询很薄时可以 inline SQL;一旦规则开始变多,就应该抽 service。
3.2 Services 和 Auth / Store context
代码里已经有明确的 src/services/ 和 src/repositories/:
src/services/*-neon.ts:Neon-backed business/data helpers,例如 stores、members、phone numbers、OAuth states。src/services/ringcentral/*、token-access.ts、subscription-client.ts:外部服务和 cross-service 调用。src/repositories/*:仍保留的 DynamoDB data access,例如call-analysis.ts、phone-store-assignments.ts。src/utils/authorization.ts/store-authorization-neon.ts:store-level authorization helper。
getStoreContext(userId, storeId) 本质上是 service 层逻辑,因为它做了业务判断:
它现在放在 routes/v3/shared.ts 是务实选择:多个 v3 route 共用,且这些 endpoint 是 read-heavy Neon SQL。语义上仍要把它当 service boundary 看,而不是“普通 util”。其他 v2/v3 route 也可能用 getAuthorizedStore() 或 getAuthorizedStoreNeon() 做同类 authorization。
3.3 SQL / Repository 层负责什么
studio-api 里大量查询是 analytical read:CTE、多 JOIN、动态 filters、pagination、sort。用 raw SQL 更直接,也更容易看执行计划。
纪律是:
- 必须用 parameterized query:
$1,$2,$N - 用户输入只能进入 params array,不能拼进 SQL string
store_id/ tenant scope 必须在 SQL 条件里明确出现- 同一查询逻辑复制到 3 个以上 endpoint 时,抽 repository/helper
3.4 什么时候必须抽 service
下面情况不要继续把逻辑塞进 route:
目标不是教条地建立 services/ 和 repositories/ 目录,而是让 boundary 清楚。
4. Authentication 和 store-level authorization
认证和授权是两层:
不要把“有 token”理解成“能访问所有数据”。token 只证明身份;store-level authorization 决定数据范围。
本地 test 环境还有一个专用旁路:X-Test-User-Id header 可以跳过 JWT,用于 integration/dev test;prod 不允许。
5. callytics-infrastructure 的 Hexagonal 架构
callytics-infrastructure/lambda/* 的复杂 processor 选择 hexagonal,是因为后台处理业务复杂、外部依赖多、需要单元测试时 mock vendor/AWS。transcribe-processor 和 ai-analysis-processor 是最明确的 reference;message-processor 也拆成 core/ 与 infrastructure/;lead-processor 这类较薄的 SQS consumer 则更接近 thin handler + core function。
- Deepgram / AWS Transcribe
- DeepSeek V4 Flash via OpenRouter
- SQS
- DynamoDB
- S3
- Neon
- Secrets Manager
核心思想:core 定义自己需要什么能力(ports),infrastructure 提供具体实现(adapters)。
典型目录:
5.1 handler.ts 是 composition root
handler.ts 允许做这些事:
- import AWS SDK
- 初始化 AWS clients
- 读取 env vars
- 创建 infrastructure adapters
- 把 adapters 注入 core pipeline
- 处理 SQS batch response / retry / partial failure
core/ 不应该 import AWS SDK,也不应该知道 Lambda/SQS/EventBridge 的存在。
5.2 Core 只依赖 ports
Core 应该写成:
这样测试时可以传 fake deps,不需要真的调用 AWS / AI vendor / Neon:
5.3 Adapters 负责技术细节
Adapter 可以知道具体 vendor:
NeonAnalysisRepository知道 SQL/DrizzleS3ConfigRepository知道 bucket/keyOpenRouterAiClient/invokeAIwrapper 知道 API endpoint/model nameSqsTaskPublisher知道 queue URL/message shape
但这些细节不能倒灌进 core。
6. Layered vs Hexagonal 怎么选
判断标准:不是“哪个 pattern 更高级”,而是“业务规则是否值得从 runtime/vendor 细节里剥离出来”。
7. ORM 策略:Drizzle vs raw SQL
retaintive 混用 Drizzle 和 raw SQL 是有意选择。
安全边界:
- raw SQL 必须 parameterized
- table/column 名不要从用户输入动态拼接
- sort field 必须 whitelist
- tenant/store scope 必须显式进 where clause
- write path 优先复用 Drizzle schema/type
8. 不要混淆的三组边界
部署边界
同一个 repo 里的目录可以部署到不同平台;不同 repo 的 Lambda 也可能通过 SQS 在同一个 runtime pipeline 里协作。
请求边界
看到“后端”二字时先问:这是 HTTP API backend,还是 async processing backend?
数据边界
9. 新增功能时怎么落位
新增前端页面需要读数据
- 在
apps/web加 page/component/query hook。 - 在
apps/api加 route。 - Route 做 Zod validation + auth context。
- 需要 store 权限时调用
getStoreContext()或同类 helper。 - SQL 必须带 store/org scope。
- 复杂逻辑超过 route 职责时抽 service/helper。
新增后台处理步骤
- 先确认触发源:SQS、EventBridge、manual invoke、还是 webhook。
- 在
lambda/<processor>/src/core写 use case / pipeline step。 - 在
core/protocols.ts定义需要的 ports。 - 在
infrastructure/实现 vendor/AWS/DB adapters。 - 在
handler.ts装配 adapters。 - 用 fake adapters 测 core;用 integration test 覆盖 adapter 关键路径。
- CDK 里声明 queue/event/env/permissions。
新增共享字段或表
- 先改
callytics-commonschema/type。 - 明确 writer:哪个 Lambda/API 写这个字段。
- 明确 reader:哪个 API/processor 读这个字段。
- 更新写入矩阵或对应 system-design 文档。
- 跨 repo 发布顺序要避免 “reader 先读不存在字段” 或 “writer 写了旧 schema”。
10. 和业界 vocabulary 对照
面试或外部沟通可以这样说:
The frontend-facing API uses a pragmatic layered architecture: Hono routes validate input, shared service helpers enforce auth and store scope, then routes call parameterized SQL or repositories. The async processing Lambdas use hexagonal architecture:
handler.tsis the composition root,core/contains business rules and ports, andinfrastructure/implements adapters for AWS, Neon, S3, SQS, and AI providers.
11. 相关文档
- 系统架构总览 — repo 间职责、runtime、部署边界
- Backend 数据管道 — 通话 / SMS / Lead / 报告 pipeline
- Store-Level 隔离设计 — 多租户隔离和 repository/query scope
- Call Lifecycle Tracking — call timeline 写入职责
- Phone Normalization Strategy — phone/store assignment repository 逻辑
- AI Voice Agent 可行性 — 未来 voice agent 为什么应走 hexagonal