Spring Boot 3.x ยท Java 17

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
1
dependency to add
0
business logic in the aggregation layer
Nโ†’1
network round trips

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.

Backend โ€” annotate existing endpointsjava
@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(...) { }
Frontend โ€” compose a recipejson
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:

Network latency Server processing Page load User action
Without bff-recipe 3 round trips, best case
getAccount
350ms 100ms 350ms
getInvoices
350ms 100ms 350ms
getPaymentMethods
350ms 100ms 350ms
submitPayment
350ms 100ms 350ms
Page load: 1,600ms + 800ms user action
A smart frontend parallelizes getInvoices and getPaymentMethods โ€” but both need getAccount first. That's 2 sequential round trips the client can't avoid, plus a third for the payment submission.
With bff-recipe 1 round trip + 1 user action
network โ†—
350ms
getAccount
100ms
getInvoices
โˆฅ 100ms
getPaymentMethods
โˆฅ 100ms
network โ†™
350ms
submitPayment
350ms 100ms 350ms
Page load: 900ms + 800ms user action โ€” 45% faster
Same 4 APIs, same dependency chain โ€” but the sequential hops happen server-side with zero network cost. The client pays for one round trip to load the page, plus one for the user-initiated payment.

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

  1. Add the starter to your existing Spring Boot project.

build.gradlegroovy
dependencies {
    implementation 'io.github.tayyab23:bff-spring-lib:1.2.0'
}
pom.xmlxml
<dependency>
    <groupId>io.github.tayyab23</groupId>
    <artifactId>bff-spring-lib</artifactId>
    <version>1.2.0</version>
</dependency>
  1. Annotate any controller.

java
@BffIngredient
@GetMapping("/api/accounts/{accountId}")
public ResponseEntity<Account> getAccount(@PathVariable String accountId) { ... }
  1. Test your new bff.

POST /bffjson
{
  "ingredients": [
    { "id": "getAccount", "params": { "accountId": "acc-123" } }
  ]
}
Response โ€” 200 OKjson
{
  "executionOrder": ["getAccount"],
  "results": {
    "getAccount": {
      "status": 200,
      "body": { "accountId": "acc-123", "name": "BFF" }
    }
  }
}
That's it. Configuration is entirely optional.

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.

installbash
npm install @bff-recipe/types --save-dev
typed BFF call with Axiostypescript
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).
bff-recipe is not a replacement for GraphQL. If your team needs field-level selection, subscriptions, or a unified graph across services โ€” use GraphQL. bff-recipe is for teams that want to keep their REST APIs exactly as they are and just eliminate the round-trip tax.

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.

Javajava
@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.

application.ymlyaml
bff-recipe:
  mode: config
  ingredients:
    getAccount:
      method: GET
      path: /api/accounts/{accountId}
    getInvoices:
      method: GET
      path: /api/invoices
  recipes:
    payments:
      ingredients: [getAccount, getInvoices]
AspectAnnotationConfig
Ingredient discovery@BffIngredient on methodsbff-recipe.ingredients in yml
Recipe definitionrecipe attribute on annotationbff-recipe.recipes in yml
Code changes to controllersOne annotation per methodZero
Works with code you don't ownNoYes
Per-environment recipesConditional beans / profilesSpring profile yml
Request format, expressions, responseIdentical
Security, DAG, parallel dispatchIdentical
Pick one. Set 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.

application.yml โ€” proxy modeyaml
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.

