bff-recipe
A Backend For Frontend aggregation library
API producers build endpoints the way they want. Consumers compose them the way they need. One annotation bridges the gap. No tug of war between team priorities. No custom aggregation endpoints. No wasted round trips.
Add one annotation to your Spring controllers you wish to aggregate. Send one request from your client and get your aggregated data back in one response.
Client-side team needs
- One request per page
- All the data the UI needs in one request
- Fast, parallel data loading
- Ship without waiting on backend
Service team needs
- Clean, single-purpose APIs
- Independent deployability
- No custom endpoints for each client
- Ownership of service boundaries
You get a Spring Boot starter, a TypeScript types package, and built-in discovery & validation endpoints.
How it works
Two things happen. Backend adds one annotation per API. Frontend sends one JSON request.
@BffIngredient(recipe = "payments", name = "getAccount")
@GetMapping("/api/accounts/{accountId}")
public ResponseEntity<Account> getAccount(...) { }
@BffIngredient(recipe = "payments", name = "getInvoices")
@GetMapping("/api/invoices")
public ResponseEntity<InvoiceList> getInvoices(...) { }
@BffIngredient(recipe = "payments", name = "getPaymentMethods")
@GetMapping("/api/payment-methods")
public ResponseEntity<PaymentMethodList> getPaymentMethods(...) { }
@BffIngredient(recipe = "payments", name = "submitPayment")
@PostMapping("/api/payments")
public ResponseEntity<Confirmation> submitPayment(...) { }
POST /bff/payments
{
"ingredients": [
{ "id": "getAccount",
"params": { "accountId": "acc-123" } },
{ "id": "getInvoices",
"map": { "query": {
"billingGroupId":
"getAccount::body::${billingGroupId}"
}}},
{ "id": "getPaymentMethods",
"map": { "query": {
"customerId":
"getAccount::body::${customerId}"
}}}
]
}
The framework figures out the dependency graph, runs independent ingredients in parallel, propagates auth, and returns everything in one response. No aggregation logic to write. No new endpoints to maintain.
Example of client-side latency
A payments page needs account info, invoices, and payment methods. Assume 700ms network latency per round trip and 100ms server processing. A good frontend team will parallelize what it can โ but the dependency chain still forces sequential hops:
Even with a smart frontend parallelizing what it can, the dependency chain forces multiple round trips. With bff-recipe, the same chain resolves server-side โ the client sends one request and gets everything back.
Quick Start
-
Add the starter to your existing Spring Boot project.
dependencies {
implementation 'io.github.tayyab23:bff-spring-lib:1.2.0'
}
<dependency>
<groupId>io.github.tayyab23</groupId>
<artifactId>bff-spring-lib</artifactId>
<version>1.2.0</version>
</dependency>
-
Annotate any controller.
@BffIngredient
@GetMapping("/api/accounts/{accountId}")
public ResponseEntity<Account> getAccount(@PathVariable String accountId) { ... }
-
Test your new bff.
{
"ingredients": [
{ "id": "getAccount", "params": { "accountId": "acc-123" } }
]
}
{
"executionOrder": ["getAccount"],
"results": {
"getAccount": {
"status": 200,
"body": { "accountId": "acc-123", "name": "BFF" }
}
}
}
For Frontend Teams
Your backend team adds @BffIngredient to their controllers. You compose recipes and get typed responses. Install the type definitions โ they add zero bytes to your runtime bundle.
npm install @bff-recipe/types --save-dev
import type { RecipeRequest, RecipeResponse } from '@bff-recipe/types';
import axios from 'axios';
interface Account { accountId: string; plan: string; billingGroupId: string; }
interface InvoiceList { items: { id: string; amount: number }[]; total: number; }
type PaymentsPage = { getAccount: Account; getInvoices: InvoiceList; };
const request: RecipeRequest = {
ingredients: [
{ id: 'getAccount', params: { accountId: 'acc-123' } },
{ id: 'getInvoices', map: { query: { billingGroupId: 'getAccount::body::${billingGroupId}' } } }
]
};
const { data } = await axios.post<RecipeResponse<PaymentsPage>>('/bff/payments', request);
data.results.getAccount.body.plan; // โ string
data.results.getInvoices.body.items[0].amount; // โ number
data.results.nonExistent; // โ compile error
Use import type and your bundler strips the import entirely โ the types exist only at compile time. Works with fetch, Axios, ky, or any HTTP client. Full reference โ
How it compares
There are real alternatives. bff-recipe indexes on minimum setup cost and maximum API delivery speed โ the shortest path from existing REST endpoints to a composed frontend response. Here's an honest look at the trade-offs.
| Approach | Setup cost | Existing APIs | Frontend control | Trade-off |
|---|---|---|---|---|
| bff-recipe | 1 dependency, 1 annotation | Unchanged โ annotate and go | Full โ compose recipes at will | Spring Boot only. No field-level selection (you get the full response body per ingredient). |
| GraphQL (e.g. DGS, Spring GraphQL) | Schema + resolvers + data loaders | Rewrite as resolvers | Full โ query exactly the fields you need | Powerful, but requires rethinking API contracts. Resolver complexity grows with the graph. Best when you need field-level granularity. |
| API Gateway composition (e.g. Kong, AWS API GW) | Gateway config + orchestration rules | Unchanged โ route externally | Limited โ gateway owns the composition | Good for simple fan-out. Dependency chains and data wiring between calls get complex fast. Adds infrastructure. |
| Custom BFF endpoints | Write a new controller per screen | Called internally | None โ backend owns every composition | Maximum flexibility, but backend becomes a bottleneck for every frontend change. Aggregation logic accumulates. |
| Client-side orchestration | Zero backend changes | Unchanged | Full | What most teams do today. Works until dependency chains force sequential round trips across the network (the latency problem above). |
Still have questions about the design trade-offs? See the FAQ โ
Two Modes
bff-recipe discovers ingredients one of two ways. Pick the one that fits your architecture โ the runtime behavior is identical either way.
Annotation Mode (default)
You own the controllers. Add one annotation โ they keep working independently.
@BffIngredient(
recipe = {"payments", "dashboard"},
name = "getAccount"
)
@GetMapping("/api/accounts/{accountId}")
public ResponseEntity<Account> getAccount(
@PathVariable String accountId) { ... }
// mode: annotation (default)
// Repeat for each endpoint you want
// to include in a recipe.
Config Mode
Can't annotate โ third-party JAR, generated code, or another team's module. Define everything in yml.
bff-recipe:
mode: config
ingredients:
getAccount:
method: GET
path: /api/accounts/{accountId}
getInvoices:
method: GET
path: /api/invoices
recipes:
payments:
ingredients: [getAccount, getInvoices]
| Aspect | Annotation | Config |
|---|---|---|
| Ingredient discovery | @BffIngredient on methods | bff-recipe.ingredients in yml |
| Recipe definition | recipe attribute on annotation | bff-recipe.recipes in yml |
| Code changes to controllers | One annotation per method | Zero |
| Works with code you don't own | No | Yes |
| Per-environment recipes | Conditional beans / profiles | Spring profile yml |
| Request format, expressions, response | Identical | |
| Security, DAG, parallel dispatch | Identical | |
mode: annotation (default) or mode: config. When using config mode, annotations are ignored entirely.Proxy Dispatch
When your APIs live in separate services โ other Lambdas, ECS tasks, or third-party systems โ use proxy-url to aggregate across them without writing proxy controllers. The BFF becomes a dedicated aggregation layer that forwards requests to external services.
bff-recipe:
mode: config
ingredients:
getAccount:
method: GET
path: /api/accounts/{accountId}
getInvoices:
method: GET
path: /api/invoices
getShipping:
method: GET
path: /api/shipments/{orderId}
proxy-url: ${SHIPPING_SERVICE_URL:http://localhost:8082}
recipes:
payments:
proxy-url: ${API_BASE_URL:http://localhost:8080}
ingredients: [getAccount, getInvoices, getShipping]
Resolution order
Ingredient proxy-url โ Recipe proxy-url โ local in-process dispatch. Same override chain philosophy as headers โ each level narrows.
| Ingredient | Dispatches to | Why |
|---|---|---|
| getAccount | ${API_BASE_URL}/api/accounts/{accountId} | No ingredient proxy-url โ falls back to recipe proxy-url |
| getInvoices | ${API_BASE_URL}/api/invoices | Same โ recipe-level default |
| getShipping | ${SHIPPING_SERVICE_URL}/api/shipments/{orderId} | Ingredient proxy-url overrides recipe |
${ENV_VAR:default}) in proxy-url. Set different values per environment โ no code changes, no rebuilds. Works with env vars, Spring profiles, AWS Parameter Store, or any Spring property source.Mixing local and proxy
Ingredients without a proxy-url (and no recipe-level default) dispatch locally through the Spring MVC chain. You can mix both in the same recipe โ some controllers in-process, others proxied to external services.
status: 502 with {"error": "ProxyError", "message": "..."}.@BffIngredient
Annotation mode details. Place it on any mapped controller method.
Annotation attributes
| Attribute | Default | Description |
|---|---|---|
| recipe | "" | Recipe name(s). Empty = default endpoint at POST /bff. Named recipes get POST /bff/{name}. |
| name | method name | ID used in recipe requests. Must be unique within a recipe. |
| forwardHeaders | INHERIT | Which original request headers to forward. {"*"} all, {} none, list, or regex. |
| customHeaders | INHERIT | Which custom headers the client may inject. |
| headerMapping | INHERIT | Whether prior ingredients' response headers can be mapped in. |
Override chain
Settings can be specified at multiple levels. Each level can only narrow what the level above allows โ never widen it.
| Setting | yml (global) | yml (per-recipe) | @BffIngredient | Recipe JSON |
|---|---|---|---|---|
| ingredient-timeout-ms | โ | โ overrides | โ | โ |
| recipe-timeout-ms | โ | โ overrides | โ | โ |
| forward headers | โ | โ narrows | โ narrows | โ narrows |
| custom headers | โ | โ narrows | โ narrows | โ narrows |
| header mapping | โ | โ narrows | โ narrows | โ narrows |
| debug | โ | โ | โ | โ requests |
| failFast | โ | โ | โ | โ |
@BffIngredient โ Recipe JSON. Each level narrows the one above. The global blocklist always wins.
Common Configuration
These settings apply to both modes. All are optional โ the defaults work out of the box.
bff-recipe:
enabled: true
base-path: "/bff"
schema:
enabled: false # GET /bff/{recipe}/schema
validate:
enabled: false # POST /bff/{recipe}/validate
debug:
enabled: false
mask-headers: ["Authorization", "X-Api-Key", "Cookie"]
execution:
parallel-threads: 10
ingredient-timeout-ms: 5000
recipe-timeout-ms: 15000
max-ingredients: 10
headers:
forward:
enabled: true
blocked: ["Host", "Content-Length", "Connection"]
custom:
enabled: false
blocked: ["Authorization", "Host"]
mapping:
enabled: false
blocked-sources: ["Set-Cookie"]
"Authorization", "authorization", and "AUTHORIZATION" all match. Original casing is preserved when forwarding.Execution Model
The framework builds a DAG from your recipe, topologically sorts it into levels, and runs each level in parallel.
Level 0: [getAccount] โ runs alone
Level 1: [getInvoices, getPaymentMethods] โ parallel (both depend on getAccount)
Level 2: [submitPayment] โ runs alone (depends on both above)
Dependencies are inferred from :: expressions automatically. Use dependsOn only for ordering without data flow.
SecurityContext is forwarded to every ingredient. Not configurable โ it's a security invariant.| failFast | Behaviour on failure |
|---|---|
| false (default) | Skip dependents (422). Continue independent ingredients. |
| true | Abort all remaining. Already-completed results still returned. |
Thread pool
Ingredients within a level run in parallel on a shared fixed thread pool. The pool is shared across all concurrent recipe requests.
| parallel-threads | Behaviour |
|---|---|
| 10 (default) | Fixed thread pool with 10 threads. Size for your expected concurrency. |
| 0 | No thread pool. Ingredients execute sequentially on the request thread. Good for Lambda, debugging, or recipes with 1โ2 ingredients. |
parallel-threads: 10, 10 concurrent recipe requests each with 5 parallel ingredients = 50 tasks competing for 10 threads. Ingredients queue behind unrelated recipes. Size the pool for your peak concurrency, or set to 0 for sequential execution.Middleware & Security
Every ingredient dispatches through Spring's full MVC pipeline โ not a shortcut, not a direct method call. This means your existing middleware applies per-ingredient, automatically:
| Concern | Where it lives | Applies to recipes? |
|---|---|---|
| Authentication | Your Spring Security filter chain | Yes โ SecurityContext propagated to each ingredient |
| Authorization | @PreAuthorize, @Secured, or filter | Yes โ each ingredient is independently authorized |
| Rate limiting | Your interceptor or filter | Yes โ each dispatch hits your rate limiter |
| Validation | @Valid, @Validated | Yes โ Bean Validation runs per ingredient |
| Logging / audit | Your interceptors | Yes โ each ingredient is a full request |
getAccount requires ROLE_ADMIN, it still requires ROLE_ADMIN when called through a recipe.
Think of it as handing the backend a shopping list. The backend walks through the store, picks up each item (hitting every checkpoint along the way), and brings it all back in one bag.
Security Model
The client controls which ingredients to call and what values to pass via the map block. Here's what that means for security โ and why it doesn't escalate privileges.
What the client can do
| Action | Example | Why it's safe |
|---|---|---|
| Choose which ingredients to call | {"id": "getAccount"} | Only ingredients registered in the recipe via @BffIngredient are callable. Unknown IDs โ 400. |
| Pass path/query params | "params": {"accountId": "acc-123"} | Same as calling GET /api/accounts/acc-123 directly. Your controller validates the input. |
| Wire values between ingredients | "getAccount::body::${accountId}" | The resolved value is passed as a normal parameter. Your controller's @Valid, @PreAuthorize, and business logic still apply. |
| Send custom headers | "custom": {"X-Idempotency-Key": "abc"} | Server allowlist controls which custom headers are permitted. Global blocklist always wins. |
What the client cannot do
| Attempt | Result |
|---|---|
Call an endpoint not annotated with @BffIngredient | 400 โ ingredient not found in recipe |
| Call an ingredient from a different recipe | 400 โ ingredient not registered in this recipe |
| Bypass auth on an ingredient | Impossible โ SecurityContext is always propagated, @PreAuthorize runs on every dispatch |
| Inject a header blocked by server policy | Silently stripped โ global blocklist always wins |
| Access another user's data via parameter manipulation | Your controller's authorization logic prevents this โ the recipe layer doesn't bypass it |
Header System
Three sources merged per ingredient, in precedence order. Server policy always wins over client intent.
| Source | Precedence | Description |
|---|---|---|
| forwarded | Lowest | Headers from the original BFF request |
| mapped | Middle | Headers from a previous ingredient's response |
| custom | Highest | Static headers in the recipe request |
{
"id": "submitPayment",
"headers": {
"forward": true,
"forwardOnly": ["Authorization"],
"custom": { "X-Idempotency-Key": "idem-abc-123" },
"mappings": { "getAccount.X-Trace-Id": "X-Upstream-Trace" }
}
}
Request Format
Send a POST to /bff/{recipe}. The URL selects the recipe โ no need to specify it in the body.
Authorization as a custom header, the client can't inject it regardless of what the JSON says.
{
"debug": false,
"failFast": false,
"headers": { "forward": true, "forwardOnly": ["Authorization"] },
"ingredients": [
{
"id": "getAccount",
"params": { "accountId": "acc-123" }
},
{
"id": "getInvoices",
"map": {
"query": {
"billingGroupId": "getAccount::body::${billingGroupId}",
"limit": 10
}
}
},
{
"id": "submitPayment",
"dependsOn": ["getInvoices"],
"map": {
"body": {
"accountId": "getAccount::body::${accountId}",
"invoiceId": "getInvoices::body::${items[0].id}",
"amount": "getInvoices::body::${items[0].amount}",
"currency": "USD",
"paymentMethodId": "pm-visa-4242"
}
},
"headers": {
"custom": { "X-Idempotency-Key": "idem-abc-123" }
}
}
]
}
Request fields & what the server can override
| Field | Type | Default | Server can override? |
|---|---|---|---|
| debug | boolean | false | Yes โ ignored if debug.enabled: false in yml |
| failFast | boolean | false | No โ client controls this |
| headers.forward | boolean | true | Yes โ server forward.enabled: false wins |
| headers.forwardOnly | string[] | all allowed | Yes โ server blocklist strips disallowed headers |
| ingredients[].id | string | required | No โ must match a registered @BffIngredient name |
| ingredients[].params | object | null | No |
| ingredients[].body | any | null | No |
| ingredients[].map | object | null | No โ but referenced fields are validated |
| ingredients[].dependsOn | string[] | inferred | No โ merged with inferred deps from map |
| ingredients[].headers.custom | object | null | Yes โ server custom.enabled, custom.blocked, and custom.allowed filter these |
| ingredients[].headers.mappings | object | null | Yes โ server mapping.enabled and blocked-sources filter these |
Expressions & Mapping
Values in the map block are either a reference (contains ::) or a literal (no ::).
| Expression | Meaning |
|---|---|
| ingredientName::body::${field} | Field from a prior ingredient's response body |
| ingredientName::body::${a.b.c} | Nested field via dot notation |
| ingredientName::body::${items[0].id} | Array index access |
| ingredientName::header::${X-Trace-Id} | Header from a prior ingredient's response |
| "json" / 42 / true | Literal โ passed through as-is |
"map": {
"path": { "accountId": "getAccount::body::${accountId}" },
"query": { "billingGroupId": "getAccount::body::${billingGroupId}", "limit": 20 },
"body": { "invoiceId": "getInvoices::body::${items[0].id}", "note": "auto-pay" }
}
getAccount::body::${billingGroupId} have no IDE support or compile-time validation โ a typo silently resolves to null at runtime. Enable the validate endpoint in dev/staging to catch these before they hit production: bff-recipe.validate.enabled: true.Array Operators
When one ingredient returns a list and the next ingredient needs values from that list, array operators let you collect, slice, and filter โ all resolved into a single value passed to one batch call. No fan-out. No N+1.
Operator Reference
| Category | Syntax | Example | Result |
|---|---|---|---|
| Collect | [*] | items[*].id | All values โ ["inv-1", "inv-2", ...] |
| Index | [n] | items[0].id | Single element by position |
| Index | [-n] | items[-1].id | Single element from end |
| Slice | [start:end] | items[0:5].id | Range (exclusive end) |
| Slice | [start:] | items[5:].id | From index to end |
| Slice | [:end] | items[:3].id | First N |
| Slice | [-n:] | items[-3:].id | Last N |
| Equality | [?field==value] | items[?status==OVERDUE].id | Filter by equality |
| Equality | [?field!=value] | items[?status!=PAID].id | Filter by inequality |
| Comparison | [?field>value] | items[?amount>100].id | Greater than |
| Comparison | [?field>=value] | items[?amount>=100].id | Greater or equal |
| Comparison | [?field<value] | items[?amount<50].id | Less than |
| Comparison | [?field<=value] | items[?amount<=50].id | Less or equal |
| Set | [?field in (a,b)] | items[?status in (OVERDUE,UNPAID)].id | Set membership |
| Regex | [?field==REG(...)] | items[?name==REG(^Pro.*)].id | Regex match |
| Existence | [?field exists] | items[?couponCode exists].id | Field present and not null |
| Existence | [?field missing] | items[?deletedAt missing].id | Field absent or null |
| Boolean | [?field==true] | items[?active==true].id | Boolean match |
| Null | [?field==null] | items[?deletedAt==null].id | Null check |
| AND | [?a==1&&b==2] | items[?status==OVERDUE&&amount>100].id | Both conditions |
| OR | [?a==1||b==2] | items[?status==OVERDUE||status==UNPAID].id | Either condition |
| Nested | [?a.b==value] | items[?billing.region==US].id | Dot notation in filter |
Type Coercion
Filter values are auto-detected:
| Value | Detected As |
|---|---|
true / false | Boolean |
null | Null |
42, 3.14, -10 | Number (compared as Double) |
REG(^Pro.*) | Compiled regex pattern |
in (a,b,c) | Set of auto-coerced values |
| Everything else | String |
Edge Cases
| Scenario | Behavior |
|---|---|
| Path before bracket is not an array | Returns null |
| Filter/slice produces empty result | Returns [] โ the batch endpoint receives an empty list |
| Filter field missing on some elements | Those elements are skipped (treated as non-match) |
| Slice out of bounds | Clamped to array bounds (no error) |
| Negative index out of bounds | Returns null |
items[*].lines[*].sku). No chaining (items[?x==1][0:3]). Post-collection field pluck is supported: items[*].id โReal-World Example: Order Enrichment
A frontend needs an order management dashboard. The backend has three separate systems: an Order Service (returns 100 orders), a Fulfillment Service (batch shipping status), and a Payment Service (batch payment status). Each downstream system only needs a subset of the orders.
{
"ingredients": [
{
"id": "getOrders",
"params": { "customerId": "cust-42" }
},
{
"id": "batchGetShipping",
"map": {
"body": {
"orderIds": "getOrders::body::${orders[?status!=CANCELLED].orderId}"
}
}
},
{
"id": "batchGetPayments",
"map": {
"body": {
"orderIds": "getOrders::body::${orders[?billing.method!=FREE_TRIAL&&status in (CONFIRMED,SHIPPED)].orderId}"
}
}
}
]
}
Execution:
Level 0: [getOrders] โ fetches 100 orders
Level 1: [batchGetShipping, batchGetPayments] โ run in parallel
โ receives ~90 non-cancelled IDs โ receives ~60 paid+confirmed IDs
One network round trip. Three systems queried. Array operators handle the subsetting โ no client-side filtering, no intermediate APIs, no N+1.
More Examples
"invoiceIds": "getInvoices::body::${items[*].id}"
"invoiceIds": "getInvoices::body::${items[?status==OVERDUE&&amount>100].id}"
"invoiceIds": "getInvoices::body::${items[?status in (OVERDUE,UNPAID)].id}"
"descriptions": "getInvoices::body::${items[?description==REG(^Pro Plan.*)].description}"
"firstPage": "getOrders::body::${orders[:10].orderId}"
"recentFive": "getOrders::body::${orders[-5:].orderId}"
"usOrders": "getOrders::body::${orders[?billing.region==US&&couponCode exists].orderId}"
Response Format
| HTTP Status | Meaning |
|---|---|
| 200 | All ingredients succeeded |
| 207 | Mixed results โ check each ingredient's status |
| 400 | Recipe itself is malformed (unknown ingredient, cycle, etc.) |
{
"executionOrder": ["getAccount", ["getInvoices", "getPaymentMethods"], "submitPayment"],
"results": {
"getAccount": { "status": 200, "body": { "accountId": "acc-123", "plan": "PRO" } },
"getInvoices": { "status": 200, "body": { "items": [...], "total": 249.99 } },
"getPaymentMethods": { "status": 403, "body": { "error": "AccessDenied" } },
"submitPayment": { "status": 422, "body": { "error": "DependencyFailed", "message": "Skipped: dependency 'getPaymentMethods' failed" } }
}
}
Error Handling
| Scenario | Status | Meaning |
|---|---|---|
| API returned an error | 4xx/5xx | Ingredient ran, underlying API failed |
| Dependency failed | 422 | Ingredient skipped โ input was incomplete |
| Unknown ingredient ID | 400 | Recipe rejected before execution |
| Circular dependency | 400 | Cycle path shown in error message |
| Too many ingredients | 400 | Exceeds max-ingredients limit |
TypeScript Types
@bff-recipe/types gives you compile-time safety for recipe requests and responses. It ships only type definitions โ zero bytes added to your runtime bundle via import type. Use it with whatever HTTP client you already have.
npm install @bff-recipe/types --save-dev
With Axios
import type { RecipeRequest, RecipeResponse } from '@bff-recipe/types';
import axios from 'axios';
// Your own types โ from OpenAPI codegen, Smithy, or hand-written
interface Account { accountId: string; plan: string; billingGroupId: string; }
interface InvoiceList { items: { id: string; amount: number }[]; total: number; }
type PaymentsPage = { getAccount: Account; getInvoices: InvoiceList; };
const request: RecipeRequest = {
ingredients: [
{ id: 'getAccount', params: { accountId: 'acc-123' } },
{ id: 'getInvoices', map: { query: { billingGroupId: 'getAccount::body::${billingGroupId}' } } }
]
};
const { data } = await axios.post<RecipeResponse<PaymentsPage>>('/bff/payments', request);
data.results.getAccount.body.plan; // โ string
data.results.getInvoices.body.items[0].amount; // โ number
data.results.nonExistent; // โ compile error
With fetch
import type { RecipeRequest, RecipeResponse } from '@bff-recipe/types';
const res = await fetch('/bff/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const data: RecipeResponse<PaymentsPage> = await res.json();
RecipeRequest, RecipeResponse<T>, and IngredientResult<T> exist only at compile time. Use import type and your bundler strips them entirely. The package has no dependencies and no runtime code.
Discovery Endpoints
Both disabled by default. Enable per environment โ they're protected by your existing Spring Security config.
{
"recipe": "payments",
"ingredients": {
"getAccount": { "method": "GET", "path": "/api/accounts/{accountId}", "responseType": "Account" },
"getInvoices": { "method": "GET", "path": "/api/invoices", "responseType": "InvoiceList" },
"submitPayment": { "method": "POST", "path": "/api/payments", "responseType": "PaymentConfirmation" }
}
}
{
"valid": false,
"errors": [
{ "ingredient": "unknownIngredient", "error": "Ingredient 'unknownIngredient' is not registered in recipe 'payments'" }
]
}
Observability
Zero config. Auto-activates when the relevant library is on the classpath.
| Feature | Requires | What you get |
|---|---|---|
| Metrics | micrometer-core | bff.recipe.requests, bff.recipe.duration, bff.ingredient.duration |
| Tracing | micrometer-tracing or OTel | Parent span per recipe, child span per ingredient with recipe/status attributes |
| Health | spring-boot-actuator | GET /actuator/health โ bffRecipe: UP with recipe + ingredient counts |
Example App
A fully working Spring Boot application demonstrating all patterns โ path variables, query params, POST bodies, header forwarding, multi-recipe membership, and mock auth.
๐ Auth
Pass Authorization: Bearer demo-token on all requests. Propagated automatically to every ingredient.
๐ฆ Recipes
payments โ account + invoices + payment methods + submit.dashboard โ account + profile + invoices.
โ๏ธ Run it
./gradlew :bff-example:bootRun โ starts on port 8080.
๐ Explore
GET /bff/payments/schema to see all ingredients and their types.
curl -X POST http://localhost:8080/bff/payments \
-H "Authorization: Bearer demo-token" \
-H "Content-Type: application/json" \
-d '{
"ingredients": [
{ "id": "getAccount", "params": { "accountId": "acc-123" } },
{ "id": "getInvoices", "map": { "query": { "billingGroupId": "getAccount::body::${billingGroupId}" } } },
{ "id": "getPaymentMethods", "map": { "query": { "customerId": "getAccount::body::${customerId}" } } }
]
}'
Anti-Patterns
An aggregation layer is powerful โ and easy to misuse. These are the patterns to avoid, including ones that bff-recipe can accidentally enable if you're not careful.
One aggregation for all
One recipe with 10 ingredients that returns everything for every screen. It starts as a convenience and becomes unmaintainable. Different screens need different data โ use named recipes (payments, dashboard, checkout) to keep each one focused.
@BffIngredient(recipe = {"payments", "dashboard"}), and continuously look for opportunities that reduce the scope of aggregations.
Chatty Ingredients
12 ingredients in a recipe where each depends on the previous one. You've moved the waterfall from the client to the server โ the latency is the same, you just can't see it anymore. If your DAG is a straight line, your APIs weren't designed for aggregation.
Business Logic in the Map Block
Trying to use array operators as a transformation engine โ filtering, slicing, and reshaping data that should be the API's responsibility. The map block moves data between ingredients. If you're writing complex compound filters to work around an API that returns too much, fix the API.
Skipping Batch Endpoints
You have an API that returns 50 order IDs and another API that accepts one order ID at a time. Don't build 50 ingredients. Build a batch endpoint (POST /api/orders/details) that accepts a list, then use [*] to collect the IDs and pass them in one call.
Leaking Internal Structure
Enabling the schema endpoint in production and exposing your internal API paths, parameter names, and response types to the public internet. The schema endpoint is a development tool.
schema.enabled: false and validate.enabled: false in production. Enable them only in dev/staging via environment-specific config.
Ignoring Idempotency on Writes
Including a submitPayment ingredient in a recipe without idempotency protection. If the client retries the recipe on a network timeout, the payment gets submitted twice. Every mutating ingredient should support an idempotency key.
X-Idempotency-Key per ingredient. Your payment controller should already enforce idempotency โ the BFF layer just forwards the key.
Premature Normalization
Refusing to duplicate data across ingredient responses in the name of DRY. An API response is not your source of truth โ your database is. If the order detail response needs the customer name embedded, embed it. Don't force the client to join data from two ingredients.
Synchronous Heavy Lifting
Putting a long-running operation (report generation, bulk export) as an ingredient. The recipe has a timeout, and a 30-second ingredient blocks the entire recipe. Heavy operations should be async.
202 Accepted with a job ID. The client polls separately. Don't put them in a recipe.
When You Actually Want SSR
Your frontend team builds increasingly complex recipes, parses the JSON response, maps it into components, and renders a page โ every time. If the end result is always a fully rendered page and not an interactive SPA that manages state, you're doing server-side rendering with extra steps.
BFF aggregation is the right tool when you have a rich client that needs raw JSON from multiple APIs in one round trip โ SPAs, mobile apps, or any client that manages its own state and renders interactively. It's the wrong tool when the real problem is "we want the server to render the page."
FAQ โ Design Decisions
These are deliberate constraints, not missing features. Each one keeps the library simple and the aggregation layer free of business logic.
Why no array iteration / $foreach?
That's the N+1 anti-pattern. Array operators ([*], [?filter], [start:end]) collect values into a single list for one batch call โ they never fan out into N separate calls. If you need to call an API per item, build a batch endpoint.
Why no expression language, conditionals, or string concatenation in the map block?
The map block is path resolution only โ it moves values from one ingredient's response to another's request. No transforms, no type coercion, no formatting. If you need logic, it belongs in the API itself, not the aggregation layer.
Why can't I transform or reshape response data?
Composition only. Values are moved as-is. The recipe layer is a courier, not a processor. If the frontend needs a different shape, the API should return that shape.
Why is auth propagation not configurable?
It's a security invariant. Every ingredient executes with the same identity as the original caller. Making this optional would create a class of bugs where ingredients silently run unauthenticated.
Can I aggregate across multiple services?
Yes โ as of v1.2.0, proxy-url in config mode lets you point ingredients at external services. You get the same DAG execution, expression wiring, and parallel dispatch โ but the HTTP calls go over the network instead of in-process.
What proxy mode handles:
- Network dispatch via RestClient
- Header forwarding (including trace headers)
- Per-ingredient and per-recipe timeouts
- URL encoding
- Environment-driven URLs via Spring property placeholders
What proxy mode does not handle:
- Service discovery โ you provide static URLs. Use env vars or Spring Cloud for dynamic resolution.
- Circuit breakers โ a failing service returns 502 on every attempt. Add Resilience4j in your app if you need circuit breaking.
- Retry logic โ one shot per ingredient. Retries should be idempotency-aware and belong in your infrastructure.
- RestClient connect/read timeouts โ the ingredient timeout kills the future, but a hung TCP connection sits until then. Configure a custom
bffRestClientbean with explicit timeouts if this matters.
This is a lightweight aggregation proxy, not a service mesh. It eliminates the boilerplate of writing proxy controllers by hand. For service discovery, circuit breaking, and advanced resilience โ layer those into your Spring app the way you normally would.
Why is ingredient order in the JSON array ignored?
The DAG determines execution order from your :: references and dependsOn declarations. Array position is irrelevant โ this prevents subtle ordering bugs.
Why only one annotation (@BffIngredient) instead of also having @BffRecipe?
One annotation to learn, one config file (application.yml) for recipe-level settings. Fewer places to look, fewer things to get wrong.
Why no built-in rate limiting?
Your existing Spring Security filters and interceptors already handle this โ and they apply per-ingredient automatically because every dispatch goes through the full MVC chain. The max-ingredients config caps per-request amplification.
Why not GraphQL?
Different tool for a different problem. GraphQL gives you field-level selection, subscriptions, and a unified graph โ but requires rethinking your API contracts into schemas and resolvers. bff-recipe lets you keep your REST APIs exactly as they are and just eliminate the round-trip tax. If you need GraphQL's power, use GraphQL.
Are header names case-sensitive?
No. HTTP headers are case-insensitive by spec (RFC 7230), and HTTP/2 actually requires lowercase headers. bff-recipe matches headers case-insensitively everywhere โ config, annotations, and recipe requests. Original casing is preserved when forwarding.
Why is failFast off by default?
Most pages can render partially. If getPaymentMethods fails, you probably still want to show the account info and invoices. failFast: true is there for workflows where partial results are meaningless.
MIT License ยท github.com/tayyab23/bff