Banning Feign: A Unified RPC Client That Fixes Context, Timeout, and Fallback by Default
Ban Feign! Why We Built Our Own InternalServiceClient
In microservice cross-service calls, 90% of people use Feign — but we chose not to.
1. Why Not Use Feign for Microservice Cross-Service Calls?
In China, when building Spring Cloud microservices, cross-service calls almost always have one answer: Feign.
OpenFeign is indeed a classic solution: declarative interfaces, automatic serialization, integrated Ribbon load balancing. But after using it for a while, you find that while it solves some problems, it introduces even more new ones.
During the design of MetaLite, we made a decision: Ban Feign and build our own InternalServiceClient.
This decision sparked a lot of debate within the team. After all, Feign is the standard for Spring Cloud; not using it meant writing our own calling framework. But after stepping on landmines across several projects, we are convinced it was the right choice.
In this article, I'll lay out Feign's pain points, the design thinking behind InternalServiceClient, and a side-by-side code comparison. After reading, you can judge for yourself.
2. Feign's Pain Points
2.1 Difficult Context Propagation
In a microservice call chain, a lot of contextual information needs to be passed between services:
- TraceId: Full-link tracing, essential for troubleshooting
- User ID: Downstream services need to know who initiated the request
- AppId: Caller identifier, used for permission control and auditing
- Seata XID: Distributed transaction's transaction ID
With Feign, this information must be manually injected into HTTP Headers via RequestInterceptor:
@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
template.header("traceId", ThreadContext.getTraceId());
template.header("userId", ThreadContext.getLoginUserId());
template.header("appId", ThreadContext.getAppId());
template.header("X-Seata-XID", ThreadContext.getSeataXid());
}
}
It looks fine, but there are two hidden dangers:
- Implicit dependency: Feign's Header propagation is done by a global interceptor. If a service forgets to configure the interceptor, the context breaks, making troubleshooting difficult.
- Scattered propagation logic: TraceId, user ID, XID are each handled in different interceptors or interception logic, lacking a unified entry point.
2.2 Inflexible Timeout Control
Feign's timeout configuration granularity is coarse. It's usually set globally in application.yml:
feign:
client:
config:
default:
connectTimeout: 5000
readTimeout: 10000
The problem is: Different calls have vastly different timeout requirements.
- Querying basic user info: 50ms is enough
- Generating a report export: might need 30 seconds
- Batch syncing data: might need 1 minute
With Feign, you either change it globally (affecting other calls) or configure a separate config class for each Client (code bloat).
Even worse, in a microservice call chain, if A → B → C each uses a 10-second timeout, the end user might wait 30 seconds to get a response. This is the timeout accumulation effect — the longer the call chain, the slower the final response.
2.3 Complex Fallback Handling
Feign's fallback relies on Hystrix or Resilience4j:
@FeignClient(name = "user-service", fallback = UserClientFallback.class)
public interface UserClient {
@GetMapping("/api/user/info")
Resp<UserDto> getUserInfo(@RequestParam String userId);
}
@Component
public class UserClientFallback implements UserClient {
@Override
public Resp<UserDto> getUserInfo(String userId) {
return Resp.error("User service temporarily unavailable");
}
}
Every interface needs a Fallback implementation class. The more services, the more Fallback classes, and the code volume skyrockets.
Moreover, Fallback is at the interface level, not the call level. For the same interface, some callers need fallback, others don't, but Feign can only configure one global strategy.
2.4 Decoupling of Interface Definition and Implementation
Feign requires the caller to define an interface:
@FeignClient(name = "user-service")
public interface UserClient {
@PostMapping("/api/admin/sysUser/getInfo")
Resp<UserDto> getUserInfo(@RequestBody InternalReq req);
}
But the actual implementation of the interface is the callee's Controller. When the callee changes the interface path, parameter types, or return format, the caller's Feign interface gets no compile-time warning — the problem is only discovered when the call fails at runtime.
For projects with many services and frequent iterations, this decoupling is extremely painful.
3. InternalServiceClient's Design
3.1 Core Design Philosophy
MetaLite's InternalServiceClient design philosophy is clear: A unified calling entry point that automatically handles all cross-cutting concerns.
public class InternalServiceClient {
public void callOneInstance(RpcRequest rpcRequest) { }
public <T> Resp<T> callOneInstanceRtnData(RpcRequest rpcRequest, Class<T> respDataType) { }
public <T> Resp<List<T>> callOneInstanceRtnListData(RpcRequest rpcRequest, Class<T> respDataType) { }
public <E> Resp<PageResultDto<E>> callOneInstanceRtnPageData(RpcRequest rpcRequest, Class<E> respDataType) { }
}
Only 4 core methods, covering all calling scenarios:
| Method | Purpose |
|---|---|
callOneInstance |
Call a single instance, no return value |
callOneInstanceRtnData |
Call a single instance, return a single object |
callOneInstanceRtnListData |
Call a single instance, return a collection |
callOneInstanceRtnPageData |
Call a single instance, return paginated data |
3.2 Automatic Context Propagation
This is one of InternalServiceClient's most core capabilities. In the checkAndFillRpcRequest method, all context is automatically injected:
private RpcRequest checkAndFillRpcRequest(RpcRequest rpcRequest) {
// Build internal request headers, automatically propagate full-link context
Map<String, String> headers = new HashMap<>();
headers.put(TRACE_ID, ThreadContext.getTraceId());
headers.put(LOGIN_USER_ID, ThreadContext.getLoginUserId());
headers.put(APP_ID, ThreadContext.getAppId());
headers.put(SEATA_XID, ThreadContext.getSeataXid());
if (rpcRequest.getHeaders() == null) {
rpcRequest.setHeaders(headers);
} else {
rpcRequest.getHeaders().putAll(headers);
}
return rpcRequest;
}
Developers don't need to worry about how Headers are passed, how TraceId is carried over, how Seata XID propagates across services — as long as you use InternalServiceClient, all of this is handled automatically.
3.3 Layer-by-Layer Timeout Decrement
This is the key design that distinguishes InternalServiceClient from Feign. In checkAndFillRpcRequest:
// Timeout decrements layer by layer
int timeoutMillis = rpcRequest.getTimeoutMillis();
timeoutMillis = timeoutMillis <= 0 ? API_REQUEST_TIMEOUT_MILLIS : timeoutMillis - API_REQUEST_TIMEOUT_MILLIS_MINUS;
rpcRequest.setTimeoutMillis(timeoutMillis);
What does this mean?
A → B → C → D
A initiates call with timeout = 5000ms
B receives call with timeout = 5000 - 500 = 4500ms
C receives call with timeout = 4500 - 500 = 4000ms
D receives call with timeout = 4000 - 500 = 3500ms
With each layer passed, the timeout automatically decrements. This ensures:
- The longer the call chain, the shorter the downstream timeout, preventing downstream services from waiting too long
- The end user's wait time has an upper limit, avoiding the ultra-long waits caused by call chain accumulation
- Downstream services fail fast more easily, reducing avalanche risk
3.4 Based on Nacos Service Discovery
InternalServiceClient uses Nacos for service discovery underneath; just specify the service name when calling:
RpcRequest rpcRequest = RpcRequest.builder()
.provider("user-service") // Service name, Nacos auto-discovery
.endpoint("/api/admin/sysUser/getInfo")
.param(internalReq)
.rpcMode(RpcModeEnum.HTTP)
.build();
Resp<UserDto> resp = internalServiceClient.callOneInstanceRtnData(rpcRequest, UserDto.class);
No need for Feign's @FeignClient(name = "user-service") annotation to bind the service name. The service name is explicitly specified on each call, clear and controllable.
3.5 Response Type Safety
Feign returns the type defined by the interface, but type conversion is only validated at runtime. InternalServiceClient requires the response data type to be explicitly specified at call time:
// Single object
Resp<UserDto> resp = internalServiceClient.callOneInstanceRtnData(rpcRequest, UserDto.class);
// Collection
Resp<List<OrderDto>> resp = internalServiceClient.callOneInstanceRtnListData(rpcRequest, OrderDto.class);
// Paginated
Resp<PageResultDto<UserDto>> resp = internalServiceClient.callOneInstanceRtnPageData(rpcRequest, UserDto.class);
Internally, types are strictly validated:
paramCheck(!Collection.class.isAssignableFrom(respDataType), "respDataType", "Cannot be a collection type");
If the type doesn't match, it errors immediately on the caller side, rather than silently failing in the downstream service.
3.6 Aspect Chain Enhancement
All methods of InternalServiceClient are intercepted by ApiCallAspect:
@Around("execution(public * com.metalite.rpc.InternalServiceClient.call*(..))")
private Object aroundBaseInternalServiceClient(ProceedingJoinPoint pjp) throws Throwable {
// Unified logging, monitoring, exception handling
}
This means every RPC call automatically goes through:
- Logging: Caller, callee, latency, response code
- Exception handling: Unified wrapping into
Respformat - Monitoring instrumentation: Call success rate, latency distribution
Developers don't need to manually add try-catch, write logs, or set up monitoring — it's all done automatically.
4. Code Comparison
4.1 Feign Approach
// 1. Define Feign interface
@FeignClient(name = "user-service", fallback = UserClientFallback.class)
public interface UserClient {
@PostMapping("/api/admin/sysUser/getInfo")
Resp<UserDto> getUserInfo(@RequestBody InternalReq req);
}
// 2. Write Fallback implementation
@Component
public class UserClientFallback implements UserClient {
@Override
public Resp<UserDto> getUserInfo(InternalReq req) {
return Resp.error("User service temporarily unavailable");
}
}
// 3. Write RequestInterceptor to propagate context
@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
template.header("traceId", ThreadContext.getTraceId());
template.header("userId", ThreadContext.getLoginUserId());
// ... Every Header must be added manually
}
}
// 4. Caller usage
@Resource
private UserClient userClient;
public Resp<UserDto> getUser(String userId) {
InternalReq req = new InternalReq();
req.putParam("userId", userId);
return userClient.getUserInfo(req); // No timeout decrement
}
4.2 InternalServiceClient Approach
@Resource
private InternalServiceClient internalServiceClient;
public Resp<UserDto> getUser(String userId) {
InternalReq req = new InternalReq();
req.putParam("userId", userId);
RpcRequest rpcRequest = RpcRequest.builder()
.provider("user-service")
.endpoint("/api/admin/sysUser/getInfo")
.param(req)
.rpcMode(RpcModeEnum.HTTP)
.build();
// One line call: context auto-propagated, timeout auto-decremented, response auto-converted
return internalServiceClient.callOneInstanceRtnData(rpcRequest, UserDto.class);
}
Comparison:
| Dimension | Feign | InternalServiceClient |
|---|---|---|
| Interface Definition | Need to define Feign interface + Fallback class | Not needed, directly construct RpcRequest |
| Context Propagation | Need to manually write RequestInterceptor | Auto-injected |
| Timeout Control | Global config or per-Client config | Layer-by-layer auto-decrement |
| Fallback Handling | Write Fallback implementation per interface | Unified response body handling |
| Type Safety | Runtime validation | Explicitly specified at call time |
| Monitoring/Logging | Requires extra configuration | Aspect auto-handled |
5. Summary of Design Philosophy
Banning Feign isn't because we think Feign is bad, but because Feign solves the problem of "can you call", while microservices need to solve the problem of "calling well".
InternalServiceClient's design philosophy:
- Convention over configuration: Context propagation, timeout decrement, aspect monitoring are all automatic; developers don't need to worry about them
- Explicit over implicit: Service name, endpoint, response type are all explicitly specified on each call, leaving no hidden dangers
- Unified entry over scattered definitions: One Client covers all calling scenarios; no need to write separate interfaces for each service
This is not a denial of Feign, but another understanding of the essence of microservice calls.
InternalServiceClient's design philosophy runs through the entire MetaLite framework — convention over configuration, explicit over implicit, unified entry over scattered definitions.
Framework Introduction: MetaLite — Next-generation enterprise-grade Java microservice technology foundation
Author Introduction: Based on 15 years of enterprise development experience in the Spring ecosystem, focused on building the next generation of Java microservice technology foundation through engineering thinking and architectural ideas validated in enterprise production environments.
Complete Documentation and Source Code: Search MetaLite on Gitee (https://gitee.com/MetaLite)
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Backend demo address: http://admin.metalite.top User: guess Password: admin@2026