Spring Boot API Gateway with Centralized Exception Handling and Unified Response Structure

Mayank GuptaMayank Gupta
12 min read

In this article we will be exploring how to use api gateway in our spring project and also how can we have centralized exception handling along with unified response structure. So without wasting time let’s get started.

Now what i mean by unifies response structure is this.

i.e service is not obliged to return data with other fields because that service may also be sending the response to other service instead of client directly hence we will add a transformation filter globally in the api gateway itself.

Other thing is the Exception handler

Now let’s see how this can be implemented , starting with setting up the ApiService

Creating a project

Start with creating the project wither use spring initializer (start.spring.io) or you do it manually (if you love pain) it’s up to you but ensure these dependencies in you pom.xml file

   <dependencies>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>

        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

the `spring-cloud-starter-gateway-server-webflux` is the core dependency of the spring cloud gateway and the spring-cloud-starter-netflix-eureka-client is dependency for creating eureka client.

Now after the project is created and all dependencies are loaded mandatorily annotate the main entry point (i.e class Annotated with @SpringBootApplication) with the annotation @EnableDiscoveryClient

And it is obvious that you must have eureka server running. with this url http://localhost:8761/eureka

Now we must configure the our ApiGateway we can do it through application.yaml file

eureka:
  instance:
    prefer-ip-address: true
  client:
    fetch-registry: true
    serviceUrl:
      defaultZone: "${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://localhost:8761/eureka}"
    register-with-eureka: true
spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      server:
        webflux:
          discovery:
            locator:
              enabled: true
          routes:
            - id: user-service
              uri: lb://user-service
              filters:
                - AuthenticationFilter
                - ApiResponseTransformFilter
                - RewritePath=/api/v1/user/(?<segment>.*), /v1/$\{segment}
              predicates:
                - Path=/api/v1/user/**
server:
  port: 8005

This configuration sets up a Spring Cloud Gateway application integrated with Eureka Service Discovery. It defines how the gateway registers with Eureka, discovers services, routes requests, and applies filters for authentication and response transformation.


🔹 Eureka Configuration

eureka:
  instance:
    prefer-ip-address: true
  • prefer-ip-address: true: Configures the service to register its IP address instead of the hostname with Eureka. This is useful in environments where DNS resolution may be unreliable or unavailable (e.g., Docker or local development networks).
  client:
    fetch-registry: true
  • fetch-registry: true: Enables the gateway to fetch the list of registered services from the Eureka server. This is essential for enabling service discovery and client-side load balancing.
    serviceUrl:
      defaultZone: "${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://localhost:8761/eureka}"
  • defaultZone: Specifies the Eureka server endpoint. It uses a placeholder to support external configuration via environment variables. If not set, it defaults to http://localhost:8761/eureka.
    register-with-eureka: true
  • register-with-eureka: true: Registers the API gateway itself as a service in Eureka. This is optional but helpful if other services need to discover and communicate with the gateway.

🔹 Spring Application Configuration

spring:
  application:
    name: api-gateway
  • name: api-gateway: Sets the logical name of the application. This name will be used when the service registers itself in Eureka.

🔹 Gateway Configuration

  cloud:
    gateway:
      server:
        webflux:
  • This section initiates the WebFlux-based reactive server used by Spring Cloud Gateway.

🔹 Discovery Locator

          discovery:
            locator:
              enabled: true
  • Enables dynamic route registration for services discovered via Eureka. When enabled, routes are automatically created based on service names. For example, if a service named user-service is registered, it can be accessed via /user-service/** without defining explicit routes.

🔹 Custom Route Definition

          routes:
            - id: user-service
  • Defines a custom route with the ID user-service.
              uri: lb://user-service
  • lb://user-service: Routes the request to the logical service name user-service, using client-side load balancing provided by Spring Cloud LoadBalancer.

🔹 Filters

              filters:
                - AuthenticationFilter
                - ApiResponseTransformFilter
  • AuthenticationFilter: A custom filter that handles authentication logic such as verifying JWT tokens or validating request headers.

  • ApiResponseTransformFilter: A custom post-filter that transforms the service response into a standardized structure (e.g., wrapping it in a common response object).

                - RewritePath=/api/v1/user/(?<segment>.*), /v1/${segment}
  • RewritePath: Uses a regular expression to rewrite incoming request paths. For example, /api/v1/user/register becomes /v1/register before it is forwarded to the downstream service.

🔹 Predicates

              predicates:
                - Path=/api/v1/user/**
  • Path: Defines the URL pattern that should match this route. Requests starting with /api/v1/user/ will be forwarded to user-service.

🔹 Server Port

server:
  port: 8005

Creating filters

Now we will require some custom filters as mentioned above AuthenticationFilter for authenticating requests and ApiResponseTransformFilter for transforming the response to send back the unified response.

AuthenticationFilter

package com.lms.api_gateway.config.filters;
import com.lms.api_gateway.config.RouteValidator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

@Component
public class AuthenticationFilter extends AbstractGatewayFilterFactory<AuthenticationFilter.Config> {

    public AuthenticationFilter() {
        super(Config.class);
    }

    @Autowired
    private RouteValidator routeValidator; // used for checking wether endpoint is open or secure
    @Autowired
    RestTemplate restTemplate;
    @Override
    public GatewayFilter apply(Config config) {
        return (((exchange, chain) -> {



                ServerHttpRequest request = null;
                if(routeValidator.isSecured.test(exchange.getRequest())) {

                    if (!exchange.getRequest().getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                        throw new RuntimeException("missing authorization header");
                    }
                    String authHeader = exchange.getRequest().getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
                    System.out.println("Token"+authHeader);

                    if (authHeader != null && authHeader.startsWith("Bearer ")) {
                        authHeader = authHeader.substring(7);
                    }
                    else{
                        throw new RuntimeException("missing authorization header");
                    }
                    try {
//                    REST call to AUTH service

                        HttpHeaders headers = new HttpHeaders();
                        headers.set("Authorization", "Bearer "+authHeader);
                        HttpEntity<String> entity = new HttpEntity<>(headers);
                        ResponseEntity<Map> response = restTemplate.exchange( "http://localhost:8001/api/v1/user/auth/authenticate",
                                HttpMethod.GET,
                                entity,
                                Map.class);

                        String userId = (String) response.getBody().get("id");
                        request= exchange.getRequest().mutate().header("X-USER-ID", userId).build();
                        exchange = exchange.mutate().request(request).build();
                    } catch (Exception e) {
                        e.printStackTrace();
                        System.out.println("invalid access...!");
                        throw new RuntimeException("un-authorized access to application");
                    }
                }
                return chain.filter(exchange);


        }));
    }

    public static class Config {}
}

In this filter what we will be doing is that for each request we first check wether the endpoint is secured or open with the help of RouteValidator

package com.lms.api_gateway.config;

import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.function.Predicate;

@Component
public class RouteValidator {

    public static final List<String> openApiEndpoints = List.of(
            "/api/v1/user/auth/register",
            "/api/v1/user/auth/login",
            "/api/v1/user/token/refresh-token",

            "/eureka"
    );

    public Predicate<ServerHttpRequest> isSecured =
            request -> openApiEndpoints
                    .stream()
                    .noneMatch(uri -> request.getURI().getPath().contains(uri));

}

And then if endpoint is secured sending a verification request to the UserService along with the token (access_token) extracted from headers , then if response is successfull then it means the request is authenticated and request pass the filter.

And I think rest is pretty easy stuff like creating API call with RestTemplate.

ApiResponseTransformFilter

Now coming to our star of the show i.e ApiResponseTransformFilter which will transform the response from downstream into our unified structure

Now first we define a class of that standard structure

package com.lms.api_gateway.config;

import java.util.Map;

public class APIResponse {
    private boolean success;
    private Map<String,Object> data ;
    private String message;
    private String error;
    private int code;
    @Override
    public String toString() {
        return "APIResponse{" +
                "success=" + success +
                ", data=" + data +
                ", message='" + message + '\'' +
                ", error='" + error + '\'' +
                '}';
    }

    public APIResponse() {
    }

    public APIResponse(boolean success, Map<String,Object> data, String message, String error) {
        this.success = success;
        this.data = data;
        this.message = message;
        this.error = error;
    }

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public Map<String,Object> getData() {
        return data;
    }

    public void setData(Map<String,Object> data) {
        this.data = data;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getError() {
        return error;
    }

    public void setError(String error) {
        this.error = error;
    }

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }
}

So this is our APIResponse which we will send to the client

In case of error data will be null and in case of successfull request error will be null.

Now how our ApiResponse transformation will work is we will create a simple Filter class Extending AbstractGatewayFilterFactory and then while filtering the request we will mutate the response body with the help of our custom ServerHttpResponseDecorator

ServerHttpResponseDecorator

ServerHttpResponseDecorator is a class provided by Spring WebFlux that allows developers to intercept, inspect, modify, or wrap the outgoing HTTP response before it's sent to the client.

It acts as a wrapper around the original ServerHttpResponse and is typically used in custom filters in Spring Cloud Gateway to:

  • Transform or modify the response body

  • Change response headers

  • Log or audit the response

  • Standardize API response formats

  • Modify HTTP status codes (with caution)

Creating a ServerHttpResponseDecorator

package com.lms.api_gateway.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lms.api_gateway.utils.JsonUtil;

import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
 * This class decorates the original ServerHttpResponse to intercept and transform the response body.
 * It wraps the downstream service response in a standardized APIResponse structure.
 */
