Why Spring Cloud Gateway Rejects MVC: DeferredImportSelector & Conditional Auto-Configuration

This article explains why Spring Cloud Gateway cannot coexist with Spring MVC due to fundamental model differences, and details how Spring Boot's DeferredImportSelector and conditional annotations enable intelligent auto-configuration by evaluating classpath, beans, and environment at runtime.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Why Spring Cloud Gateway Rejects MVC: DeferredImportSelector & Conditional Auto-Configuration

Gateway + MVC: The Classic Conflict

Adding spring-boot-starter-web to a Spring Cloud Gateway project triggers a startup failure:

Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway at this time. Please remove spring-boot-starter-web dependency.

This error comes from Gateway's own GatewayClassPathWarningAutoConfiguration, not Spring Boot.

How Gateway Detects MVC

The configuration class uses two conditional inner classes: SpringMvcFoundOnClasspathConfiguration with

@ConditionalOnClass(name = "org.springframework.web.servlet.DispatcherServlet")

— activates when DispatcherServlet is present, logging a warning (or throwing an exception in newer versions). WebfluxMissingFromClasspathConfiguration with

@ConditionalOnMissingClass("org.springframework.web.reactive.DispatcherHandler")

— activates when WebFlux is absent, prompting to add spring-boot-starter-webflux.

Thus Gateway "detects" MVC by checking for specific classes on the classpath via conditional annotations.

Why Gateway and MVC Cannot Coexist

Fundamental Model Differences

The conflict stems from three layers:

Web Server Conflict: MVC defaults to Tomcat (Servlet container); WebFlux uses Netty (non-blocking). Both produce ServletWebServerFactory and ReactiveWebServerFactory beans, causing a bean clash.

Request Dispatcher Conflict: MVC uses DispatcherServlet; WebFlux uses DispatcherHandler. Two dispatchers cannot share the request pipeline.

Programming Model Conflict: MVC controllers return synchronous values on Servlet threads; WebFlux controllers return Mono / Flux on Netty event loops. Mixing blocking calls in Gateway filters blocks Netty threads, causing performance collapse or deadlock.

In short: Gateway is built for high-performance reactive processing; MVC is designed for traditional blocking Servlets. Their underlying models are incompatible.

Correct Way to Add Endpoints in Gateway

Use WebFlux-style endpoints — no extra dependency needed because Gateway already pulls in spring-boot-starter-webflux:

@RestController
public class HealthController {
    @GetMapping("/health")
    public Mono<String> health() {
        return Mono.just("ok");
    }
}

Or use functional routing with RouterFunction.

DeferredImportSelector: The Engine of "Cook According to the Dishes"

Entry Point: @EnableAutoConfiguration

@SpringBootApplication

includes @EnableAutoConfiguration, which declares @Import(AutoConfigurationImportSelector.class). AutoConfigurationImportSelector implements DeferredImportSelector.

ImportSelector vs DeferredImportSelector

ImportSelector

: executes immediately during configuration class parsing — too early to see user-defined beans. DeferredImportSelector: executes after all @Configuration classes and @ComponentScan beans are processed . This delay ensures conditional annotations can accurately evaluate the final classpath, bean registry, and environment.

Execution Flow in ConfigurationClassParser

public void parse(Set<BeanDefinitionHolder> configCandidates) {
    // 1. Parse all user configuration classes
    for (BeanDefinitionHolder holder : configCandidates) {
        parse(holder.getBeanDefinition().getMetadata(), holder.getBeanName());
    }
    // 2. Process regular ImportSelectors (immediate)
    // ...
    // 3. Process DeferredImportSelectors (delayed)
    this.deferredImportSelectorHandler.process();
}

The handler sorts and groups deferred selectors, then calls processGroupImports() which invokes each group's process() and selectImports().

AutoConfigurationImportSelector's Group Mechanism

AutoConfigurationImportSelector

defines an inner AutoConfigurationGroup implementing DeferredImportSelector.Group:

private static class AutoConfigurationGroup implements DeferredImportSelector.Group {
    private final Map<String, AnnotationMetadata> entries = new LinkedHashMap<>();
    private final List<AutoConfigurationEntry> autoConfigurationEntries = new ArrayList<>();

