Skip to main content

BFF 层的设计与边界

BFF(Backend For Frontend)是为某一类前端定制的聚合层。它的价值是编排,不是业务计算。

一、聚合与裁剪​

BFF(Backend For Frontend)最朴素的价值是减少往返:一个商品详情页要商品、库存、价格、促销、推荐五份数据,让客户端发五次请求,弱网下就是五次往返加五次失败可能;由 BFF 聚合成一次返回,客户端只处理一个结果。其次才是裁剪字段(移动端不需要 PC 端的二十个字段)、协议转换、以及隐藏内部服务的拆分细节。

当端越来越多——Web、小程序、App——每个端要的字段、聚合方式、协议都不完全相同。如果没有 BFF,常见做法是后端开一个「大而全」的接口,所有端各取所需再本地裁剪,结果字段越来越臃肿、废弃字段不敢删。BFF 让每个端拥有自己的适配层:Web 端要大字段,移动端要精简,两套 BFF 各自服务,互不影响。

这里要把「转发」和「聚合」区分开。只把请求原样转给下游服务的 BFF 没有意义,客户端直接调下游也一样;真正产生价值的是「取数 + 裁剪 + 聚合 + 降级」这四类编排动作收在服务端完成。转发没有价值,聚合才有。

浏览器HttpOnly 会话 CookieBFF聚合多个下游接口按端裁剪字段鉴权与 token 持有不含业务规则商品服务交易服务用户服务红线: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,它对「按前端拆分」的理由讲得很透。