This solution covers both Spring Boot 2.x and 3.x implementations.
Problem Description
A common requirement arises where each API interface carries an interfacePath defined in a custom annotation on the interface itself. For instance:
@FeignClient(value = "x-module")
public interface XXXService extends XApi {
}
@XXXMapping("/member")
public interface XApi {
}
The goal is to automatically prepend @XXXMapping values to the @FeignClient path attribute, rather than manually configuring each Feign client.
How @FeignClient Scanning Works
Projects using Feign typically enable it via @EnableFeignClients on the main application class:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Import({FeignClientsRegistrar.class})
public @interface EnableFeignClients {
String[] value() default {};
String[] basePackages() default {};
Class<?>[] basePackageClasses() default {};
Class<?>[] defaultConfiguration() default {};
Class<?>[] clients() default {};
}
The @Import annotation registers FeignClientsRegistrar, which implements ImportBeanDefinitionRegistrar to inject bean definitions into the Spring container during initialization.
Analyzing the Registration Process
The registerBeanDefinitions method in FeignClientsRegistrar performs two key operations:
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
this.registerDefaultConfiguration(metadata, registry);
this.registerFeignClients(metadata, registry);
}
The registerFeignClients method employs a scanner to find all @FeignClient annotated interfaces:
LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet();
Map<String, Object> attrs = metadata.getAnnotationAttributes(EnableFeignClients.class.getName());
ClassPathScanningCandidateComponentProvider scanner = this.getScanner();
scanner.setResourceLoader(this.resourceLoader);
scanner.addIncludeFilter(new AnnotationTypeFilter(FeignClient.class));
Set<String> basePackages = this.getBasePackages(metadata);
Iterator var8 = basePackages.iterator();
while(var8.hasNext()) {
String basePackage = (String)var8.next();
candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
}
Iterator var13 = candidateComponents.iterator();
while(var13.hasNext()) {
BeanDefinition candidateComponent = (BeanDefinition)var13.next();
if (candidateComponent instanceof AnnotatedBeanDefinition) {
AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition)candidateComponent;
AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
Assert.isTrue(annotationMetadata.isInterface(), "@FeignClient can only be specified on an interface");
Map<String, Object> attributes = annotationMetadata.getAnnotationAttributes(FeignClient.class.getCanonicalName());
String name = this.getClientName(attributes);
this.registerClientConfiguration(registry, name, attributes.get("configuration"));
this.registerFeignClient(registry, annotationMetadata, attributes);
}
}
The registerFeignClient method extracts attributes like value, path, and contextId from the annotation metadata, then configures a FeignClientFactoryBean to handle the actual client instantiation:
private void registerFeignClient(BeanDefinitionRegistry registry, AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {
String className = annotationMetadata.getClassName();
Class clazz = ClassUtils.resolveClassName(className, (ClassLoader)null);
ConfigurableBeanFactory beanFactory = registry instanceof ConfigurableBeanFactory ? (ConfigurableBeanFactory)registry : null;
String contextId = this.getContextId(beanFactory, attributes);
String name = this.getName(attributes);
FeignClientFactoryBean factoryBean = new FeignClientFactoryBean();
factoryBean.setBeanFactory(beanFactory);
factoryBean.setName(name);
factoryBean.setContextId(contextId);
factoryBean.setType(clazz);
factoryBean.setRefreshableClient(this.isClientRefreshEnabled());
BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(clazz, () -> {
factoryBean.setUrl(this.getUrl(beanFactory, attributes));
factoryBean.setPath(this.getPath(beanFactory, attributes));
factoryBean.setDecode404(Boolean.parseBoolean(String.valueOf(attributes.get("decode404"))));
Object fallback = attributes.get("fallback");
if (fallback != null) {
factoryBean.setFallback(fallback instanceof Class ? (Class)fallback : ClassUtils.resolveClassName(fallback.toString(), (ClassLoader)null));
}
Object fallbackFactory = attributes.get("fallbackFactory");
if (fallbackFactory != null) {
factoryBean.setFallbackFactory(fallbackFactory instanceof Class ? (Class)fallbackFactory : ClassUtils.resolveClassName(fallbackFactory.toString(), (ClassLoader)null));
}
return factoryBean.getObject();
});
definition.setAutowireMode(2);
definition.setLazyInit(true);
this.validate(attributes);
AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
beanDefinition.setAttribute("factoryBeanObjectType", className);
beanDefinition.setAttribute("feignClientsRegistrarFactoryBean", factoryBean);
boolean primary = (Boolean)attributes.get("primary");
beanDefinition.setPrimary(primary);
String[] qualifiers = this.getQualifiers(attributes);
if (ObjectUtils.isEmpty(qualifiers)) {
qualifiers = new String[]{contextId + "FeignClient"};
}
BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, qualifiers);
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
this.registerOptionsBeanDefinition(registry, contextId);
}
The BeanDefinition contains an instanceSupplier that holds the parsed @FeignClient attributes as an AnnotationAttributes map. This InstanceSupplier is invoked later during bean instantiation to configure the actual Feign client.
Solution Approach
Since the InstanceSupplier retains the parsed attributes and these attributes determine request path during instantiation, we can intercept the BeanDefinition betwean registration and instantiation, then modify the attributes stored within the InstanceSupplier.
This interception occurs after ImportBeanDefinitionRegistrar processes but before bean instantiation—exactly where BeanFactoryPostProcessor executes.
Spring Boot 2.x Implemantation
Create a custom BeanFactoryPostProcessor to scan and modify Feign client bean definitions:
@Component
@Log4j2
public class FeignClientPathProcessor implements BeanFactoryPostProcessor, ResourceLoaderAware, EnvironmentAware {
private String scanPackage;
private ResourceLoader resourceLoader;
private Environment environment;
@Override
@SuppressWarnings("unchecked")
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
List<String> feignClients = discoverFeignClients();
for (String clientName : feignClients) {
GenericBeanDefinition definition = (GenericBeanDefinition) beanFactory.getBeanDefinition(clientName);
Class<?> clientClass = definition.getBeanClass();
Class<?> targetApi = findTargetApiInterface(clientClass);
ApiPathMapping mapping = targetApi.getAnnotation(ApiPathMapping.class);
String apiPath = mapping.value();
Supplier<?> supplier = definition.getInstanceSupplier();
injectPathFromAnnotation(supplier, apiPath);
}
}
private Class<?> findTargetApiInterface(Class<?> clientClass) {
return Arrays.stream(clientClass.getInterfaces())
.filter(i -> i.getName().startsWith("com.example") && i.getName().endsWith("Api"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Target API interface not found"));
}
private void injectPathFromAnnotation(Supplier<?> supplier, String apiPath) {
try {
Field[] fields = supplier.getClass().getDeclaredFields();
for (Field field : fields) {
if (Map.class.isAssignableFrom(field.getType())) {
field.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, String> attrMap = (Map<String, String>) field.get(supplier);
String serviceName = attrMap.get("value");
attrMap.put("path", serviceName + apiPath);
}
}
} catch (Exception ex) {
log.error("Failed to inject Feign client path", ex);
}
}
private List<String> discoverFeignClients() {
ClassPathScanningCandidateComponentProvider provider = createScanner();
provider.setResourceLoader(this.resourceLoader);
provider.addIncludeFilter(new AnnotationTypeFilter(FeignClient.class));
Set<BeanDefinition> candidates = provider.findCandidateComponents(scanPackage);
return candidates.stream()
.map(BeanDefinition::getBeanClassName)
.collect(Collectors.toList());
}
private ClassPathScanningCandidateComponentProvider createScanner() {
return new ClassPathScanningCandidateComponentProvider(false, this.environment) {
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
return beanDefinition.getMetadata().isIndependent()
&& !beanDefinition.getMetadata().isAnnotation();
}
};
}
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
this.scanPackage = environment.getProperty("feign.scan.package");
}
}
Spring Boot 3.x Implementation
Upgrading to Spring Boot 3.x changes the bean definition structure. In 2.x, the beanClass stores the original class type, but in 3.x, it stores FeignClientFactoryBean. The actual Feign interface type moves to the factoryBeanObjectType attribute, and annotation properties shift to propertyValues.
Modify the postProcessBeanFactory method accordingly:
@Override
@SuppressWarnings("unchecked")
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
List<String> feignClients = discoverFeignClients();
for (String clientName : feignClients) {
GenericBeanDefinition definition = (GenericBeanDefinition) beanFactory.getBeanDefinition(clientName);
String feignInterfaceType = String.valueOf(definition.getAttribute("factoryBeanObjectType"));
try {
Class<?> clientClass = Class.forName(feignInterfaceType);
Class<?> targetApi = findTargetApiInterface(clientClass);
ApiPathMapping mapping = targetApi.getAnnotation(ApiPathMapping.class);
String apiPath = mapping.value();
MutablePropertyValues propertyValues = definition.getPropertyValues();
String serviceName = String.valueOf(propertyValues.get("name"));
propertyValues.add("path", serviceName + apiPath);
} catch (Exception ex) {
log.error("Failed to configure Feign client: {}", clientName, ex);
}
}
}
The key differences in Spring Boot 3.x:
- The
factoryBeanObjectTypeattribute holds the original interface class name as a string - Use
Class.forName()to load the interface class dynamically - Access
propertyValuesdirectly instead of reflecting intoInstanceSupplier - The
pathproperty is added viaMutablePropertyValues.add()method
Summary
This approach intercepts Feign client registration through BeanFactoryPostProcessor, allowing dynamic path modification based on custom annotations. The solution adapts to structural changes between Spring Boot 2.x and 3.x by handling different bean definition formats appropriately.