Handling Custom Decoders for Void Return Types in Spring Cloud OpenFeign
When implementing remote service integrations with Spring Cloud OpenFeign, a common pattern involves wrapping outgoing requetss and incoming responses in a unified protocol structure. Developers typically register a custom Decoder to extract payload data, validate HTTP status codes, and rethrow remote business exceptions. A specific edge case arises when the target interface declares a void return type. In these scenarios, the framework's internal request pipeline completely skips the custom decoding stage, leaving exception handling untriggered and returning a successful completion state regardless of the actual response body.
Tracing the execution stack reveals that this short-circuit is intentional within OpenFeign's architecture. The AsyncResponseHandler component evaluates the declared method signature early in the response cycle. If the signature indicates a void return, the handler fulfills the underlying CompletableFuture with null without invoking the registered Decoder. This bypass mechanism was historically gated behind an internal boolean property named forceDecoding inside the synchronous method handler factory. Disabling this optimization ensures the full serialization/deserialization chain executes.
public class UnifiedApiDecoder implements Decoder {
@Override
public Object decode(Response response, Type targetType) throws IOException {
String responseBody = Util.toString(response.body().asReader(Util.UTF_8));
ApiResponseWrapper wrapper = JsonParser.readValue(responseBody, ApiResponseWrapper.class);
if (!wrapper.isSuccess()) {
throw new RemoteServiceFailureException(wrapper.getErrorDetails());
}
return JsonParser.convertValue(wrapper.getPayload(), targetType);
}
}
For legacy OpenFeign versions (typically aligned with Spring Boot 2.x), enabling full decoding requires injecting the decoder into a configuration bean and forcing the internal flag via reflection:
@Configuration
public class FeignClientConfiguration {
@Bean
public UnifiedApiDecoder apiDecoder() {
return new UnifiedApiDecoder();
}
@Bean
public Feign.Builder feignBuilder(UnifiedApiDecoder decoder) throws Exception {
Feign.Builder clientBuilder = Feign.builder().decoder(apiDecoder());
java.lang.reflect.Field forceDecodeFlag = clientBuilder.getClass()
.getDeclaredField("forceDecoding");
forceDecodeFlag.setAccessible(true);
forceDecodeFlag.set(clientBuilder, true);
return clientBuilder;
}
}
Once the decoding pipeline is forced, the custom decoder must explicit account for void return types to prevent unnecessary payload trensformation or casting exceptions. The validation logic should execute first, followed by a type check that returns null for void signatures:
public class UnifiedApiDecoder implements Decoder {
@Override
public Object decode(Response response, Type targetType) throws IOException {
String rawPayload = Util.toString(response.body().asReader(Util.UTF_8));
ApiResponseWrapper responseStruct = JsonParser.readValue(rawPayload, ApiResponseWrapper.class);
if (!responseStruct.isSuccess()) {
throw new RemoteServiceFailureException(responseStruct.getErrorDetails());
}
boolean isVoidReturn = targetType == Void.class || targetType == Void.TYPE;
if (isVoidReturn) {
return null;
}
return JsonParser.convertValue(responseStruct.getData(), targetType);
}
}
Recent iterations of OpenFeign (standardized in Spring Boot 3.x+) refactored this internal state management. The opaque forceDecoding field was replaced with a public builder fluent API designed specifically for this scenario. Developers no longer need reflection to override the default behavior. Instead, the builder exposes a direct method that guarantees the decoder processes all response streams, including those intended for void methods.
@Configuration
public class ModernFeignConfiguration {
@Bean
public UnifiedApiDecoder apiDecoder() {
return new UnifiedApiDecoder();
}
@Bean
public Feign.Builder feignBuilder(UnifiedApiDecoder decoder) {
return Feign.builder()
.decoder(apiDecoder())
.decodeVoid();
}
}
By routing requests through the complete decoding lifecycle, exception interception becomes consistent across all endpoint signatures. This approach maintains strict control over network traffic and ensures that centralized error handling remains active regardless of whether a microservice method produces a return value or operates purely as a side-effect trigger.