Langfuse 后端路由与控制器架构指南:Next.js + tRPC 分层设计与最佳实践
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
Langfuse 是一个开源的 AI 工程平台,其 Web 端采用 Next.js + tRPC 技术栈构建。本文以.agents/skills/backend-dev-guidelines/references/routing-and-controllers.md为核心,系统讲解 Langfuse 内部的路由模式与关注点分离(Separation of Concerns)架构:从 tRPC 内部 UI 接口到公共 REST API 的完整调用链、Service 与 Repository 的分层职责,以及必须避免的三大反模式。读完本文,你将掌握如何在 Langfuse 仓库中正确新增一条路由/接口,并理解其"路由薄、服务厚、仓储纯"的分层设计哲学。
架构总览:从入口到数据库的四层结构
Langfuse 使用分层架构(Layered Architecture)组织后端代码,每一层职责单一、边界清晰。从源码目录结构可以明确看到这条完整链路:
┌─────────────────────────────────────────────────────────────┐ │ ENTRY POINTS │ │ ┌──────────────────────┐ ┌─────────────────────────┐ │ │ │ tRPC Procedures │ │ Public REST API Routes │ │ │ │ (Internal UI API) │ │ (SDK/External API) │ │ │ └──────────────────────┘ └─────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ SERVICE LAYER │ │ Business logic, orchestration, validation │ │ web/src/features/*/server/ or packages/shared/services/ │ └─────────────────────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ REPOSITORY LAYER │ │ Complex queries, data transformation │ │ packages/shared/src/server/repositories/ │ └─────────────────────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ DATABASE LAYER │ │ PostgreSQL (Prisma) + ClickHouse (Direct Client) │ └─────────────────────────────────────────────────────────────┘两层入口(tRPC Procedures 与 Public REST API Routes)共享同一套 Service / Repository / 数据库底座。这正是 Langfuse 能够在同一套业务逻辑之上同时支撑 Web 控制台(tRPC)与 SDK 外部集成(REST API)的原因。
各层职责清单
入口层(Routes / Procedures)应当:
- ✅ 定义路由与 procedure 签名
- ✅ 通过 middleware 处理认证 / 授权
- ✅ 使用 Zod schema 校验输入
- ✅ 委托给 Service 执行
- ✅ 返回响应
入口层绝不应当:
- ❌ 包含业务逻辑
- ❌ 直接访问数据库
- ❌ 执行复杂的数据转换
- ❌ 直接调用 Repository(必须经由 Service)
Service 层:
- ✅ 承载业务逻辑
- ✅ 编排多个操作(orchestration)
- ✅ 调用 Repository 或 Prisma / ClickHouse
- ✅ 处理复杂工作流
- ❌ 不应感知 HTTP、tRPC 或 request/response 对象
Repository 层:
- ✅ 复杂数据库查询
- ✅ 数据转换(DB → 领域模型)
- ✅ ClickHouse 查询构建器
- ✅ 可复用的查询逻辑
- ❌ 不应包含业务逻辑
这套职责划分在仓库中体现得极为一致:路由文件位于web/src/server/api/routers/与web/src/pages/api/public/,业务逻辑集中在web/src/features/*/server/与packages/shared/src/server/services/,查询逻辑则下沉到 packages/shared/src/server/repositories/,其中 README.md 还记录了跨表关联查询的时间窗口约束(如基于 Score 时间戳回溯 1 小时 cutoff)等数据关联保证。
tRPC Routers:内部 UI 的类型安全接口
tRPC 为前端提供类型安全的远程调用,Langfuse 的内部 UI 全部通过 tRPC 与后端通信。所有内部 router 位于web/src/server/api/routers/。
Router 结构示例:scores.ts
以 web/src/server/api/routers/scores.ts(共 1141 行)为例,一个典型的 router 通过createTRPCRouter聚合多个 procedure:
import { z } from "zod/v4"; import { createTRPCRouter, protectedProjectProcedure, } from "@/src/server/api/trpc"; import { paginationZod, singleFilter, orderBy } from "@langfuse/shared"; import { getScoresUiTable, getScoresUiCount, upsertScore, } from "@langfuse/shared/src/server"; const ScoreAllOptions = z.object({ projectId: z.string(), filter: z.array(singleFilter), orderBy: orderBy, ...paginationZod, }); export const scoresRouter = createTRPCRouter({ /** * Get all scores for a project */ all: protectedProjectProcedure .input(ScoreAllOptions) .query(async ({ input, ctx }) => { // Delegate to repository for data fetching const clickhouseScoreData = await getScoresUiTable({ projectId: input.projectId, filter: input.filter ?? [], orderBy: input.orderBy, limit: input.limit, offset: input.page * input.limit, }); // Delegate to Prisma for related data const [jobExecutions, users] = await Promise.all([ ctx.prisma.jobExecution.findMany({ where: { jobOutputScoreId: { in: clickhouseScoreData.map((score) => score.id), }, }, }), ctx.prisma.user.findMany({ where: { id: { in: clickhouseScoreData .map((s) => s.authorUserId) .filter((id): id is string => id !== null), }, }, }), ]); // Transform and combine data return clickhouseScoreData.map((score) => ({ ...score, jobConfigurationId: jobExecutions.find((j) => j.jobOutputScoreId === score.id) ?.jobConfigurationId ?? null, authorUserImage: users.find((u) => u.id === score.authorUserId)?.image ?? null, authorUserName: users.find((u) => u.id === score.authorUserId)?.name ?? null, })); }), /** * Create or update score */ createAnnotationScore: protectedProjectProcedure .input(CreateAnnotationScoreData) .mutation(async ({ input, ctx }) => { // Validation validateConfigAgainstBody(input); // Delegate to repository await upsertScore({ id: input.id ?? randomUUID(), traceId: input.traceId, projectId: input.projectId, name: input.name, value: input.value, source: ScoreSource.ANNOTATION, authorUserId: ctx.session.user.id, comment: input.comment, }); // Audit log await auditLog({ session: ctx.session, resourceType: "score", resourceId: input.id, action: "create", }); return { success: true }; }), });从这个例子可以总结出编写 tRPC router 的关键要点:
- 选择恰当的 procedure 类型(
protectedProjectProcedure、authenticatedProcedure等) - 用 Zod 定义输入 schema(
.input()) - 读操作使用
.query(),写操作使用.mutation() - 数据访问一律委托给 Service / Repository
- 保持 procedure 精简——不含业务逻辑
- 全程类型安全(TypeScript 从 Zod schema 自动推断类型)
注册 Router:root.ts
所有 router 必须在 web/src/server/api/root.ts 中手动注册,该文件定义了appRouter这一总入口:
import { createTRPCRouter } from "@/src/server/api/trpc"; import { scoresRouter } from "./routers/scores"; import { tracesRouter } from "./routers/traces"; import { dashboardRouter } from "@/src/features/dashboard/server/dashboard-router"; export const appRouter = createTRPCRouter({ scores: scoresRouter, traces: tracesRouter, dashboard: dashboardRouter, // ... other routers }); export type AppRouter = typeof appRouter;从源码结构看,web/src/server/api/root.ts 目前注册了 50+ 个 router,覆盖 annotation queues、batch export、traces、sessions、generations、evals、monitors、slack、搜索、标注队列等几乎所有功能域,其中大量 feature 级 router 由各功能目录自行导出(如web/src/features/dashboard/server/dashboard-router、web/src/features/evals/server/router),体现了"路由按功能域就近组织、统一在 root 汇总"的模式。
tRPC 基础设施:trpc.ts
所有 procedure 的基座定义在 web/src/server/api/trpc.ts(799 行),其核心内容包括:
- Context 构建:
createTRPCContext通过getServerAuthSession获取 next-auth session,并注入prisma实例,供所有 procedure 通过ctx.prisma/ctx.session使用。 - 初始化与错误格式化:使用
superjson作为 transformer,errorFormatter会将 ZodError 扁平化(z.flattenError)返回给前端,并针对ClickHouseResourceError隐藏堆栈。 - 全局中间件:
withErrorHandling统一拦截异常,将BaseError映射为对应的 tRPC error code,5xx 错误不暴露内部堆栈;withOtelInstrumentation通过 OpenTelemetry 注入 baggage(包含 userId、projectId、clickhouse surface/route 标记),实现全链路可观测。 - procedure 层级:从
publicProcedure(未认证)到enforceUserIsAuthed中间件包装出的认证 procedure,再到项目级保护 procedure,逐级增强安全约束。
前端调用方式
由于 tRPC 的类型推断,前端可以获得与后端完全一致的类型安全体验:
// Type-safe client call const { data, isLoading } = api.scores.all.useQuery({ projectId: "proj_123", page: 0, limit: 50, filter: [], orderBy: null, });任何对后端 schema 的修改都会在前端编译期暴露,这是 tRPC 相比手写 REST client 的核心优势。
Public REST API Routes:面向 SDK 与外部集成的文件式路由
与内部 tRPC 不同,公共 API 面向 Python/JS SDK 与外部集成,采用Next.js 文件式路由,目录位置为web/src/pages/api/public/。
文件式路由约定
Next.js 用文件系统天然表达 URL 结构:
web/src/pages/api/public/ ├── scores/ │ ├── index.ts → GET/POST /api/public/scores │ └── [scoreId].ts → GET/PATCH/DELETE /api/public/scores/:scoreId ├── traces/ │ ├── index.ts → GET /api/public/traces │ └── [traceId].ts → GET /api/public/traces/:traceId └── datasets/ └── [name]/ ├── index.ts → GET/POST /api/public/datasets/:name └── items/ └── index.ts → GET /api/public/datasets/:name/items动态路由规则:
[param].ts→ 单个动态段(如/api/public/scores/[scoreId].ts)[...param].ts→ 通配路由(catch-all,如/api/public/[...path].ts)
从仓库现状看,web/src/pages/api/public/下已有 scores、traces、datasets、dataset-items、observations、sessions、generations、prompts、ingestion、health、media、models、metrics、scim、organizations 以及 v2/v3 版本目录等数十个端点,并且/api/public/v2/下还有 14 个文件,体现了公共 API 的版本化演进。
REST API 标准模式:scores/index.ts
以 web/src/pages/api/public/scores/index.ts 为模板,一个标准端点由withMiddlewares包裹、按 HTTP 方法分派处理函数:
import { v4 } from "uuid"; import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute"; import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares"; import { GetScoresQueryV1, GetScoresResponseV1, PostScoresBodyV1, PostScoresResponseV1, } from "@langfuse/shared"; import { eventTypes, processEventBatch } from "@langfuse/shared/src/server"; import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service"; export default withMiddlewares({ // POST /api/public/scores POST: createAuthedProjectAPIRoute({ name: "Create Score", bodySchema: PostScoresBodyV1, responseSchema: PostScoresResponseV1, fn: async ({ body, auth, res }) => { const event = { id: v4(), type: eventTypes.SCORE_CREATE, timestamp: new Date().toISOString(), body, }; if (!event.body.id) { event.body.id = v4(); } const result = await processEventBatch([event], auth); if (result.errors.length > 0) { const error = result.errors[0]; res.status(error.status).json({ message: error.error ?? error.message, }); return { id: "" }; } return { id: event.body.id }; }, }), // GET /api/public/scores GET: createAuthedProjectAPIRoute({ name: "Get Scores", querySchema: GetScoresQueryV1, responseSchema: GetScoresResponseV1, fn: async ({ query, auth }) => { const scoresApiService = new ScoresApiService("v1"); const [items, count] = await Promise.all([ scoresApiService.generateScoresForPublicApi({ projectId: auth.scope.projectId, page: query.page, limit: query.limit, userId: query.userId, name: query.name, }), scoresApiService.getScoresCountForPublicApi({ projectId: auth.scope.projectId, userId: query.userId, name: query.name, }), ]); return { data: items, meta: { page: query.page, limit: query.limit, totalItems: count, totalPages: Math.ceil(count / query.limit), }, }; }, }), });关键要点:
- 所有公共 API 路由必须使用
withMiddlewares(提供 CORS、统一错误处理、OpenTelemetry 埋点) - 认证端点必须使用
createAuthedProjectAPIRoute(处理鉴权、限流、参数校验) - 每个 HTTP 方法定义独立的 handler
- 输入 / 输出用 Zod schema 校验
- 业务逻辑委托给 Service
createAuthedProjectAPIRoute 的能力矩阵
从 web/src/features/public-api/server/createAuthedProjectAPIRoute.ts(473 行)的配置项可以看出该高阶函数的能力边界:
| 配置项 | 作用 |
|---|---|
name | 路由名称,用于可观测性 |
action | 策略核(policy core)授权的项目操作,必填,确保路由不可能"无授权上线" |
querySchema/bodySchema/responseSchema | Zod 请求/响应校验 |
successStatusCode | 自定义成功状态码 |
rateLimitResource | 限流资源(默认public-api),结合RateLimitService使用 |
rateLimitUpgradePath | 限流升级路径提示 |
isAdminApiKeyAuthAllowed | 自托管实例是否允许ADMIN_API_KEY认证(默认false,且仅当未设置NEXT_PUBLIC_LANGFUSE_CLOUD_REGION时可用) |
allowedAccessLevels | 接受的访问级别,如["project", "scores"]表示允许 public key 的 Bearer 认证 |
allowInAppAgentKey | 是否允许 in-app agent key 调用(仅建议对非变更型 GET 路由开启) |
errorContract | 结构化错误契约(如structuredPublicApiErrorContract) |
withMiddlewares 的统一错误处理
web/src/features/public-api/server/withMiddlewares.ts 是公共 API 的"最后防线",它:
- 先执行 CORS 中间件(
runMiddleware(req, res, cors)) - 对未声明的 HTTP 方法统一抛出
MethodNotAllowedError(默认 handler) - 对
ClickHouseResourceError返回 422,并附带资源使用建议(如提示迁移到 v2 Observations/Metrics API) - 对
BaseError依据httpCode返回结构化错误,5xx 同时上报traceException - 对 Prisma 异常与 ZodError 分别映射为 500 / 400
无需鉴权的简单路由
对于健康检查这类无需认证的路由,只需withMiddlewares即可:
// web/src/pages/api/public/health.ts import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares"; export default withMiddlewares({ GET: async (req, res) => { res.status(200).json({ status: "ok" }); }, });版本化 API 类型的位置约定
当某个功能域存在多版本公共 API 类型时,必须放置在packages/shared/下对应功能的interfaces/api/目录中,每个版本一个子目录。scores 功能是标准范例(packages/shared/src/features/scores/interfaces/api/),其中GetScoresQueryV1/GetScoresResponseV1等符号即源于此,该目录下实际包含v1/、v2/、v3/三个版本,每个版本都由schemas.ts(Zod 请求/响应形状)、endpoints.ts(组合后的请求/响应类型)、validation.ts(跨字段校验辅助)组成。
明确禁止的做法:
- 创建扁平文件(如
<domain>-api-v2.ts) - 将版本化类型放到
web/src/features/public-api/types/
packages/shared/src/features/<domain>/interfaces/api/ ├── v1/ │ ├── schemas.ts # Zod schemas for request/response shapes │ ├── endpoints.ts # Composed request/response types │ └── validation.ts # Cross-field validation helpers ├── v2/ │ └── ... └── vN/ └── ...Fern API Definitions:与 OpenAPI 规范的同步
Langfuse 通过 Fern 中对应的 Fern 定义(该目录下已有 scores.yml、traces.yml、ingestion.yml、observations.yml、metrics.yml、models.yml 等 30+ 个定义文件)。
工作流:
- 编辑 Fern 源文件(
fern/apis/server/definition/*.yml) - 运行
pnpm run openapi:export - 提交
web/public/generated/**生成产物 - 改动
fern/**的 PR 会通过pnpm run openapi:check在 CI 中校验服务端规范是否漂移
Zod 到 Fern 类型映射表
| Zod Type | Fern Type | 示例 |
|---|---|---|
.nullish() | optional<nullable<T>> | z.string().nullish()→optional<nullable<string>> |
.nullable() | nullable<T> | z.string().nullable()→nullable<string> |
.optional() | optional<T> | z.string().optional()→optional<string> |
| 始终存在 | T | z.string()→string |
每个 Fern 类型顶部应添加引用 TypeScript 源文件的注释,保证双向可追踪:
# Source: web/src/features/public-api/types/traces.ts - APITrace Trace: properties: id: string name: type: nullable<string>Service Layer:业务逻辑的归属地
Service 层是业务逻辑与操作编排的载体,位置约定为web/src/features/*/server/或packages/shared/src/server/services/。tRPC procedure 与 API route 只负责"接线",真正干活的是 Service。
Service 模式示例:ScoresApiService
以 web/src/features/public-api/server/scores-api-service.ts 为例,Service 通过构造函数注入 API 版本,实现版本感知的行为差异:
import { _handleGenerateScoresForPublicApi, _handleGetScoresCountForPublicApi, type ScoreQueryType, } from "@/src/features/public-api/server/scores"; import { _handleGetScoreById } from "@langfuse/shared/src/server"; export class ScoresApiService { constructor(private readonly apiVersion: "v1" | "v2") {} /** * Get a specific score by ID */ async getScoreById({ projectId, scoreId, source, }: { projectId: string; scoreId: string; source?: ScoreSourceType; }) { return _handleGetScoreById({ projectId, scoreId, source, scoreScope: this.apiVersion === "v1" ? "traces_only" : "all", preferredClickhouseService: "ReadOnly", }); } /** * Get list of scores with version-aware filtering */ async generateScoresForPublicApi(props: ScoreQueryType) { return _handleGenerateScoresForPublicApi({ props, scoreScope: this.apiVersion === "v1" ? "traces_only" : "all", }); } /** * Get count of scores with version-aware filtering */ async getScoresCountForPublicApi(props: ScoreQueryType) { return _handleGetScoresCountForPublicApi({ props, scoreScope: this.apiVersion === "v1" ? "traces_only" : "all", }); } }关键要点:
- Service 承载业务逻辑,而非路由逻辑
- Service不导入tRPC 或 Next.js 类型(协议无关)
- Service 可以直接调用 Repository、Prisma、ClickHouse
- Service 负责编排多个操作
- Service 可同时被 tRPC 与公共 API 复用
Service 的放置位置
功能专属 Service(与具体功能强相关):
web/src/features/ ├── datasets/ │ └── server/ │ └── dataset-service.ts ├── evals/ │ └── server/ │ └── eval-service.ts └── public-api/ └── server/ └── scores-api-service.ts共享 Service(跨功能复用,位于 packages/shared/src/server/services/):
packages/shared/src/server/services/ ├── SlackService.ts ├── DashboardService/ ├── StorageService.ts └── DefaultEvaluationModelService/从仓库现状看,共享服务目录还包含BufferedStreamUploader.ts、InMemoryFilterService.ts、traces-ui-table-service.ts、TableViewService/、PromptService/等,进一步印证了"通用逻辑上提到 shared 包、功能逻辑留在 feature 内"的布局原则。
Repository Layer:数据访问的封装
Repository 层位于 packages/shared/src/server/repositories/,负责复杂数据库查询、数据转换(DB → 领域模型)并提供可复用的查询逻辑。
目录结构
packages/shared/src/server/repositories/ ├── traces.ts # Trace queries (ClickHouse) ├── observations.ts # Observation queries (ClickHouse) ├── scores.ts # Score queries (ClickHouse) ├── clickhouse.ts # Core ClickHouse helpers └── definitions.ts # Type definitions仓库中该目录实际包含 40+ 个文件,除 traces/observations/scores 外,还有datasets.ts、dataset-items.ts、events.ts、experiments.ts、dashboards.ts、daily-metrics.ts、comments.ts、automation-repository.ts以及配套的*_converters.ts转换器与*.test.ts测试文件。
Repository 模式示例:traces.ts
import { queryClickhouse, upsertClickhouse } from "./clickhouse"; import { TraceRecordReadType } from "./definitions"; import { convertClickhouseToDomain } from "./traces_converters"; /** * Get traces by IDs */ export const getTracesByIds = async ( projectId: string, traceIds: string[], ): Promise<TraceRecordReadType[]> => { const rows = await queryClickhouse<TraceRecordReadType>({ query: ` SELECT * FROM traces WHERE project_id = {projectId: String} AND id IN ({traceIds: Array(String)}) ORDER BY event_ts DESC LIMIT 1 BY id, project_id `, params: { projectId, traceIds }, tags: { feature: "tracing", type: "trace" }, }); return rows.map(convertClickhouseToDomain); }; /** * Upsert trace to ClickHouse */ export const upsertTrace = async ( trace: TraceRecordInsertType, ): Promise<void> => { await upsertClickhouse({ table: "traces", records: [trace], eventBodyMapper: (body) => ({ id: body.id, name: body.name, user_id: body.user_id, // ... map fields }), tags: { feature: "ingestion", type: "trace" }, }); };关键要点:
- 读查询使用
queryClickhouse - 写入使用
upsertClickhouse - DDL(ALTER TABLE 等)使用
commandClickhouse - 包含数据转换器(
convertClickhouseToDomain) - 添加 OpenTelemetry tags 保证可观测性
- Repository不包含业务逻辑
何时使用 Repository
✅应当使用 Repository:
- 含 CTE、JOIN、聚合的复杂 ClickHouse 查询
- 在多处复用的查询(DRY 原则)
- 从 DB 类型到领域模型的数据转换
- 流式返回大结果集
❌可以直接使用 Prisma / ClickHouse:
- 简单 CRUD 操作
- 一次性查询
- 原型开发(后续可重构为 Repository)
关注点分离的正确示范
把三个层次组合起来,就得到了 Langfuse 后端代码的标准形态:
tRPC Procedure(入口,保持精简):
// web/src/server/api/routers/scores.ts export const scoresRouter = createTRPCRouter({ all: protectedProjectProcedure .input(ScoreFilterOptions) .query(async ({ input }) => { // ✅ Thin procedure - delegates to repository return await getScoresUiTable({ projectId: input.projectId, filter: input.filter, orderBy: input.orderBy, }); }), create: protectedProjectProcedure .input(CreateScoreInput) .mutation(async ({ input, ctx }) => { // ✅ Delegates to service for orchestration return await createScoreWithValidation({ scoreData: input, userId: ctx.session.user.id, projectId: ctx.session.projectId, }); }), });Service(业务逻辑与编排):
// web/src/features/scores/server/score-service.ts export async function createScoreWithValidation({ scoreData, userId, projectId, }: { scoreData: CreateScoreInput; userId: string; projectId: string; }) { // ✅ Business logic: validation const config = await prisma.scoreConfig.findUnique({ where: { id: scoreData.configId }, }); if (!config) { throw new LangfuseNotFoundError("Score config not found"); } validateConfigAgainstBody(config, scoreData); // ✅ Business logic: orchestration const scoreId = randomUUID(); await Promise.all([ // Create score in ClickHouse upsertScore({ id: scoreId, projectId, traceId: scoreData.traceId, name: scoreData.name, value: scoreData.value, authorUserId: userId, }), // Audit log in PostgreSQL auditLog({ userId, resourceType: "score", resourceId: scoreId, action: "create", }), ]); return { id: scoreId }; }Repository(纯数据访问):
// packages/shared/src/server/repositories/scores.ts export const upsertScore = async (score: ScoreInsertType): Promise<void> => { // ✅ Pure data access - no business logic await upsertClickhouse({ table: "scores", records: [score], eventBodyMapper: (body) => ({ id: body.id, trace_id: body.traceId, name: body.name, value: body.value, author_user_id: body.authorUserId, }), tags: { feature: "scoring" }, }); };为什么这套分层有效
- tRPC Procedure:足够薄,只负责委托
- Service:承载全部业务逻辑(校验、编排)
- Repository:纯数据访问,可复用
- Service 协议无关:可被 tRPC、公共 API 甚至 worker 进程共同调用
- 边界清晰:易于测试、维护与扩展
一个直观的佐证是:写入端的upsertScore同时被 tRPC router(创建标注分数)与 ingestion 事件处理链路使用,而读取端的getScoresUiTable被 tRPC 与公共 API 共用——这正是"一次实现、多入口复用"的分层红利。
三大反模式(Anti-Patterns)
理解了正确范式之后,识别并规避反模式同样重要。以下是文档明确列出的三类典型错误。
反模式 1:把业务逻辑写进路由
错误示范:
// ❌ BAD: Business logic in tRPC procedure export const scoresRouter = createTRPCRouter({ create: protectedProjectProcedure .input(CreateScoreInput) .mutation(async ({ input, ctx }) => { // ❌ Validation logic in route const config = await ctx.prisma.scoreConfig.findUnique({ where: { id: input.configId }, }); if (!config) { throw new TRPCError({ code: "NOT_FOUND" }); } if (config.dataType === "NUMERIC" && typeof input.value !== "number") { throw new TRPCError({ code: "BAD_REQUEST" }); } // ❌ Direct database access await ctx.prisma.score.create({ data: { id: randomUUID(), projectId: ctx.session.projectId, traceId: input.traceId, name: input.name, value: input.value, }, }); // ❌ More business logic await auditLog({ ... }); return { success: true }; }), });为什么糟糕:
- 业务逻辑被 tRPC 绑定,公共 API 无法复用
- 难以测试(需要 mock tRPC context)
- 没有关注点分离
- 难以维护
正确示范:
// ✅ GOOD: Thin procedure, delegates to service export const scoresRouter = createTRPCRouter({ create: protectedProjectProcedure .input(CreateScoreInput) .mutation(async ({ input, ctx }) => { return await createScoreWithValidation({ scoreData: input, userId: ctx.session.user.id, projectId: ctx.session.projectId, }); }), });反模式 2:在路由中直接访问数据库
错误示范:
// ❌ BAD: Direct database access in route export default withMiddlewares({ GET: createAuthedProjectAPIRoute({ name: "Get Scores", fn: async ({ auth }) => { // ❌ Direct ClickHouse query in route const scores = await queryClickhouse({ query: "SELECT * FROM scores WHERE project_id = {projectId: String}", params: { projectId: auth.scope.projectId }, }); return { data: scores }; }, }), });正确示范:
// ✅ GOOD: Delegates to service or repository export default withMiddlewares({ GET: createAuthedProjectAPIRoute({ name: "Get Scores", fn: async ({ auth, query }) => { const scoresService = new ScoresApiService("v1"); return await scoresService.generateScoresForPublicApi({ projectId: auth.scope.projectId, page: query.page, limit: query.limit, }); }, }), });反模式 3:把业务逻辑写进 Repository
错误示范:
// ❌ BAD: Business logic in repository export const upsertScore = async ( score: ScoreInsertType ): Promise<void> => { // ❌ Validation in repository if (!score.name) { throw new Error("Score name is required"); } // ❌ Authorization check in repository const project = await prisma.project.findUnique({ where: { id: score.projectId }, }); if (!project) { throw new Error("Project not found"); } // ❌ Side effects in repository await auditLog({ ... }); await upsertClickhouse({ ... }); };正确示范:
// ✅ GOOD: Pure data access, no business logic export const upsertScore = async (score: ScoreInsertType): Promise<void> => { await upsertClickhouse({ table: "scores", records: [score], eventBodyMapper: (body) => ({ id: body.id, trace_id: body.traceId, name: body.name, value: body.value, }), tags: { feature: "scoring" }, }); };实践总结:新增一条接口的完整清单
综合本文内容,在 Langfuse 中新增一条后端接口应遵循以下步骤:
- 判断归属:内部 UI 功能 → tRPC router;SDK/外部集成 →
web/src/pages/api/public/下的 Next.js 文件路由。 - 定义类型:若为公共 API,将版本化 Zod 类型放入
packages/shared/src/features/<domain>/interfaces/api/<version>/。 - 编写 Service:业务逻辑放入
web/src/features/<domain>/server/或共享的packages/shared/src/server/services/。 - 编写 Repository:复杂查询放入
packages/shared/src/server/repositories/,保持纯数据访问。 - 组装入口:tRPC 用
createTRPCRouter+ procedure;公共 API 用withMiddlewares+createAuthedProjectAPIRoute,配置好action与 schema。 - 同步 Fern 定义:修改公共类型后更新 fern/apis/server/definition/ 并运行
pnpm run openapi:export,确保 CI 的pnpm run openapi:check通过。
延伸阅读
本文相关的基础设施与实践规范,可继续阅读仓库中的以下文件:
- .agents/skills/backend-dev-guidelines/SKILL.md — 后端开发总纲
- .agents/skills/backend-dev-guidelines/references/architecture-overview.md — 系统架构全景
- .agents/skills/backend-dev-guidelines/references/middleware-guide.md — 中间件模式
- .agents/skills/backend-dev-guidelines/references/database-patterns.md — 数据访问模式
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考