public class DecoratorImpl extends ServerHttpResponseDecorator {

    // Stores the content type of the original response
    MediaType contentType;

    // ObjectMapper for JSON serialization
    private final ObjectMapper objectMapper = new ObjectMapper();

    // Constructor: takes the original ServerHttpResponse as delegate
    public DecoratorImpl(ServerHttpResponse delegate) {
        super(delegate); // Passes the original response to the base decorator

        // Capture content type from original headers
        this.contentType = delegate.getHeaders().getContentType();

        // Default to JSON if content type is null
        if (this.contentType == null) {
            this.contentType = MediaType.APPLICATION_JSON;
        }
    }

    // Helper method to create a standardized success API response
    private APIResponse transform(Map<String, Object> object) {
        APIResponse apiResponse = new APIResponse();
        apiResponse.setData(object);
        apiResponse.setSuccess(true);
        return apiResponse;
    }

    // Buffer factory used to create the final DataBuffer after transformation
    DataBufferFactory bufferFactory = getDelegate().bufferFactory();

    // Override the writeWith method to intercept and transform the response body
    @Override
    public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {

        // Ensure the Content-Type header is set correctly
        getHeaders().setContentType(contentType);

        // Convert body Publisher into Flux for reactive processing
        Flux<? extends DataBuffer> fluxBody = Flux.from(body);

        // Capture the HTTP status code of the response
        HttpStatusCode statusCode = getStatusCode();
        HttpStatus status = HttpStatus.valueOf(statusCode.value());

        // If response is not JSON, skip transformation and write as-is
        if (!contentType.equals(MediaType.APPLICATION_JSON)) {
            return super.writeWith(body);
        }

        // Join all data buffers into one complete buffer for processing
        return DataBufferUtils.join(fluxBody)
                .publishOn(Schedulers.boundedElastic()) // Offload heavy work to boundedElastic thread pool
                .flatMap(dataBuffer -> {
                    // Read bytes from data buffer
                    byte[] content = new byte[dataBuffer.readableByteCount()];
                    dataBuffer.read(content);

                    // Release buffer memory
                    DataBufferUtils.release(dataBuffer);

                    // Convert byte[] to String (assumed to be UTF-8 JSON)
                    String responseBody = new String(content, StandardCharsets.UTF_8);

                    // Convert JSON string into Map
                    Map<String, Object> mapBody = JsonUtil.toMap(responseBody);

                    APIResponse apiResponse;

                    // If it's a 4xx client error, wrap as an error response
                    if (statusCode != null && statusCode.is4xxClientError()) {
                        apiResponse = new APIResponse();
                        apiResponse.setSuccess(false);
                        apiResponse.setCode(statusCode.value());
                        apiResponse.setError(status.getReasonPhrase());
                        apiResponse.setMessage(responseBody); // fallback
                        apiResponse.setMessage(mapBody.getOrDefault("message", "Some error occurred").toString());
                    } else {
                        // Otherwise, wrap as a success response
                        apiResponse = transform(mapBody);
                    }

                    try {
                        // Serialize the APIResponse into byte[]
                        byte[] newContent = objectMapper.writeValueAsBytes(apiResponse);

                        // Wrap the new content in a DataBuffer
                        DataBuffer buffer = bufferFactory.wrap(newContent);

                        // Write the new DataBuffer to the response
                        return super.writeWith(Mono.just(buffer));
                    } catch (JsonProcessingException e) {
                        e.printStackTrace();
                        // If serialization fails, return the error as-is
                        return super.writeWith(Flux.error(e));
                    }
                });
    }
}

Let me explain how this works ,

First of all we get a ServerHttpResponse as a constructor parameter which is the original response sent by the downstream ok .

Then we have the extract the info like ContentType and Status code from the headers.

Then override a method called writeWith
writeWith(...) is the method used to write the response body to the client in a reactive, non-blocking way.

It's part of the ServerHttpResponse (and also ServerHttpResponseDecorator) and is the only way to programmatically write content into the response body when building reactive applications.

🔸Parameters:

  • Publisher<? extends DataBuffer> body:

    • This is the reactive stream (Mono or Flux) of DataBuffer that represent the bytes of the response body.

🔸 Returns:

  • Mono<Void>:

    • A reactive signal indicating completion (or failure) of writing the response

in our method implementation what we are doing is first of all check whether the response was 200 OK or it’s a error (client error) now if it’s a client error then we return different body otherwise different response.