    @Override
    public void process(AnnotationMetadata metadata, DeferredImportSelector selector) {
        AutoConfigurationEntry entry = ((AutoConfigurationImportSelector) selector)
                .getAutoConfigurationEntry(metadata);
        this.autoConfigurationEntries.add(entry);
        for (String importClassName : entry.getConfigurations()) {
            this.entries.putIfAbsent(importClassName, metadata);
        }
    }

    @Override
    public Iterable<Entry> selectImports() {
        List<String> configurations = new ArrayList<>(this.entries.keySet());
        configurations.sort(AUTO_CONFIGURATION_ORDER_COMPARATOR); // @AutoConfigureBefore/After/Order
        List<Entry> result = new ArrayList<>();
        for (String configuration : configurations) {
            result.add(new Entry(configuration, this.entries.get(configuration)));
        }
        return result;
    }
}

Two-phase process:

process() : Loads all candidate auto-configuration classes from

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

, applies conditional filtering ( @ConditionalOnClass, @ConditionalOnMissingBean, etc.), and stores the filtered set.

selectImports() : Sorts the surviving configurations via @AutoConfigureBefore, @AutoConfigureAfter, @AutoConfigureOrder, and returns the final import list.

Core Filtering: getAutoConfigurationEntry()

protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata metadata) {
    if (!isEnabled(metadata)) return EMPTY_ENTRY;
    AnnotationAttributes attributes = getAttributes(metadata);
    List<String> configurations = getCandidateConfigurations(metadata, attributes); // from .imports files
    configurations = removeDuplicates(configurations);
    Set<String> exclusions = getExclusions(metadata, attributes);
    configurations.removeAll(exclusions);
    configurations = filter(configurations, autoConfigurationMetadata); // CONDITION EVALUATION
    fireAutoConfigurationImportEvents(configurations, exclusions);
    return new AutoConfigurationEntry(configurations, exclusions);
}

The filter() step evaluates every conditional annotation on each auto-configuration class: @ConditionalOnClass — class present on classpath @ConditionalOnMissingClass — class absent @ConditionalOnBean / @ConditionalOnMissingBean — bean present/absent in container @ConditionalOnWebApplication — Servlet, Reactive, or non-web @ConditionalOnProperty — configuration property value

Only when all conditions match does the configuration survive.

Back to Gateway: Conditional Assembly in Action

Gateway Auto-Configuration Loading

Gateway registers its auto-configurations in

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

:

org.springframework.cloud.gateway.config.GatewayClassPathWarningAutoConfiguration
org.springframework.cloud.gateway.config.GatewayAutoConfiguration
org.springframework.cloud.gateway.config.GatewayLoadBalancerClientAutoConfiguration
org.springframework.cloud.gateway.config.GatewayMetricsAutoConfiguration
...

GatewayClassPathWarningAutoConfiguration Conditions

As shown earlier, its two inner classes use @ConditionalOnClass(DispatcherServlet) and @ConditionalOnMissingClass(DispatcherHandler) to act as sentinels.

Why Spring Boot's MVC Auto-Configuration Doesn't Activate

WebMvcAutoConfiguration

carries

@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)

. When both MVC and WebFlux are present, Spring Boot detects a Reactive environment (Gateway forces spring.main.web-application-type=reactive), so the SERVLET condition fails and WebMvcAutoConfiguration is filtered out. However, DispatcherServlet class remains on the classpath, so Gateway's sentinel detects it and aborts startup.

The error "Spring MVC found on classpath" appears because MVC's auto-configuration is suppressed, but its classes are still present — Gateway's sentinel sees them and refuses to start.

Complete Conditional Assembly Flow for Gateway + MVC

Spring Boot Startup
    │
    ▼
@EnableAutoConfiguration
    │
    ▼
AutoConfigurationImportSelector (DeferredImportSelector)
    │
    ▼
Deferred execution: wait for all user config
    │
    ▼
