Skip to main content
Gradually Adopting Spring Boot for Legacy Servlet Apps: Build, Auto-Configuration, and Dual WAR Modes
  1. Posts/

Gradually Adopting Spring Boot for Legacy Servlet Apps: Build, Auto-Configuration, and Dual WAR Modes

·2089 words·10 mins
NeatGuyCoding
Author
NeatGuyCoding
Table of Contents

Gradually Adopting Spring Boot for Legacy Servlet Apps: Build, Auto-Configuration, and Dual WAR Modes
#

Abstract: Before a large-scale Spring Boot migration, establish repeatable integration verification and a controlled dependency baseline; then advance in layers—Starter, auto-configuration troubleshooting, Spring context inside an external container, transitional Holder, beanification, Servlet annotation migration, and executable WAR. The article is organized by dependency and runtime layers, contrasts demo-style bootstrap paths with the reference manual’s recommended path, and where official docs do not spell out behavior (for example process lifecycle when only a non-web context starts), leaves engineering-level uncertainty explicit.


1. Integration test guardrails: build lifecycle and embedded Servlet containers
#

(1) Rationale: During incremental refactors, unit tests rarely cover class loading, deployment descriptors, and real HTTP paths. Handing the WAR to a Maven-plugin–driven embedded Servlet container and binding probe requests to phases such as verify can be combined in CI with flows like mvn clean verify to form a “change a little, verify a round” loop.

(2) Implementation anchors: The Codehaus Cargo Maven 3 Plugin documents lifecycle integration and containerId examples; Tomcat 10.x identifiers are described in Tomcat 10.x container notes (tomcat10x). Maven phase responsibilities are covered in the Introduction to the Build Lifecycle.

(3) How to use it: The client only performs liveness and routing probes (example paths below; the actual context path must match deployment).

curl -sS -o /dev/null -w "%{http_code}\n" "http://localhost:8080/petclinic/api/owners"

On the server side, the plugin starts/stops a container with the WAR already deployed: the request above is handled by the same application running in that container (for example mapped to an existing Servlet), with no extra stub service required.


2. Target shape and version ladder: Initializr, Java, Jakarta, and the BOM
#

(1) Rationale: Generating a “target dependency set” with Spring Initializr avoids drift from hand-assembled starters; Java / Jakarta EE generations also constrain the usable Spring Boot major line—from Boot 3 GA onward, the official story clearly sits on Jakarta EE 10 (EE 9 baseline) and a Java 17 baseline.

(2) Implementation anchors: Spring Initializr (start.spring.io) generates POM or Gradle; system requirements are in Spring Boot system requirements; generational background appears in Spring Boot 3.0 Goes GA — Java 17 and Jakarta EE. Aggregate dependency versions with the BOM: Using Spring Boot — spring-boot-dependencies. If precedence rules for multiple BOM imports are ambiguous in complex setups, treat dependency:tree as ground truth (Maven import semantics: Importing Dependencies).

