BFF Pattern An Example
BFF by example: one product, three payloads
An e-commerce app serves two very different clients — a desktop web frontend and a data-conscious mobile app — both backed by a single Product Service. The naive design lets each client call that service directly and receive the full Product aggregate. The Backend-for-Frontend (BFF) design instead places a thin, client-shaped service in front of the backend for each client type, so every client gets exactly the fields it renders and nothing more.
Below we build all three services in Java and Go, then measure the actual bytes on the wire to see precisely what the pattern buys — and where the savings come from.
1. Product Service — the fat backend
One microservice owns product data and returns the entire aggregate: full description, specs, image set, inventory, every review, Q&A, cross-sell IDs. It knows nothing about who is asking, so it hands back everything to everyone.
Java (Spring):
@RestController
@RequestMapping("/products")
public class ProductServiceController {
@Autowired
private ProductRepository productRepository;
// Returns the FULL product aggregate — every field, every review.
@GetMapping("/{productId}")
public Product getProductById(@PathVariable String productId) {
return productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
}Go (chi):
// GET /products/{id} — full product aggregate, unfiltered.
func (s *ProductServer) GetProduct(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
p, err := s.repo.FindByID(r.Context(), id)
if err != nil {
http.Error(w, "product not found", http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(p) // the fat object
}2. Web BFF — rich, but curated
The desktop page shows a lot: a trimmed marketing description, the top specs, a small gallery, a rating summary, the top handful of reviews, and cross-sell cards. The Web BFF calls the Product Service and projects the fat object down to just that.
Java (Spring):
@RestController
@RequestMapping("/web/products")
public class WebProductController {
@Autowired
private ProductServiceClient productServiceClient;
@GetMapping("/{productId}")
public WebProductDetails getProductDetails(@PathVariable String productId) {
Product product = productServiceClient.getProductById(productId);
return toWebView(product); // trims to what the web page renders
}
private WebProductDetails toWebView(Product p) {
return WebProductDetails.builder()
.id(p.getId())
.name(p.getName())
.price(p.getDisplayPrice())
.description(truncate(p.getDescription(), 600))
.specs(topN(p.getSpecs(), 10))
.gallery(webSized(p.getImages(), 5))
.rating(p.getRatingSummary())
.topReviews(p.getReviews().stream().limit(5).toList())
.related(p.getRelated().stream().limit(6).toList())
.build();
}
}Go (chi):
// GET /web/products/{id} — rich view for the desktop page.
func (b *WebBFF) GetProductDetails(w http.ResponseWriter, r *http.Request) {
p, err := b.client.GetProduct(r.Context(), chi.URLParam(r, "id"))
if err != nil { http.Error(w, "upstream error", http.StatusBadGateway); return }
view := WebProductDetails{
ID: p.ID,
Name: p.Name,
Price: p.DisplayPrice,
Description: truncate(p.Description, 600),
Specs: topN(p.Specs, 10),
Gallery: webSized(p.Images, 5),
Rating: p.RatingSummary,
TopReviews: firstN(p.Reviews, 5),
Related: firstN(p.Related, 6),
}
_ = json.NewEncoder(w).Encode(view)
}3. Mobile BFF — only the card
The mobile list/detail card shows a name, price, one thumbnail, an in-stock flag, and a star rating. Everything else is dropped. The Mobile BFF calls the same Product Service and returns a tiny shape.
Java (Spring):
@RestController
@RequestMapping("/mobile/products")
public class MobileProductController {
@Autowired
private ProductServiceClient productServiceClient;
@GetMapping("/{productId}")
public MobileProductCard getProductDetails(@PathVariable String productId) {
Product product = productServiceClient.getProductById(productId);
return toMobileCard(product); // only what the card shows
}
private MobileProductCard toMobileCard(Product p) {
return MobileProductCard.builder()
.id(p.getId())
.name(p.getName())
.price(p.getDisplayPrice())
.thumbnail(thumb(p.getImages()))
.inStock(p.getInventory().isAvailable())
.rating(p.getRatingSummary())
.build();
}
}Go (chi):
// GET /mobile/products/{id} — the bytes a phone actually needs.
func (b *MobileBFF) GetProductCard(w http.ResponseWriter, r *http.Request) {
p, err := b.client.GetProduct(r.Context(), chi.URLParam(r, "id"))
if err != nil { http.Error(w, "upstream error", http.StatusBadGateway); return }
card := MobileProductCard{
ID: p.ID,
Name: p.Name,
Price: p.DisplayPrice,
Thumb: thumb(p.Images),
InStock: p.Inventory.Available,
Rating: p.RatingSummary,
}
_ = json.NewEncoder(w).Encode(card)
}Why bother? A byte-level trace
Here is the same product, serialized as JSON three ways, field group by field group. A dash (—) means the field is omitted, i.e. 0 B. The overhead row is JSON structural characters plus header bytes on a warm HTTP/2 connection (HPACK-indexed; see the note under the table). Every column adds up to its own TOTAL — add any column yourself and check.
| Field group | Raw Product (B) | Web BFF (B) | Mobile BFF (B) |
|---|---|---|---|
| Identity (id, name, brand) | 210 | 120 | 60 |
| Pricing (price, tax, discounts) | 260 | 90 | 20 |
| Marketing description | 2,400 | 640 | — |
| Structured specs | 1,600 | 520 | — |
| Images | 1,800 | 420 | 70 |
| Inventory / availability | 1,100 | 40 | 12 |
| Seller / merchant profile | 700 | — | — |
| Rating summary (avg + count) | 60 | 60 | 26 |
| Reviews (50 bodies + metadata) | 21,000 | 1,500 | — |
| Q&A section | 2,200 | — | — |
| Related / cross-sell | 1,600 | 900 | — |
| SEO & analytics metadata | 900 | — | — |
| Protocol + JSON framing overhead | 230 | 110 | 12 |
| TOTAL ON THE WIRE | 34,060 (~34 KB) | 4,400 (~4.4 KB) | 200 (~0.20 KB) |
Reading the totals against each other — every ratio below comes straight from the TOTAL row:
- The mobile payload is ~170× smaller than the raw object (34,060 ÷ 200 ≈ 170).
- The mobile payload is ~22× smaller than the web payload (4,400 ÷ 200 = 22).
- Even the rich web payload is ~7.7× smaller than the raw object (34,060 ÷ 4,400 ≈ 7.7).
How the trace was built (and why reviews dominate)
Bytes are UTF-8 JSON, counted as listed field-group bytes + one overhead row = the column TOTAL. The overhead row counts JSON structural punctuation plus header bytes as seen on a warm HTTP/2 connection, where HPACK indexes repeated response headers down to a few bytes. On a cold HTTP/1.1 exchange add roughly 100 B of uncompressed headers to every column — which softens the mobile ratio from ~170× to ~114× (34,160 ÷ 300) but changes no conclusion.
The single biggest line is reviews: 21,000 B — 50 reviews at roughly 420 B each. That 420 B is a real review: a ~300-character body plus its rating, author, date, and JSON keys (~120 B of metadata). Reviews alone are ~62% of the raw payload, which is exactly why trimming them to the top 5 on web (and dropping them entirely on mobile) is where most of the savings come from.
This also shows the premise is conservative, not inflated: real catalogs carry reviews of 100–500 characters, and hundreds of them on popular items. Push review count or body length up and the raw object grows into the tens or hundreds of KB while the mobile card stays at ~200 B — the gap widens, it never shrinks. The BFF is doing the projection your client would otherwise pay for on every request.
Pitfalls to watch
- Duplicated logic drifts. Web and Mobile BFFs often share aggregation, auth, and mapping code. Copy-paste it and the two drift apart; pull the shared parts into a common library or module instead.
- The BFF becomes a mini-monolith. It is tempting to dump orchestration, business rules, and caching into the BFF. Keep it a presentation-shaping layer; domain logic belongs in the downstream services.
- One BFF per client type, not per endpoint. Spawning a BFF for every screen leads to service sprawl. The usual granularity is one BFF per client/experience team.
- Release coupling. A BFF is tied to its client's release cadence. That is a feature (each team owns its contract) but also an operational cost: more services to deploy, monitor, and be on-call for.
- Fan-out without a deadline. The example calls one backend, but real BFFs call several. A single slow downstream can hold the whole response hostage. Give every branch a timeout and a fallback so the screen renders even when a non-critical downstream lags.
- The alternative is a shared API gateway. When client needs are similar, a single gateway with response filtering — field selection, sparse fieldsets, or a GraphQL layer — can trim payloads without standing up N separate services. Reach for a BFF only when the shapes genuinely diverge.
When to use it — and when not to
Reach for a BFF when:
- You have multiple client types (web, iOS, Android, third-party) with genuinely divergent data and shape needs — as in the 34 KB vs 200 B split above.
- You want each client/experience team to own its own aggregation and contract, and iterate without coordinating a shared API.
- Constrained clients (mobile, low-bandwidth) are hurt by over-fetching or too many round-trips, and you want to collapse several backend calls into one client-shaped response.
- A client needs bespoke concerns — its own auth/session handling, response format, or protocol — that would pollute a shared API.
Prefer an alternative — do NOT add a BFF — when:
- All clients need essentially the same data. A single API gateway with field filtering, or a GraphQL layer that lets each client select fields, serves everyone from one place.
- You have only one client, or a small team. Every BFF is another service to build, deploy, secure, monitor, and be paged for; N BFFs = N of each.
- Your latency budget can't absorb an extra network hop between client and backend.
The core trade-off vs. the named alternative: an API gateway centralizes cross-cutting concerns (auth, rate-limiting, routing) in one place but forces every client onto a lowest-common-denominator contract; a BFF gives each client a tailored, minimal contract — the payoff measured above — at the cost of more services to build, deploy, and keep in sync. Choose the BFF when per-client divergence is real and worth that operational overhead; choose the gateway (or GraphQL) when it is not.
Source & further reading
- Phil Calçado, The Back-end for Front-end Pattern (BFF) — the original write-up of the pattern as it emerged at SoundCloud.
- Sam Newman, Backends For Frontends and Building Microservices (2nd ed., O'Reilly) — canonical treatment, including the "one BFF per client type, not per endpoint" guidance.
- Chris Richardson, microservices.io — API Gateway / BFF pattern, for the gateway-vs-BFF trade-off.
The Java Product Service / Web BFF / Mobile BFF example is expanded from the guide's original lesson; the byte-level trace, the Go implementations, the diagrams, and the selection/trade-off analysis are added for depth. All byte figures are illustrative but internally consistent (each column sums to its stated total).
🤖 Don't fully get this? Learn it with Claude
Stuck on BFF Pattern An Example? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **BFF Pattern An Example** (System Design) and want to truly understand it. Explain BFF Pattern An Example from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **BFF Pattern An Example** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **BFF Pattern An Example** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **BFF Pattern An Example** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.