SuperAPI AI Directory

bff-spring

Agents & models

View repository

Updated 10-16 09:10

About this skill

BFF Spring Boot (apigen-bff)

Configuration

apigen:
  bff:
    enabled: true

    client-detection:
      header: X-Client-Type
      fallback: WEB
rate-limits:
  web:
    requests-per-minute: 1000
  mobile:
    requests-per-minute: 500
  iot:
    requests-per-minute: 100

response-tailoring:
  enabled: true
  compress-mobile: true

composition:
  timeout: 5s
  parallel-enabled: true

## Client Type Detection

```java
public enum ClientType {
    WEB("web"),
    MOBILE_IOS("ios"),
    MOBILE_ANDROID("android"),
    TABLET("tablet"),
    TV("tv"),
    IOT("iot"),
    PARTNER("partner");

    public boolean isMobile() {
        return this == MOBILE_IOS || this == MOBILE_ANDROID;
    }

    public boolean requiresCompression() {
        return isMobile() || this == IOT;
    }
}

Annotations

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BffEndpoint {
    ClientType[] clients() default {};
    String[] include() default {};
    boolean aggregate() default false;
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TailorForClient {
    ClientType value();
    String[] fields() default {};
    boolean compress() default false;
}

Base BFF Controller

@RestController
@RequestMapping("/bff")
public abstract class BaseBffController {

    protected final QueryCompositionService compositionService;
    protected final ResponseTailoringService tailoringService;
    protected final CombinedRateLimitService rateLimitService;

    protected ClientType detectClient(HttpServletRequest request) {
        String clientHeader = request.getHeader("X-Client-Type");
        if (clientHeader != null) {
            return ClientType.fromValue(clientHeader);
        }

        String userAgent = request.getHeader("User-Agent");
        return ClientTypeDetector.fromUserAgent(userAgent);
    }

    protected <T> T tailorResponse(T response, ClientType clientType) {
        return tailoringService.tailor(response, clientType);
    }
}

Query Composition Service

@Service
public class QueryCompositionServiceImpl implements QueryCompositionService {

    private final List<ServiceRequest> serviceRequests;
    private final ExecutorService executor;

    @Override
    public AggregatedResponse compose(List<String> includes,
                                       Map<String, Object> params) {

        List<CompletableFuture<ServiceResult>> futures = includes.stream()
            .map(include -> findServiceRequest(include))
            .map(sr -> CompletableFuture.supplyAsync(
                () -> executeService(sr, params), executor)
                .exceptionally(this::handleFailure))
            .toList();

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .orTimeout(5, TimeUnit.SECONDS)
            .join();

        return aggregateResults(futures);
    }

    private ServiceResult handleFailure(Throwable ex) {
        log.warn("Service call failed: {}", ex.getMessage());
        return ServiceResult.failure(ex.getMessage());
    }
}

Response Tailoring Service

@Service
public class ResponseTailoringServiceImpl implements ResponseTailoringService {

    private final ObjectMapper objectMapper;
    private final Map<ClientType, Set<String>> fieldMappings;

    @Override
    @SuppressWarnings("unchecked")
    public <T> T tailor(T response, ClientType clientType) {
        if (response == null) return null;

        Set<String> allowedFields = fieldMappings.get(clientType);
        if (allowedFields == null || allowedFields.isEmpty()) {
            return response;
        }

        // Convert to map, filter fields, convert back
        Map<String, Object> map = objectMapper.convertValue(response, Map.class);
        Map<String, Object> filtered = filterFields(map, allowedFields);

        return (T) objectMapper.convertValue(filtered, response.getClass());
    }

    private Map<String, Object> filterFields(Map<String, Object> source,
                                              Set<String> allowed) {
        return source.entrySet().stream()
            .filter(e -> allowed.contains(e.getKey()))
            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }
}

Aggregated Response Model

public record AggregatedResponse(
    Map<String, Object> data,
    Map<String, ErrorInfo> errors,
    ResponseMetadata metadata
) {
    public boolean hasErrors() {
        return errors != null && !errors.isEmpty();
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private final Map<String, Object> data = new HashMap<>();
        private final Map<String, ErrorInfo> errors = new HashMap<>();

        public Builder addData(String key, Object value) {
            data.put(key, value);
            return this;
        }

        public Builder addError(String key, String message) {
            errors.put(key, new ErrorInfo(message, null));
            return this;
        }

        public AggregatedResponse build() {
            return new AggregatedResponse(data, errors,
                new ResponseMetadata(Instant.now()));
        }
    }
}

BFF Endpoint Aspect

@Aspect
@Component
public class BffEndpointAspect {

    private final CombinedRateLimitService rateLimitService;

    @Around("@annotation(bffEndpoint)")
    public Object handleBffEndpoint(ProceedingJoinPoint joinPoint,
                                     BffEndpoint bffEndpoint) throws Throwable {

        HttpServletRequest request = getCurrentRequest();
        ClientType clientType = detectClient(request);

        // Check client type restriction
        if (bffEndpoint.clients().length > 0 &&
            !Arrays.asList(bffEndpoint.clients()).contains(clientType)) {
            throw new ClientTypeNotAllowedException(clientType);
        }

        // Apply rate limiting
        rateLimitService.checkLimit(clientType, request);

        return joinPoint.proceed();
    }
}

Example BFF Controller

@RestController
@RequestMapping("/bff/v1")
public class DashboardBffController extends BaseBffController {

    private final UserService userService;
    private final OrderService orderService;
    private final NotificationService notificationService;

    @GetMapping("/dashboard")
    @BffEndpoint(aggregate = true)
    public AggregatedResponse getDashboard(
            @RequestParam(defaultValue = "profile,notifications") String include,
            HttpServletRequest request) {

        ClientType client = detectClient(request);
        List<String> includes = Arrays.asList(include.split(","));

        AggregatedResponse.Builder builder = AggregatedResponse.builder();

        if (includes.contains("profile")) {
            UserDTO profile = userService.getCurrentUser();
            builder.addData("profile", tailorResponse(profile, client));
        }

        if (includes.contains("notifications")) {
            int limit = client.isMobile() ? 5 : 20;
            List<NotificationDTO> notifications =
                notificationService.getRecent(limit);
            builder.addData("notifications", notifications);
        }

        if (includes.contains("orders")) {
            List<OrderDTO> orders = orderService.getRecentOrders();
            builder.addData("orders", tailorResponse(orders, client));
        }

        return builder.build();
    }

    @GetMapping("/dashboard")
    @TailorForClient(value = ClientType.MOBILE_IOS,
                     fields = {"id", "name", "avatarThumb"})
    public MobileDashboardResponse getMobileDashboard() {
        // iOS-specific optimized response
    }
}

Rate Limiting Service

@Service
public class CombinedRateLimitServiceImpl implements CombinedRateLimitService {

    private final Map<ClientType, Bucket> buckets = new ConcurrentHashMap<>();
    private final BffProperties properties;

    @Override
    public void checkLimit(ClientType clientType, HttpServletRequest request) {
        Bucket bucket = buckets.computeIfAbsent(clientType, this::createBucket);

        if (!bucket.tryConsume(1)) {
            throw new RateLimitExceededException(
                "Rate limit exceeded for client type: " + clientType);
        }
    }

    private Bucket createBucket(ClientType clientType) {
        int rpm = properties.getRateLimits()
            .getOrDefault(clientType, 100);

        return Bucket.builder()
            .addLimit(Bandwidth.simple(rpm, Duration.ofMinutes(1)))
            .build();
    }
}

Testing

@WebMvcTest(DashboardBffController.class)
class DashboardBffControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void shouldTailorResponseForMobile() throws Exception {
        when(userService.getCurrentUser()).thenReturn(fullUserDto());

        mockMvc.perform(get("/bff/v1/dashboard")
                .header("X-Client-Type", "ios"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.profile.email").doesNotExist())
            .andExpect(jsonPath("$.data.profile.id").exists());
    }
}

Related Skills

  • bff-concepts: BFF pattern concepts
  • spring-boot-4: Spring Boot 4.0 patterns