(3) How to use it: After importing the Boot BOM in your own dependencyManagement, diff WEB-INF/lib (or equivalent) before and after import to quickly spot version splits across sibling modules such as Jackson.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>${spring-boot.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

3. First Boot dependency: spring-boot-starter and the logging stack
#

(1) Rationale: spring-boot-starter brings core bootstrap behavior and defaults to Logback (Logging — default Logback); coexisting with legacy logging implementations often causes binding clashes that must be explicitly excluded or switched at the POM level.

(2) Implementation anchors: A typical switch to Log4j 2 excludes spring-boot-starter-logging and adds spring-boot-starter-log4j2 (How-to — Configure Log4j); the starter list remains in the Starters table.

(3) How to use it:

The slide title is “Add first spring dependency”; <artifactId>spring-boot-starter</artifactId> and the “Recheck logging library” hint are visible.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter</artifactId>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

4. Application entry: @SpringBootApplication, scan boundaries, and web environment detection
#

(1) Rationale: The main class should sit at the root package for component scanning (structuring your code). If neither Spring MVC nor WebFlux is on the classpath, SpringApplication uses the non-Servlet AnnotationConfigApplicationContext (Web environment detection). Whether the process exits immediately and whether the exit code is 0 are also influenced by non-daemon threads and similar factors; the docs do not explicitly tie “must be 0” to that line—treat production behavior as something to observe.

(2) Implementation anchors: @SpringBootApplication combines @Configuration, @EnableAutoConfiguration, and @ComponentScan; entry is SpringApplication.run.

(3) How to use it:

@SpringBootApplication
public class PetclinicApplication {
    public static void main(String[] args) {
        SpringApplication.run(PetclinicApplication.class, args);
    }
}

5. Auto-configuration and data sources: conditional report, debug, and exclude
#

(1) Rationale: When HikariCP and JDBC-related clues appear on the classpath, DataSourceAutoConfiguration may still try to create a dataSource without spring.datasource.* configuration, failing at startup. Primary visibility switches include --debug and the conditional report (Auto-configuration); you can also temporarily exclude relevant auto-configuration to restore an incremental pace.

(2) Implementation anchors: Exclusions via @SpringBootApplication(exclude = …) or spring.autoconfigure.exclude; fully qualified auto-configuration class names may shift across Boot majors—align with classes actually present in your dependencies.

(3) How to use it: The fully qualified exclusion target may change with the Boot major version; align it with the auto-configuration classes on the current classpath (jump from the conditional report in the IDE when needed).

The slide title “But it can fail as well (depends on the classpath)” appears together with a log fragment “Error creating bean with name ‘dataSource’”.

Debug output shows “Inspect active auto configurations” and the “DataSourceConfiguration.Hikari matched” section.

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class PetclinicApplication { /* ... */ }
# One property path that increases auto-configuration visibility (exact logger names per team convention)
logging.level.org.springframework.boot.autoconfigure=DEBUG

6. Spring context in an external Servlet container: Listener approach vs. manual-recommended path#

(1) Rationale: When deploying to external Tomcat, main is not the container entry; you must start SpringApplication explicitly in the web lifecycle. One approach is calling SpringApplication.run from ServletContextListener contextInitialized, registered with @WebListener or web.xml. The Spring Boot reference manual recommends SpringBootServletInitializer and the configure method for the same goal (Traditional deployment). Engineering trade-offs are maintenance cost versus team familiarity; record the chosen path in your architecture decisions.

(2) Implementation anchors: The Listener path involves SpringApplication, ConfigurableApplicationContext, and close() in contextDestroyed; WAR packaging still needs constraints such as spring-boot-starter-tomcat as provided (see Section 10 and the traditional deployment chapter).

(3) How to use it:

The slide title “Introduce ServletContextListener to start context” and a skeleton public class SpringContextListener implements ServletContextListener are visible.

@WebListener
public class SpringContextListener implements ServletContextListener {
    private ConfigurableApplicationContext applicationContext;

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        applicationContext = SpringApplication.run(PetclinicApplication.class, new String[]{});
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        if (applicationContext != null) {
            applicationContext.close();
        }
    }
}

Manual-path skeleton (compare with the official full procedure):

public class ServletInitializer extends SpringBootServletInitializer {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(PetclinicApplication.class);
    }
}

The run view shows the title “Run our web application in Servlet Container” and log lines “Spring Boot (v4.0.5)” and “Started application in 0.468 seconds”.


7. Transitional static access: ApplicationContextHolder and ApplicationContextInitializer
#

(1) Rationale: Legacy code that reaches objects via getInstance() alongside Spring constructor injection hits initialization ordering issues. A static ApplicationContextHolder is widely considered an antipattern, but during a migration window it limits churn; the key is writing the context before any Bean may touch the Holder. ApplicationContextInitializer runs before bean definitions load (event semantics: SpringApplication — ApplicationContextInitializedEvent), and with SpringFactoriesLoaderMETA-INF/spring.factories and the key org.springframework.context.ApplicationContextInitializer, you can push Holder initialization to a safe point.

(2) Implementation anchors: Implementations, key/value line continuation in META-INF/spring.factories, and Objects.requireNonNull guards inside the Holder.

(3) How to use it:

The slide shows “Introduce ApplicationContextInitializer to init holder,” a fragment implementing ApplicationContextInitializer<ConfigurableApplicationContext>, and a spring.factories registration sketch.