Load all auto-configurations from .imports files
    │
    ├── GatewayClassPathWarningAutoConfiguration
    │   ├── SpringMvcFoundOnClasspathConfiguration
    │   │   └── @ConditionalOnClass(DispatcherServlet) → ✅ MVC present → ACTIVATES → ERROR
    │   └── WebfluxMissingFromClasspathConfiguration
    │       └── @ConditionalOnMissingClass(DispatcherHandler) → ❌ WebFlux present → SKIPPED
    │
    ├── WebMvcAutoConfiguration
    │   └── @ConditionalOnWebApplication(SERVLET) → ❌ Reactive env → SKIPPED
    │
    ├── GatewayAutoConfiguration
    │   └── @ConditionalOnProperty + @ConditionalOnClass → ✅ ACTIVATES
    │
    └── ... other auto-configurations
    │
    ▼
Filtered configurations imported
    │
    ▼
Gateway sentinel detects MVC → startup fails

This is "cook according to the dishes": inspect classpath dependencies, container beans, environment type, and configuration properties to decide which auto-configurations apply.

Practice: Building a "Cook According to the Dishes" Starter

Requirement: Automatic Logging Framework Switch

A logging starter that chooses Logback when present, otherwise falls back to JDK Logging.

Interface and Implementations

public interface MyLogger { void log(String message); }

public class LogbackLogger implements MyLogger {
    private final org.slf4j.Logger logger = LoggerFactory.getLogger("my-logger");
    public void log(String message) { logger.info("[Logback] " + message); }
}

public class JdkLogger implements MyLogger {
    private final java.util.logging.Logger logger = java.util.logging.Logger.getLogger("my-logger");
    public void log(String message) { logger.info("[JDK] " + message); }
}

Conditional Auto-Configuration Classes

@AutoConfiguration
@ConditionalOnClass(name = "ch.qos.logback.classic.LoggerContext")
@ConditionalOnMissingBean(MyLogger.class)
public class LogbackAutoConfiguration {
    @Bean
    public MyLogger myLogger() { return new LogbackLogger(); }
}

@AutoConfiguration
@ConditionalOnMissingClass("ch.qos.logback.classic.LoggerContext")
@ConditionalOnMissingBean(MyLogger.class)
@AutoConfigureAfter(LogbackAutoConfiguration.class)
public class JdkLoggerAutoConfiguration {
    @Bean
    public MyLogger myLogger() { return new JdkLogger(); }
}

Registration

In

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

:

com.example.logging.LogbackAutoConfiguration
com.example.logging.JdkLoggerAutoConfiguration

Behavior

Logback on classpath → LogbackLogger injected.

No Logback → JdkLogger injected.

User defines own MyLogger bean → user's bean wins (via @ConditionalOnMissingBean).

This mirrors exactly how Gateway detects MVC.

Summary

Gateway vs MVC Root Cause: Gateway uses WebFlux+Reactor+Netty (non-blocking reactive); MVC uses Servlet+Tomcat (blocking). Web server, dispatcher, and programming models all conflict.

Gateway's MVC Detection: GatewayClassPathWarningAutoConfiguration uses @ConditionalOnClass(DispatcherServlet) to spot MVC classes and abort.

DeferredImportSelector's Role: AutoConfigurationImportSelector implements it, delaying auto-configuration import until all user configuration is parsed. This ensures conditional annotations ( @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnWebApplication) evaluate accurately.

Full Auto-Configuration Flow: @EnableAutoConfiguration@Import(AutoConfigurationImportSelector) → deferred execution → load candidates from .imports → condition filtering → sorting via @AutoConfigureBefore/After → import final set.

Gateway Best Practices: Never add spring-boot-starter-web; write endpoints with WebFlux ( Mono / Flux or RouterFunction); exclude transitive spring-web; avoid blocking calls in filters. DeferredImportSelector embodies Spring Boot's "convention over configuration" philosophy: the framework doesn't decide for the user; it intelligently provides defaults based on the environment, with user configuration always taking precedence. Mastering this mechanism turns guesswork into precise diagnostics for auto-configuration failures, bean conflicts, and dependency clashes.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

reactive programmingSpring BootWebFluxSpring MVCSpring Cloud GatewayAuto-configurationDeferredImportSelectorConditional Annotations
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.