IngredientDispatches toWhy
getAccount${API_BASE_URL}/api/accounts/{accountId}No ingredient proxy-url โ†’ falls back to recipe proxy-url
getInvoices${API_BASE_URL}/api/invoicesSame โ€” recipe-level default
getShipping${SHIPPING_SERVICE_URL}/api/shipments/{orderId}Ingredient proxy-url overrides recipe
Environment-driven URLs. Use Spring property placeholders (${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.

Proxy errors return 502. If the external service is unreachable or returns a non-parseable response, the ingredient result will have status: 502 with {"error": "ProxyError", "message": "..."}.

@BffIngredient

Annotation mode details. Place it on any mapped controller method.

Annotation attributes

AttributeDefaultDescription
recipe""Recipe name(s). Empty = default endpoint at POST /bff. Named recipes get POST /bff/{name}.
namemethod nameID used in recipe requests. Must be unique within a recipe.
forwardHeadersINHERITWhich original request headers to forward. {"*"} all, {} none, list, or regex.
customHeadersINHERITWhich custom headers the client may inject.
headerMappingINHERITWhether 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.

Settingyml (global)yml (per-recipe)@BffIngredientRecipe 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โ€”โ€”โ€”โœ“
Override chain: Global yml โ†’ Per-recipe yml โ†’ @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.

application.yml โ€” all optionalyaml
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"]
Headers are case-insensitive. "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.

execution levels exampletext
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.

Auth is always propagated. The Spring SecurityContext is forwarded to every ingredient. Not configurable โ€” it's a security invariant.
failFastBehaviour on failure
false (default)Skip dependents (422). Continue independent ingredients.
trueAbort 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-threadsBehaviour
10 (default)Fixed thread pool with 10 threads. Size for your expected concurrency.
0No thread pool. Ingredients execute sequentially on the request thread. Good for Lambda, debugging, or recipes with 1โ€“2 ingredients.
Sizing matters under load. With 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:

ConcernWhere it livesApplies to recipes?
AuthenticationYour Spring Security filter chainYes โ€” SecurityContext propagated to each ingredient
Authorization@PreAuthorize, @Secured, or filterYes โ€” each ingredient is independently authorized
Rate limitingYour interceptor or filterYes โ€” each dispatch hits your rate limiter
Validation@Valid, @ValidatedYes โ€” Bean Validation runs per ingredient
Logging / auditYour interceptorsYes โ€” each ingredient is a full request
This is intentional. The aggregation layer has zero business logic โ€” it's a recipe executor, not a controller. All security, validation, and throttling stay where they belong: on the individual APIs. If 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

ActionExampleWhy 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

AttemptResult
Call an endpoint not annotated with @BffIngredient400 โ€” ingredient not found in recipe
Call an ingredient from a different recipe400 โ€” ingredient not registered in this recipe
Bypass auth on an ingredientImpossible โ€” SecurityContext is always propagated, @PreAuthorize runs on every dispatch
Inject a header blocked by server policySilently stripped โ€” global blocklist always wins
Access another user's data via parameter manipulationYour controller's authorization logic prevents this โ€” the recipe layer doesn't bypass it
The key insight: a recipe request is equivalent to the client making the same API calls individually. Every ingredient dispatches through the full Spring MVC chain โ€” filters, interceptors, security, validation. The recipe layer is a transport optimization, not a privilege escalation.

Header System

Three sources merged per ingredient, in precedence order. Server policy always wins over client intent.

SourcePrecedenceDescription
forwardedLowestHeaders from the original BFF request
mappedMiddleHeaders from a previous ingredient's response
customHighestStatic headers in the recipe request
per-ingredient header configjson
{
  "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.

The server always wins. The recipe JSON can request header forwarding, custom headers, and debug info โ€” but the backend configuration (yml + annotation) sets the ceiling. The client can only narrow what the server allows, never widen it. If the server blocks Authorization as a custom header, the client can't inject it regardless of what the JSON says.
POST /bff/paymentsjson
{
  "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

FieldTypeDefaultServer can override?
debugbooleanfalseYes โ€” ignored if debug.enabled: false in yml
failFastbooleanfalseNo โ€” client controls this
headers.forwardbooleantrueYes โ€” server forward.enabled: false wins
headers.forwardOnlystring[]all allowedYes โ€” server blocklist strips disallowed headers
ingredients[].idstringrequiredNo โ€” must match a registered @BffIngredient name
ingredients[].paramsobjectnullNo
ingredients[].bodyanynullNo
ingredients[].mapobjectnullNo โ€” but referenced fields are validated
ingredients[].dependsOnstring[]inferredNo โ€” merged with inferred deps from map
ingredients[].headers.customobjectnullYes โ€” server custom.enabled, custom.blocked, and custom.allowed filter these
ingredients[].headers.mappingsobjectnullYes โ€” server mapping.enabled and blocked-sources filter these

Expressions & Mapping

Values in the map block are either a reference (contains ::) or a literal (no ::).

ExpressionMeaning
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 / trueLiteral โ€” passed through as-is
map block โ€” all target locationsjson
"map": {
  "path":  { "accountId": "getAccount::body::${accountId}" },
  "query": { "billingGroupId": "getAccount::body::${billingGroupId}", "limit": 20 },
  "body":  { "invoiceId": "getInvoices::body::${items[0].id}", "note": "auto-pay" }
}
Known limitation: Expression strings like 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.

The rule: Array operators produce a list. That list is passed as-is to the next ingredient. The library never iterates over it to make N separate calls.
Proceed with care. Array operators are powerful โ€” and easy to misuse. Before building complex filter chains, make sure you understand API anti-patterns that an aggregation layer can accidentally enable. The map block is for wiring, not for replacing proper API design.

Operator Reference

CategorySyntaxExampleResult
Collect[*]items[*].idAll values โ†’ ["inv-1", "inv-2", ...]
Index[n]items[0].idSingle element by position
Index[-n]items[-1].idSingle element from end
Slice[start:end]items[0:5].idRange (exclusive end)
Slice[start:]items[5:].idFrom index to end
Slice[:end]items[:3].idFirst N
Slice[-n:]items[-3:].idLast N
Equality[?field==value]items[?status==OVERDUE].idFilter by equality
Equality[?field!=value]items[?status!=PAID].idFilter by inequality
Comparison[?field>value]items[?amount>100].idGreater than
Comparison[?field>=value]items[?amount>=100].idGreater or equal
Comparison[?field<value]items[?amount<50].idLess than
Comparison[?field<=value]items[?amount<=50].idLess or equal
Set[?field in (a,b)]items[?status in (OVERDUE,UNPAID)].idSet membership
Regex[?field==REG(...)]items[?name==REG(^Pro.*)].idRegex match
Existence[?field exists]items[?couponCode exists].idField present and not null
Existence[?field missing]items[?deletedAt missing].idField absent or null
Boolean[?field==true]items[?active==true].idBoolean match
Null[?field==null]items[?deletedAt==null].idNull check
AND[?a==1&&b==2]items[?status==OVERDUE&&amount>100].idBoth conditions
OR[?a==1||b==2]items[?status==OVERDUE||status==UNPAID].idEither condition
Nested[?a.b==value]items[?billing.region==US].idDot notation in filter

Type Coercion

Filter values are auto-detected:

ValueDetected As
true / falseBoolean
nullNull
42, 3.14, -10Number (compared as Double)
REG(^Pro.*)Compiled regex pattern
in (a,b,c)Set of auto-coerced values
Everything elseString

Edge Cases

ScenarioBehavior
Path before bracket is not an arrayReturns null
Filter/slice produces empty resultReturns [] โ€” the batch endpoint receives an empty list
Filter field missing on some elementsThose elements are skipped (treated as non-match)
Slice out of boundsClamped to array bounds (no error)
Negative index out of boundsReturns null
Constraints: One bracket operator per path segment. No nested wildcards (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.

POST /bff/orders โ€” one request, three systems, two subsetsjson
{
  "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:

what happenstext
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

collect all IDsjson
"invoiceIds": "getInvoices::body::${items[*].id}"
only overdue, amount > $100json
"invoiceIds": "getInvoices::body::${items[?status==OVERDUE&&amount>100].id}"
set membershipjson
"invoiceIds": "getInvoices::body::${items[?status in (OVERDUE,UNPAID)].id}"
regex match on descriptionjson
"descriptions": "getInvoices::body::${items[?description==REG(^Pro Plan.*)].description}"
first 10 items, last 5 itemsjson
"firstPage": "getOrders::body::${orders[:10].orderId}"
"recentFive": "getOrders::body::${orders[-5:].orderId}"
nested field filter + existence checkjson
"usOrders": "getOrders::body::${orders[?billing.region==US&&couponCode exists].orderId}"

Response Format

HTTP StatusMeaning
200All ingredients succeeded
207Mixed results โ€” check each ingredient's status
400Recipe itself is malformed (unknown ingredient, cycle, etc.)
response shapejson
{
  "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

ScenarioStatusMeaning
API returned an error4xx/5xxIngredient ran, underlying API failed
Dependency failed422Ingredient skipped โ€” input was incomplete
Unknown ingredient ID400Recipe rejected before execution
Circular dependency400Cycle path shown in error message
Too many ingredients400Exceeds 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.

installbash
npm install @bff-recipe/types --save-dev

With Axios

typed BFF calltypescript
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

plain fetchtypescript
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();
Zero runtime cost. 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.

GET /bff/payments/schemajson
{
  "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" }
  }
}
POST /bff/payments/validate โ€” on failurejson
{
  "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.

FeatureRequiresWhat you get
Metricsmicrometer-corebff.recipe.requests, bff.recipe.duration, bff.ingredient.duration
Tracingmicrometer-tracing or OTelParent span per recipe, child span per ingredient with recipe/status attributes
Healthspring-boot-actuatorGET /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.

try it โ€” full payments recipebash
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.

Instead: One recipe per screen or user flow. If two screens share 3 ingredients, that's fine โ€” put those ingredients in both recipes via @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.

Instead: Aim for wide DAGs (many ingredients at the same level running in parallel), not deep chains. If you have a 5-level chain, consider whether some of those APIs should be merged or whether a batch endpoint would collapse the chain.

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.

Instead: Array operators are for subsetting โ€” picking which items to send to a batch endpoint. If you need to transform, aggregate, or compute derived values, that logic belongs in a controller method.

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.

Instead: If you find yourself wanting N+1, that's a signal to build a batch API. The library intentionally doesn't support iteration โ€” this is the nudge.

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.

Instead: Keep 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.

Instead: Use the custom header system to pass 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.

Instead: Each ingredient should return everything the client needs from that resource. The recipe layer moves data between ingredients for wiring, not for assembly.

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.

Instead: Long-running operations should return 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."

Ask your team: Are we assembling data to power an interactive UI, or are we assembling data to template into HTML? If it's the latter, look at SSR frameworks that render HTML server-side and send it ready to display. No JSON parsing, no client-side assembly, no recipe complexity. bff-recipe solves the aggregation problem, not the rendering problem.

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 bffRestClient bean 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