Now for processing we first convert that Publisher to Flux for reactive stream processing and also fetch the DataBufferFactory from the original delegated response to create a new buffer as we can’t directly create the DataBuffer because DataBuffer is an interface whose implementations are internally managed.

  return DataBufferUtils.join(fluxBody)
                .publishOn(Schedulers.boundedElastic()) // Offload heavy work to boundedElastic thread pool
                .flatMap(dataBuffer -> {
                    // Read bytes from data buffer
                    byte[] content = new byte[dataBuffer.readableByteCount()];
                    dataBuffer.read(content);

                    // Release buffer memory
                    DataBufferUtils.release(dataBuffer);

                    // Convert byte[] to String (assumed to be UTF-8 JSON)
                    String responseBody = new String(content, StandardCharsets.UTF_8);

                    // Convert JSON string into Map
                    Map<String, Object> mapBody = JsonUtil.toMap(responseBody);

                    APIResponse apiResponse;

                    // If it's a 4xx client error, wrap as an error response
                    if (statusCode != null && statusCode.is4xxClientError()) {
                        apiResponse = new APIResponse();
                        apiResponse.setSuccess(false);
                        apiResponse.setCode(statusCode.value());
                        apiResponse.setError(status.getReasonPhrase());
                        apiResponse.setMessage(responseBody); // fallback
                        apiResponse.setMessage(mapBody.getOrDefault("message", "Some error occurred").toString());
                    } else {
                        // Otherwise, wrap as a success response
                        apiResponse = transform(mapBody);
                    }

                    try {
                        // Serialize the APIResponse into byte[]
                        byte[] newContent = objectMapper.writeValueAsBytes(apiResponse);

                        // Wrap the new content in a DataBuffer
                        DataBuffer buffer = bufferFactory.wrap(newContent);

                        // Write the new DataBuffer to the response
                        return super.writeWith(Mono.just(buffer));
                    } catch (JsonProcessingException e) {
                        e.printStackTrace();
                        // If serialization fails, return the error as-is
                        return super.writeWith(Flux.error(e));
                    }
                });

Now this part of code is what which do all the processing on the stream.

First, it joins all incoming DataBuffer chunks from the fluxBody into a single DataBuffer using DataBufferUtils.join(...), ensuring the full response body is available at once for processing.

It then switches execution to the boundedElastic scheduler using publishOn(...) to safely handle blocking operations like JSON parsing off the main event loop (i.e in a new Thread)

Inside the flatMap, it reads the raw byte content from the buffer, releases the buffer memory to prevent leaks, and converts the byte array into a UTF-8 encoded string, assuming it's JSON. This JSON string is parsed into a Map<String, Object> using a custom utility JsonUtil.toMap(...).

Based on the HTTP status code (specifically if it's a 4xx client error), the response is either wrapped into an error format with metadata like status code, error reason, and a meaningful message, or transformed into a standardized success wrapper using the transform(...) method.

Finally, the new structured response (APIResponse) is serialized back into a JSON byte array, wrapped in a DataBuffer, and written to the response using super.writeWith(...).

In case of any serialization errors, a fallback error response is triggered using Flux.error(...). This flow ensures consistent, structured API responses without blocking the reactive pipeline.

ApiResponseTransformFilter

package com.lms.api_gateway.config.filters;

import com.fasterxml.jackson.databind.ObjectMapper;

import com.lms.api_gateway.config.APIResponse;
import com.lms.api_gateway.config.DecoratorImpl;

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;

import org.springframework.http.server.reactive.ServerHttpResponse;

import org.springframework.stereotype.Component;

import java.util.Map;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR;

@Component
public class ApiResponseTransformFilter extends AbstractGatewayFilterFactory<ApiResponseTransformFilter.Config> {

    private final ObjectMapper objectMapper = new ObjectMapper();

    public ApiResponseTransformFilter() {
        super(Config.class);
    }

    private APIResponse transform(Map<String, Object> object, Config config) {
        APIResponse apiResponse = new APIResponse();
        apiResponse.setData(object);
        apiResponse.setSuccess(true);
        return apiResponse;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return new OrderedGatewayFilter((exchange, chain) -> {
            ServerHttpResponse originalResponse = exchange.getResponse();

                       return chain.filter(exchange.mutate().response(new DecoratorImpl(originalResponse)).build());




        }, NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1);
    }

    public static class Config {
        // Future config options
    }
}

JsonUtil

package com.lms.api_gateway.utils;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.Map;

@Service
public  class JsonUtil {
    private static final ObjectMapper objectMapper = new ObjectMapper();

    public static Map<String, Object> toMap(String json) {
        try {
            return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
        } catch (IOException e) {
            throw new RuntimeException("Failed to parse JSON to Map", e);
        }
    }
}

Here is the final code Github

Thanx for reading will see you next time , if you have any question feel free to ask.

11
Subscribe to my newsletter

Read articles from Mayank Gupta directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mayank Gupta
Mayank Gupta