BFF 层的设计与边界
BFF(Backend For Frontend)是为某一类前端定制的聚合层。它的价值是编排,不是业务计算。
一、聚合与裁剪
BFF(Backend For Frontend)最朴素的价值是减少往返:一个商品详情页要商品、库存、价格、促销、推荐五份数据,让客户端发五次请求,弱网下就是五次往返加五次失败可能;由 BFF 聚合成一次返回,客户端只处理一个结果。其次才是裁剪字段(移动端不需要 PC 端的二十个字段)、协议转换、以及隐藏内部服务的拆分细节。
当端越来越多——Web、小程序、App——每个端要的字段、聚合方式、协议都不完全相同。如果没有 BFF,常见做法是后端开一个「大而全」的接口,所有端各取所需再本地裁剪,结果字段越来越臃肿、废弃字段不敢删。BFF 让每个端拥有自己的适配层:Web 端要大字段,移动端要精简,两套 BFF 各自服务,互不影响。
这里要把「转发」和「聚合」区分开。只把请求原样转给下游服务的 BFF 没有意义,客户端直接调下游也一样;真正产生价值的是「取数 + 裁剪 + 聚合 + 降级」这四类编排动作收在服务端完成。转发没有价值,聚合才有。
二、什么不该放进 BFF
BFF 最容易被写歪的地方,是慢慢变成第二个后端业务层。守边界的原则是三条「不做」:
- 不做业务规则:税费、计价、库存扣减这类领域规则属于领域服务。放 BFF 会因为多端各自实现而出现不一致,且无法对账——同一笔订单在 Web 端和小程序端算出不同价格,是没人能接受的。
- 不做持久化:BFF 应当无状态,可随时重启、扩容、回滚。一旦它自己写库,就变成有状态服务,发布和扩缩容都要先考虑数据一致性。
- 不做跨端通用能力:登录、鉴权、消息推送、限流这类能力应该落在平台层或网关,而不是复制进每一个端的 BFF。
BFF 该做的是:协议转换(把内部 RPC / GraphQL 转成前端友好的 REST)、字段裁剪、鉴权透传、以及降级。透传这一项要说得准确——BFF 负责把前端会话换成下游调用所需的凭证,但凭证本身留在服务端,不要把第三方 token 下发到浏览器。截至 2026 年 10 月,业界把「后端保留第三方凭证、前端只持 HttpOnly 会话 Cookie」的模式称为 True BFF,它和单纯把 access token 转发给前端的写法在安全性上差了一截。
一句话收口:规则放 BFF 会导致多端实现不一致,领域规则应留在领域服务。
三、关键路径与降级
这是 BFF 设计里最实用的一条:依赖要分强弱。库存、价格是强依赖——拿不到就应该报错,不能让用户看到错误的价格下单;推荐、评价数是弱依赖——拿不到就返回空数组或默认值,页面其余部分照常可用。
const [item, stock] = await Promise.all([getItem(id), getStock(id)]) // 强依赖:失败即抛
const [recs, reviews] = await Promise.all([
getRecommend(id).catch(() => []), // 弱依赖:失败降级
getReviewCount(id).catch(() => null),
])
关键是把强弱写成配置而不是散落在代码里。一个下游接口上线或下线时,你只想改一份声明(这个源是强还是弱、失败返回什么默认值),而不是去翻主流程里的每一个 catch。做法上可以给每个数据源标注依赖等级,编排层统一处理:
// 多个弱依赖并行取数,单个失败不影响整体返回
const results = await Promise.allSettled([getPromotion(id), getReviews(id), getShipping(id)])
const bonus = results
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value)
// 失败的记为空,主流程照常返回,强依赖不在这里
这样「加一个数据源不用改主流程」才成立——新接口只是配置表里多一行,编排代码不动。
BFF 该做的那部分长这样——编排、裁剪、并行:
app.get('/api/product/:id', async (req, res) => {
const [item, stock] = await Promise.all([
getItem(req.params.id),
getStock(req.params.id),
])
// 只返回这一屏要用的字段,不把下游原始结构透出去
res.json({
id: item.id,
title: item.title,
price: item.price,
inStock: stock.available > 0,
})
})
四、归属与协作方式
最合理的归属是前端团队:需求变化最快的是前端,页面改版、字段增减、聚合逻辑调整,前端最清楚要什么。代价是前端要一并承担服务端的可用性职责——监控、限流、发布、告警,不能再一句「后端的问题」推走。
完全按端划分(Web 一个、小程序一个、App 一个)迭代最快,但会有重复造轮子:超时策略、降级逻辑、日志格式每个 BFF 写一遍,迟早分叉。取舍是「按端归属 + 公共规范」——各端 BFF 自己维护自己定制的聚合,但把通用的超时、降级、错误码、链路追踪封装成共享库,新 BFF 直接复用。完全按端划分会重复造轮子,没有公共规范的按端划分只是把重复藏得更深。
会话 Cookie 会被浏览器自动携带,这一点决定了 BFF 天然暴露在 CSRF 面前。防护要做三层:Cookie 上 SameSite=Lax(跨站发起的 POST 不会带上它);对写操作校验 Origin / Referer;需要更强保证时再加一个双重提交的 CSRF token。HttpOnly 解决的是「JS 读不到」,它挡不住「浏览器替我把它发出去」。
五、多一次跳转的代价
BFF 帮前端省了往返,但自己要付的账不能忘:
- 并行取数:下游之间无依赖就并行,用
Promise.all/allSettled而不是串起来等。 - 超时控制:每个下游都设上限,宁可快速失败也别被一个慢接口拖垮整页。
- 并发上限:限制同时打到下游的请求数,避免 BFF 把压力放大成下游的雪崩。
- 熔断:下游持续失败时快速失败,给它恢复时间。
async function withTimeout(promise, ms) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), ms)
try {
return await promise(ctrl.signal)
} finally {
clearTimeout(timer)
}
}
// 下游抖动时快速失败,而不是卡住整页
const stock = await withTimeout(() => getStock(id), 800).catch(() => null)
最容易被忽视的一点:BFF 本身会成为新的故障点与瓶颈。它挂了,所有依赖它的端一起挂;它慢了,所有端一起慢。所以加了 BFF 也要给它自己设防——独立的超时、熔断、容量评估和告警,不能假设「有了中间层就稳了」。
一旦开始写规则,BFF 就从编排层变成了第二个业务服务:
// 错:促销规则写进 BFF
if (amount > 199) amount -= 30 // 满减
if (user.level === 'gold') amount *= 0.95 // 会员折扣
// 后果:规则一改就要发版;H5 与小程序各自调一次,两边口径迟早分叉
// 正确位置:由下游的营销服务返回最终金额,BFF 只负责把它拼进响应
六、把业务逻辑塞进 BFF
BFF 不是把后端接口转发一下——转发没有价值,价值在聚合、裁剪与降级。
业务逻辑放 BFF 也不见得更灵活:规则放这儿会导致多端实现不一致,领域规则应该留在领域服务。
加了 BFF 更不等于不会有性能问题——它自己会成为新的故障点与瓶颈,超时、并发控制、熔断一样都不能少。
BFF 的价值是让前端少发几次请求、少处理几次失败——但别把它写成第二个后端业务层。
BFF 模式的官方描述见 Microsoft:Backends for Frontends,它对「按前端拆分」的理由讲得很透。