Liking cljdoc? Tell your friends :D

Extensions

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.

ExtensionWhen it runsReferenced in spec asConfig keyJava interface
context enricherFirst, once per request(implicit, ordered chain):enrichersio.github.rthadani.bff.BffContextEnricher
validatorAfter enrichment, before the chainvalidator::validatorsio.github.rthadani.bff.BffValidator
transformerAfter output mappingtransformer::transformersio.github.rthadani.bff.BffTransformer
resolverInstead of the backend chainresolver::resolversio.github.rthadani.bff.BffResolver
cache backendAround every cacheable step(single instance):cacheio.github.rthadani.bff.CacheStore
HTTP clientEvery spec url: step(single instance):http-clientio.github.rthadani.bff.BffHttpClient
retry hookBetween step attempts (when retry fires)inside a step's retry.before_retry:retry-hooksio.github.rthadani.bff.BffRetryHook
custom scalarEvery input coerce / output serializetop-level scalars: block:scalarsio.github.rthadani.bff.BffScalar

For Java, each extension point that ships with a base class has two options:

  1. Implement the interface directly. Args and ctx arrive as Map<String, Object>. Works well with Spring's @Component.
  2. Extend the convenience base class. Gives you typed helpers (ResolverResult, StepResult).

The extensions map

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.


Context enricher

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.

Protocol

(defprotocol BffContextEnricher
  (enrich [this ctx]))

Clojure

(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]})

Java — interface

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

Validator

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.

Protocol

(defprotocol BffValidator
  (validate [this args ctx]))

Return nil or [] to pass. Return [{:message "..."}] to fail.

Clojure — ns/fn

(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

Clojure — by key

(defn check-order [args _ctx] ...)

(bff/create-handler "spec.yaml"
  {:validators {"check-order" check-order}})
validator:
  key: check-order

Java — interface

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();
    }
}

Java — base class

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()).


Transformer

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.

Protocol

(defprotocol BffTransformer
  (transform [this args chain-ctx mapped]))

Clojure — ns/fn

(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

Clojure — by key

(bff/create-handler "spec.yaml"
  {:transformers {"attach-warnings" attach-warnings}})
transformer:
  key: attach-warnings

Java — interface

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;
    }
}

Java — base class

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()).


Resolver

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.

Protocol

(defprotocol BffResolver
  (resolve-endpoint [this args ctx]))

Return {:data {...} :errors [...]}.

Clojure — ns/fn

(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

Clojure — by key

(bff/create-handler "spec.yaml"
  {:resolvers {"user-profile" user-profile}})
resolver:
  key: user-profile

Java — interface

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());
    }
}

Java — base class

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)).


Cache backend

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.

Protocol

(defprotocol CacheStore
  (cache-get        [this key])
  (cache-put        [this key value ttl-ms])
  (cache-invalidate [this key]))

Clojure

(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})

Java — interface

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)).


HTTP client

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.

Clojure

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})

Java — interface

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.

Configuring the built-in hato client

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.


Retry hook

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.

Protocol

(defprotocol BffRetryHook
  (before-retry [this failure-context]))

The failure-context map contains:

KeyValue
:step-idKeyword id of the step that just failed
:attempt1-indexed retry number about to happen
:argsGraphQL input arguments
:chain-ctxResults of steps completed so far
:request-ctxCurrent request context (headers, remote-addr)
:errorThe {:code :message :detail} error map

Return a new request-ctx map to use for the retry, or nil to reuse the current one.

Clojure — ns/fn

(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

Clojure — by key

(bff/create-handler "spec.yaml"
  {:retry-hooks {"auth-token-refresh" auth-token-refresh}})
retry:
  before_retry:
    key: auth-token-refresh

Java — interface

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)).


Custom scalar

Declare a scalar type in the spec's top-level scalars: block, then supply a {parse, serialize} implementation in the handler config under :scalars.

  • parse — client input (usually a string from JSON) -> internal value. Throw on invalid input. Lacinia surfaces the exception as a coercion error.
  • serialize — internal value -> JSON primitive (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.

Protocol

(defprotocol BffScalar
  (parse     [this value])
  (serialize [this value]))

Clojure — map form

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}})

Built-in scalars

bff.scalar ships four convenience scalars — drop them straight into the config:

VarGraphQL type name (by convention)Backed by
bff.scalar/date-timeDateTimejava.time.Instant
bff.scalar/dateDatejava.time.LocalDate
bff.scalar/local-date-timeLocalDateTimejava.time.LocalDateTime
bff.scalar/uuidUuidjava.util.UUID
(require '[bff.scalar :as scalar])

(bff/create-handler "spec.yaml"
  {:scalars {"DateTime" scalar/date-time
             "Date"     scalar/date
             "Uuid"     scalar/uuid}})

Java — interface

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 & rthadani
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close