The slide title “Introduce Context holder,” public final class ApplicationContextHolder, and the English note “This is a major antipattern” appear together.

public final class ApplicationContextHolder {
    private static ApplicationContext ctx;

    public static void setApplicationContext(ApplicationContext applicationContext) {
        ApplicationContextHolder.ctx = Objects.requireNonNull(applicationContext);
    }

    public static <T> T getBean(Class<T> type) {
        return ctx.getBean(type);
    }

    private ApplicationContextHolder() {}
}
public class ApplicationContextHolderInitializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        ApplicationContextHolder.setApplicationContext(applicationContext);
    }
}
# META-INF/spring.factories
org.springframework.context.ApplicationContextInitializer=\
com.example.petclinic.spring.ApplicationContextHolderInitializer

8. From singleton factories to @Bean / @Service: first domain components
#

(1) Rationale: Moving modules that still read configuration by hand (for example data-source configuration classes) into @Configuration + @Bean delegates “how to construct” to the container; legacy getInstance() can temporarily delegate to ApplicationContextHolder.getBean(...) so callers can be replaced file by file and module by module.

(2) Implementation anchors: @Bean methods, @Configuration classes, @Service stereotypes, @Import for configuration aggregation, and constructor injection replacing new in fields.

(3) How to use it:

The slide title “Define first bean” shows @Bean public DatabaseConfig databaseConfig(), DatabaseConfigConfiguration, and related sketches.

The slide shows “Define more beans,” “annotate as @Service,” and public class OwnerRepository.

@Configuration
public class DatabaseConfigConfiguration {
    @Bean
    public DatabaseConfig databaseConfig() {
        return new DatabaseConfig("jdbc:postgresql://localhost/petclinic", "user", "secret");
    }
}
public class DatabaseConfig {
    public static DatabaseConfig getInstance() {
        return ApplicationContextHolder.getBean(DatabaseConfig.class);
    }
    /* ... */
}
@Service
public class OwnerRepository {
    private final DatabaseConfig databaseConfig;

    public OwnerRepository(DatabaseConfig databaseConfig) {
        this.databaseConfig = databaseConfig;
    }
}

Circular dependencies masked in a static graph may surface after moving into Spring; @Lazy and lazy-init semantics must be used with care relative to dependency direction—not as an architectural fix.


9. Modernizing Servlet mappings: @WebServlet and @ServletComponentScan
#

(1) Rationale: Replacing pure web.xml mappings with Jakarta Servlet annotations reduces deployment-descriptor drift; on the embedded-container path, Boot offers scan-based registration (@ServletComponentScan); on older lines you can register the same components explicitly with ServletRegistrationBean and friends.

(2) Implementation anchors: jakarta.servlet.annotation.WebServlet, @ServletComponentScan on the main class, ServletRegistrationBean / FilterRegistrationBean (see Servlet Web Applications).

(3) How to use it:

The slide title “Migrate servlets” shows “Before Spring Boot 4: use ServletRegistrationBean” and “@ServletComponentScan (since boot 4.0)” (OCR may miscapture the annotation name; the intent is @ServletComponentScan).

@WebServlet(urlPatterns = {"/api/owners", "/api/owners/*"})
public class OwnerServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setStatus(HttpServletResponse.SC_OK);
        resp.getWriter().write("ok");
    }
}
@SpringBootApplication
@ServletComponentScan
public class PetclinicApplication {
    public static void main(String[] args) {
        SpringApplication.run(PetclinicApplication.class, args);
    }
}

Demo convention: custom request headers matter only for cross-cutting observation; if doGet must read a header, use a name agreed with the client, such as req.getHeader("X-Demo-Trace").


10. Executable WAR: repackage, provided, and WEB-INF/lib-provided
#

(1) Rationale: The Spring Boot Maven plugin’s repackage goal produces a layout runnable with java -jar while still deployable as a standard WAR; marking embedded Tomcat provided lands related JARs under WEB-INF/lib-provided, reducing duplicate class loading against the external container’s own implementations (spring-boot-maven-plugin — packaging, Traditional deployment — lib-provided).

(2) Implementation anchors: <goal>repackage</goal>, spring-boot-starter-tomcat + <scope>provided</scope>, and the WEB-INF/lib-provided keyword.

(3) How to use it:

The slide title “Explain uber war” shows <artifactId>spring-boot-maven-plugin</artifactId>, “spring-boot-starter-tomcat,” provided, and related fragments (OCR may garble artifact names; the intent is spring-boot-starter-tomcat).

<packaging>war</packaging>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    <scope>provided</scope>
  </dependency>
</dependencies>
<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <executions>
        <execution>
          <goals><goal>repackage</goal></goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

11. Dual bootstrap paths and context path: duplicate-context risk and server.servlet.context-path
#

(1) Rationale: If the main-based path still triggers the same ServletContextListener (or another second bootstrap chain) as WAR deployment, nested Spring contexts until resource exhaustion are a plausible risk for that deployment style—the official manual may not walk every combination; treat duplicate banners in logs, duplicate Bean registration, or resource warnings as signals. Also: default context paths for java -jar vs. external container often differ; align with existing integration-test URLs.

(2) Implementation anchors: server.servlet.context-path (metadata describes the application’s context path); validate on three paths: container-only, main-only, and both Uber WAR startup modes.

(3) How to use it:

server.servlet.context-path=/petclinic
java -jar target/petclinic.war
curl -sS -o /dev/null -w "%{http_code}\n" "http://localhost:8080/petclinic/api/owners"

Architecture cross-check: layered migration and runtime paths
#

The diagram below compresses several demo steps into a single communicable static view (labels deliberately quote text containing @ and / to reduce renderer ambiguity).

Mermaid diagram 1

flowchart LR
  subgraph war_paths["One WAR, two entry points"]
    A["\"SpringBootServletInitializer.configure\""]
    B["\"ServletContextListener + SpringApplication.run\""]
  end
  subgraph packaging["Packaging semantics"]
    R["\"spring-boot-maven-plugin repackage\""]
    P["\"provided\" Tomcat -> WEB-INF/lib-provided"]
  end
  war_paths --> R
  R --> P

References and further reading
#

Related

Spring Boot 4 Stack Overview: Starter Granularity, MVC Version Negotiation, and Security Evolution

·1854 words·9 mins
With Spring Boot 4 and Spring Framework 7, dependencies split into finer starters by capability; outbound HTTP clients can be isolated from server-side MVC. The same codebase can enable built-in API version negotiation in Spring MVC alongside Spring Data JDBC and RestClient / declarative @HttpExchange clients. Spring Security 7 emphasizes composable Customizer<HttpSecurity>, one-time token login, WebAuthn, and annotation-driven multi-factor authentication.

Supercharging Spring Boot Tests with Kotlin Expressiveness: Assertions, Fixtures, and Reactive Boundaries

·1833 words·9 mins
Spring Boot and Kotlin interoperate maturely on the JVM; teams often introduce Kotlin first in src/test, applying extension functions, default parameters, type-safe DSLs, and assertion styles such as Kotest in integration tests and MockMvc scenarios to cut boilerplate and tighten failure messages. Meanwhile, Java builders, overloaded static helpers, and Project Reactor’s StepVerifier each carry their own cognitive cost; the article organizes common motivations by dependency layer, alignable public APIs, and semantic boundaries to watch (e.g. JVM type erasure, whether reactive verification truly completes subscription).

AI Coding Agents on Spring Projects: Live Pipelines, Verifiable Loops, and Context Governance

·2826 words·14 mins
For engineers already shipping services on the JVM and web stacks, this article starts from a typical Spring Boot + Kotlin real-time interactive application, traces the data path from database signals through reactive SSE to the browser, and breaks human–agent collaboration into three verifiable layers: compile-and-test closure, versioned project memory (CLAUDE.md, rules, Skills), and Hooks plus MCP on the tool-call path. The second half covers gaps in tests and state machines caused by spec-less iteration, how structured clarification (Interview) writes navigation and security decisions into the specification, and how cross-cutting steps can be silently dropped in long conversations—with ideas for segmented execution.