Extensions are passed to bff.core/create-handler (or Bff.createHandler / Bff.createServlet in Java) as a config map at startup. Each extension point has a Clojure protocol and a corresponding Java interface under io.github.rthadani.bff.*. Some points also have a convenience base class.
| Extension | When it runs | Referenced in spec as | Config key | Java interface |
|---|---|---|---|---|
| context enricher | First, once per request | (implicit, ordered chain) | :enrichers | io.github.rthadani.bff.BffContextEnricher |
validator | After enrichment, before the chain | validator: | :validators | io.github.rthadani.bff.BffValidator |
transformer | After output mapping | transformer: | :transformers | io.github.rthadani.bff.BffTransformer |
resolver | Instead of the backend chain | resolver: | :resolvers | io.github.rthadani.bff.BffResolver |
| cache backend | Around every cacheable step | (single instance) | :cache | io.github.rthadani.bff.CacheStore |
| HTTP client | Every spec url: step | (single instance) | :http-client | io.github.rthadani.bff.BffHttpClient |
| retry hook | Between step attempts (when retry fires) | inside a step's retry.before_retry | :retry-hooks | io.github.rthadani.bff.BffRetryHook |
| custom scalar | Every input coerce / output serialize | top-level scalars: block | :scalars | io.github.rthadani.bff.BffScalar |
For Java, each extension point that ships with a base class has two options:
Map<String, Object>. Works well with Spring's @Component.ResolverResult, StepResult).Both bff.core/create-handler and Java's Bff.createHandler accept a single
extensions config. In Clojure it is a plain map:
(bff/create-handler
"bff-spec.yaml"
{:enrichers [customer-enricher tenant-enricher] ; ordered seq
:validators {"check-order" order-validator} ; keyed
:transformers {"attach-warnings" warnings-transformer}
:resolvers {"user-profile" user-profile-resolver}
:retry-hooks {"auth-token-refresh" token-refresh-hook}
:cache redis-cache-store
:http-client my-http-client})
In Java, use the BffConfig.Builder:
BffConfig config = BffConfig.builder()
.enricher(customerEnricher)
.enricher(tenantEnricher)
.validator("check-order", orderValidator)
.transformer("attach-warnings", warningsTransformer)
.resolver("user-profile", userProfileResolver)
.retryHook("auth-token-refresh", tokenRefreshHook)
.cache(redisCacheStore)
.httpClient(myHttpClient)
.build();
All keys are optional. Extensions are fixed at startup.
Runs once per GraphQL operation, before validators and the backend chain. Use it to pre-compute values into ctx that downstream steps can read, for example fetching a customer ID from Redis using the JWT subject.
Enrichers run in the order they appear in :enrichers. Each one sees the ctx accumulated by earlier enrichers. Return nil to leave ctx unchanged.
(defprotocol BffContextEnricher
(enrich [this ctx]))
(defn customer-enricher
[{:keys [authorization]}]
(let [subject (jwt/subject authorization)
customer-id (redis/hget (str "user:" subject) "customerId")]
{:customerId customer-id}))
Register by adding it to :enrichers in your config:
(bff/create-handler "spec.yaml" {:enrichers [customer-enricher]})
import io.github.rthadani.bff.BffContextEnricher;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Map;
public class CustomerEnricher implements BffContextEnricher {
private final StringRedisTemplate redis;
public CustomerEnricher(StringRedisTemplate redis) { this.redis = redis; }
@Override
public Map<String, Object> enrich(Map<String, Object> ctx) {
String subject = JwtUtil.subject((String) ctx.get("authorization"));
Object cust = redis.opsForHash().get("user:" + subject, "customerId");
return cust == null ? null : Map.of("customerId", cust.toString());
}
}
Register on the builder: .enricher(new CustomerEnricher(redis)).
Downstream, an endpoint or step can read the enriched value via a ctx
source mapping:
input_mapping:
customerId:
source: ctx
key: customerId
Runs before any backend call. Return errors to short-circuit; the chain is never touched. Both built-in arg rules and a custom validator can be declared on the same endpoint — built-in rules run first, then the custom validator.
(defprotocol BffValidator
(validate [this args ctx]))
Return nil or [] to pass. Return [{:message "..."}] to fail.
(ns my.project.validators)
(defn check-order
[args _ctx]
(when (and (= "GBP" (:currency args))
(> (:amount args) 10000))
[{:message "GBP orders above 10000 require manual approval"}]))
validator:
ns: my.project.validators
fn: check-order
(defn check-order [args _ctx] ...)
(bff/create-handler "spec.yaml"
{:validators {"check-order" check-order}})
validator:
key: check-order
import io.github.rthadani.bff.BffValidator;
import java.util.List;
import java.util.Map;
public class OrderValidator implements BffValidator {
@Override
public List<Map<String, Object>> validate(Map<String, Object> args, Map<String, Object> ctx) {
if ("GBP".equals(args.get("currency"))
&& ((Number) args.get("amount")).doubleValue() > 10000) {
return List.of(Map.of("message", "GBP orders above 10000 require manual approval"));
}
return List.of();
}
}
Extend BaseValidator when you'd rather return a List<String> of messages
and let the base class wrap them into the expected error shape.
import bff.validator.BaseValidator;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class OrderValidator extends BaseValidator {
@Override
protected List<String> doValidate(Map<String, Object> args, Map<String, Object> ctx) {
List<String> errors = new ArrayList<>();
Double amount = (Double) args.get("amount");
String currency = (String) args.get("currency");
if (amount != null && amount > 10000 && "GBP".equals(currency)) {
errors.add("GBP orders above 10000 require manual approval");
}
return errors;
}
}
Register on the builder: .validator("check-order", new OrderValidator()).
Runs after jq output mappings are applied. Receives the GraphQL args, the full step result map, and the already-mapped output. Returns the final output map.
(defprotocol BffTransformer
(transform [this args chain-ctx mapped]))
(ns my.project.transformers.orders)
(defn attach-warnings
[_args chain-ctx output]
(assoc output :warnings
(cond-> []
(= :error (get-in chain-ctx [:notify_user :status]))
(conj "Notification could not be sent"))))
transformer:
ns: my.project.transformers.orders
fn: attach-warnings
(bff/create-handler "spec.yaml"
{:transformers {"attach-warnings" attach-warnings}})
transformer:
key: attach-warnings
chainCtx is a Map<String, Object> where each value is itself a
Map<String, Object> — the raw step result with a "status" key
("ok" or "error"), a "data" key when successful, and an "error"
sub-map when failed.
import io.github.rthadani.bff.BffTransformer;
import java.util.Map;
public class AttachWarningsTransformer implements BffTransformer {
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> transform(Map<String, Object> args,
Map<String, Object> chainCtx,
Map<String, Object> output) {
Map<String, Object> notify = (Map<String, Object>) chainCtx.get("notify_user");
if ("error".equals(notify.get("status"))) {
output.put("warning", "Notification could not be sent");
}
return output;
}
}
BaseTransformer unpacks chainCtx into Map<String, StepResult> for you.
StepResult has isOk(), isError(), getData(), and getMessage().
import bff.executor.BaseTransformer;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class AttachWarningsTransformer extends BaseTransformer {
@Override
protected Map<String, Object> doTransform(
Map<String, Object> args,
Map<String, StepResult> chainCtx,
Map<String, Object> output) {
List<String> warnings = new ArrayList<>();
if (chainCtx.get("notify_user").isError()) {
warnings.add("Notification could not be sent");
}
output.put("warnings", warnings);
return output;
}
}
Register on the builder: .transformer("attach-warnings", new AttachWarningsTransformer()).
Replaces the backend chain entirely. Use it for endpoints that don't fit the HTTP fan-out model, like database calls or cache lookups. No output mapping or transformer runs after it. The resolver owns the full {:data :errors} response.
(defprotocol BffResolver
(resolve-endpoint [this args ctx]))
Return {:data {...} :errors [...]}.
(ns my.project.resolvers)
(defn user-profile [args _ctx]
(let [user (db/find-user (:userId args))]
{:data {:fullName (:name user) :email (:email user)}
:errors []}))
resolver:
ns: my.project.resolvers
fn: user-profile
(bff/create-handler "spec.yaml"
{:resolvers {"user-profile" user-profile}})
resolver:
key: user-profile
Return a Map<String, Object> with "data" and "errors" keys. Keys inside
data become the endpoint's output_type fields.
import io.github.rthadani.bff.BffResolver;
import java.util.List;
import java.util.Map;
public class UserProfileResolver implements BffResolver {
private final UserRepository repo;
public UserProfileResolver(UserRepository repo) { this.repo = repo; }
@Override
public Map<String, Object> resolve(Map<String, Object> args, Map<String, Object> ctx) {
String userId = (String) args.get("userId");
User user = repo.findById(userId);
if (user == null) {
return Map.of("data", Map.of(),
"errors", List.of(Map.of("message", "User not found: " + userId)));
}
return Map.of("data", Map.of("fullName", user.getName(), "email", user.getEmail()),
"errors", List.of());
}
}
BaseResolver builds the response for you via ResolverResult.
import bff.executor.BaseResolver;
import java.util.Map;
public class UserProfileResolver extends BaseResolver {
private final UserRepository repo;
public UserProfileResolver(UserRepository repo) { this.repo = repo; }
@Override
protected ResolverResult doResolve(Map<String, Object> args, Map<String, Object> ctx) {
String userId = (String) args.get("userId");
User user = repo.findById(userId);
if (user == null) {
return ResolverResult.error("User not found: " + userId, "USER_NOT_FOUND");
}
return ResolverResult.ok(Map.of(
"fullName", user.getName(),
"email", user.getEmail()
));
}
}
ResolverResult.withError lets you return partial data alongside errors, and
either factory takes an optional machine-readable code that surfaces under
extensions.code:
return ResolverResult.ok(Map.of("fullName", user.getName()))
.withError("email service unavailable", "EMAIL_UNAVAILABLE");
Clojure resolvers surface the same shape by returning {:message "..." :code "..."}
in :errors — the engine lifts a top-level :code into :extensions.code
automatically.
Register on the builder: .resolver("user-profile", new UserProfileResolver(repo)).
A single instance registered on the handler. Every step that declares cache: {key: "..."} routes reads and writes through it. Cache failures are logged and swallowed. They never propagate to the GraphQL response.
(defprotocol CacheStore
(cache-get [this key])
(cache-put [this key value ttl-ms])
(cache-invalidate [this key]))
(def store
(reify bff.cache/CacheStore
(cache-get [_ k] (get @cache-atom k))
(cache-put [_ k v _ttl] (swap! cache-atom assoc k v))
(cache-invalidate [_ k] (swap! cache-atom dissoc k))))
(bff/create-handler "spec.yaml" {:cache store})
import io.github.rthadani.bff.CacheStore;
import org.springframework.data.redis.core.StringRedisTemplate;
public class RedisCacheStore implements CacheStore {
private final StringRedisTemplate redis;
public RedisCacheStore(StringRedisTemplate redis) { this.redis = redis; }
@Override
public Object get(String key) {
return redis.opsForValue().get(key);
}
@Override
public void put(String key, Object value, long ttlMs) {
redis.opsForValue().set(key, value.toString(), java.time.Duration.ofMillis(ttlMs));
}
@Override
public void invalidate(String key) {
redis.delete(key);
}
}
Register on the builder: .cache(new RedisCacheStore(redis)).
A single instance registered on the handler. Every spec step with a url:
field routes through it. Omit it and BFF uses a built-in hato client.
Given a Request (method, url, params, body, headers, step id), return a
Response with the raw status and body. BFF maps status codes to its error
codes (401 → :unauthorized, and so on), so return the real status even
for non-2xx responses. Exceptions are caught and turned into :unexpected.
Any 1-arity fn. Return {:status int :body str} and BFF maps status codes
for you. Or return a tagged {:status :ok :data ...} / {:status :error :error ...} map and it's passed through as-is.
(require '[hato.client :as hato])
(defn my-http-client
[{:keys [method url params body headers]}]
(let [resp (hato/request {:method method
:url url
:query-params params
:form-params body
:headers headers
:as :string
:throw-exceptions? false})]
{:status (:status resp) :body (:body resp)}))
(bff/create-handler "spec.yaml" {:http-client my-http-client})
import io.github.rthadani.bff.BffHttpClient;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.HttpStatusCodeException;
public class SpringRestBffClient implements BffHttpClient {
private final MyRestClient rest;
public SpringRestBffClient(MyRestClient rest) { this.rest = rest; }
@Override
public Response send(Request req) {
HttpHeaders headers = new HttpHeaders();
req.headers.forEach(headers::add);
try {
ResponseEntity<String> resp = switch (req.method) {
case "GET" -> rest.get(req.url, headers, req.queryParams);
case "POST" -> rest.post(req.url, headers, req.body, req.queryParams);
case "PUT" -> rest.put(req.url, headers, req.body, req.queryParams);
case "PATCH" -> rest.patch(req.url, headers, req.body, req.queryParams);
case "DELETE" -> rest.delete(req.url, headers, req.body, req.queryParams);
default -> throw new IllegalArgumentException("Unsupported: " + req.method);
};
return new Response(resp.getStatusCode().value(), resp.getBody());
} catch (HttpStatusCodeException ex) {
return new Response(ex.getStatusCode().value(), ex.getResponseBodyAsString());
}
}
}
Register on the builder: .httpClient(new SpringRestBffClient(myRest)).
Retries, cache reads/writes, and compensations all go through the injected client — no bypass path in the executor.
For the common case — keep the built-in client, just tweak it (SSL context,
timeouts) — use hatoOptions instead of implementing BffHttpClient. Keys
are hato build-http-client option names; they are converted to keywords
internally. Ignored when a custom httpClient is set.
KeyStore ts = KeyStore.getInstance("JKS");
try (FileInputStream in = new FileInputStream("/path/to/portal-trust.jks")) {
ts.load(in, "changeit".toCharArray());
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ts);
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, tmf.getTrustManagers(), null);
BffConfig config = BffConfig.builder()
.hatoOptions(Map.of(
"ssl-context", ctx,
"connect-timeout", 10_000L))
.build();
Clojure equivalent:
(bff/create-handler "spec.yaml"
{:hato-options {:ssl-context ctx :connect-timeout 10000}})
Clients are cached per options map, so calling handlers built with the same
options reuses the same underlying HttpClient.
Runs between attempts when a step declares before_retry. Use it to rewrite the request context, typically to inject a refreshed auth header. If no hook is declared, retries reuse the current ctx.
The step spec:
- id: fetch_customer
url: "{service_base}/portal/customers/{id}"
method: GET
retry:
max: 2
on_code: [unauthorized]
before_retry:
key: auth-token-refresh
See spec.md for the list of codes accepted
by on_code.
(defprotocol BffRetryHook
(before-retry [this failure-context]))
The failure-context map contains:
| Key | Value |
|---|---|
:step-id | Keyword id of the step that just failed |
:attempt | 1-indexed retry number about to happen |
:args | GraphQL input arguments |
:chain-ctx | Results of steps completed so far |
:request-ctx | Current request context (headers, remote-addr) |
:error | The {:code :message :detail} error map |
Return a new request-ctx map to use for the retry, or nil to reuse the
current one.
(ns my.project.retry)
(defn auth-token-refresh
[{:keys [request-ctx]}]
(let [fresh-token (auth-client/refresh-service-token!)]
(assoc request-ctx :authorization (str "Bearer " fresh-token))))
retry:
before_retry:
ns: my.project.retry
fn: auth-token-refresh
(bff/create-handler "spec.yaml"
{:retry-hooks {"auth-token-refresh" auth-token-refresh}})
retry:
before_retry:
key: auth-token-refresh
import io.github.rthadani.bff.BffRetryHook;
import java.util.HashMap;
import java.util.Map;
public class TokenRefreshHook implements BffRetryHook {
private final AuthTokenService tokens;
public TokenRefreshHook(AuthTokenService tokens) { this.tokens = tokens; }
@Override
public Map<String, Object> beforeRetry(Map<String, Object> failureContext) {
@SuppressWarnings("unchecked")
Map<String, Object> requestCtx = (Map<String, Object>) failureContext.get("request-ctx");
Map<String, Object> next = new HashMap<>(requestCtx);
next.put("authorization", "Bearer " + tokens.refresh());
return next;
}
}
Register on the builder: .retryHook("auth-token-refresh", new TokenRefreshHook(tokens)).
Declare a scalar type in the spec's top-level scalars: block, then supply a {parse, serialize} implementation in the handler config under :scalars.
String / Number / Boolean / nil).scalars:
- name: DateTime
description: "ISO-8601 timestamp"
- name: Mac
description: "MAC address in colon-hex form"
If a spec declares a scalar with no matching entry in :scalars, the handler
fails fast at create-handler time with ex-info naming the missing scalar.
(defprotocol BffScalar
(parse [this value])
(serialize [this value]))
A plain map with :parse and :serialize keys satisfies the protocol:
(def mac-scalar
{:parse (fn [v]
(let [s (str/lower-case (str v))]
(or (re-matches #"^([0-9a-f]{2}:){5}[0-9a-f]{2}$" s)
(throw (ex-info (str "Not a MAC address: " v) {:value v})))
s))
:serialize identity})
(bff/create-handler "spec.yaml" {:scalars {"Mac" mac-scalar}})
bff.scalar ships four convenience scalars — drop them straight into the
config:
| Var | GraphQL type name (by convention) | Backed by |
|---|---|---|
bff.scalar/date-time | DateTime | java.time.Instant |
bff.scalar/date | Date | java.time.LocalDate |
bff.scalar/local-date-time | LocalDateTime | java.time.LocalDateTime |
bff.scalar/uuid | Uuid | java.util.UUID |
(require '[bff.scalar :as scalar])
(bff/create-handler "spec.yaml"
{:scalars {"DateTime" scalar/date-time
"Date" scalar/date
"Uuid" scalar/uuid}})
import io.github.rthadani.bff.BffScalar;
public class MacScalar implements BffScalar {
private static final java.util.regex.Pattern MAC =
java.util.regex.Pattern.compile("^([0-9a-f]{2}:){5}[0-9a-f]{2}$",
java.util.regex.Pattern.CASE_INSENSITIVE);
@Override public Object parse(Object value) {
String s = value.toString().toLowerCase();
if (!MAC.matcher(s).matches()) {
throw new IllegalArgumentException("Not a MAC address: " + value);
}
return s;
}
@Override public Object serialize(Object value) {
return value.toString();
}
}
Register on the builder: .scalar("Mac", new MacScalar()).
Can you improve this documentation? These fine people already did:
Rohit Thadani & rthadaniEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |