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:

<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).


@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:

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);
}
}

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 SpringFactoriesLoader — META-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:

ApplicationContextInitializer<ConfigurableApplicationContext>, and a spring.factories registration sketch.

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:

@Bean public DatabaseConfig databaseConfig(), DatabaseConfigConfiguration, and related sketches.

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:

@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:

<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).

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#
- Codehaus Cargo — Maven 3 Plugin usage and lifecycle integration
- Codehaus Cargo — Tomcat 10.x container id
tomcat10x - Maven — Build lifecycle phase responsibilities
- Spring Initializr (start.spring.io)
- Spring Boot — System requirements (Java version range)
- Spring Boot 3.0 GA blog — Java 17 and Jakarta EE direction
- Spring Boot — Build system,
spring-boot-dependenciesBOM, and Starters table - Maven — Importing Dependencies (
importscope semantics) - Spring Boot — Logging reference (default Logback)
- Spring Boot — How-to: switch to Log4j 2 (includes exclusion example)
- Spring Boot — Structuring your code and
@SpringBootApplicationscan boundaries - Spring Boot — SpringApplication and web environment detection
- Spring Boot — Auto-configuration,
--debug, exclusions, andDataSourceAutoConfigurationexamples - Spring Boot — Traditional deployment (WAR,
provided, executable layout,SpringBootServletInitializer) - Spring Boot —
SpringBootServletInitializerJavaDoc - Jakarta Servlet —
ServletContextListenerAPI - Jakarta Servlet —
@WebListenerJavaDoc - Spring Framework —
SpringFactoriesLoader(META-INF/spring.factories) - Spring Framework —
ApplicationContextInitializerJavaDoc - Spring Framework —
@Lazyand lazy-init semantics - Spring Boot — Servlet Web (
@ServletComponentScanand RegistrationBeans) - Spring Boot Maven Plugin —
repackageand WAR layout



