Somewhere in your organization, there's a Java web application that everybody depends on and nobody wants to touch. It was written when Eclipse was the IDE, Tomcat 6 was cutting-edge, and web.xml was the center of the universe. It works. Students (or customers, or employees) use it every day. But every time someone files a ticket asking for a new feature, the team exchanges a look that says: who's going to open that codebase?
You don't need a rewrite. You don't need a microservices initiative or a "move to the cloud." You need Spring Boot, a week with the existing source code, and a strategy that lets you migrate one servlet at a time without taking the old system offline.
This article is that strategy. It's drawn from a real applications at Bradley University: MyBradley, a pure-servlet JSP application with 43 servlets and 60+ JSPs that has served students for over a decade, with a update in progress; and Employee Meal Plan, a finished Spring Boot app that shows what the end state looks like when you get there.
Part 1: Understanding what you actually have
Before you can migrate anything, you have to be honest about what "legacy" means in your specific case. Let's look at MyBradley. Not to shame it, but to understand the patterns that Spring Boot was designed to replace.
The anatomy of a servlet-era application
MyBradley is a textbook early-2000s J2EE application. No Spring. No Struts. No framework at all. Just raw HttpServlet classes dispatching to JSP views:
// MyBradley (Old): Home.java -- a 200+ line servlet
public class Home extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
SessionManager sessionManager = new SessionManager(request, response);
try {
sessionManager.getConnectionManager().OpenConnection();
UserLookupObject ulo = new UserLookupObject(
sessionManager.getConnectionManager(),
sessionManager.getNetworkId());
Integer studnum = ulo.Properties().getStudnum();
ProgramObject po = new ProgramObject(
sessionManager.getConnectionManager(), studnum);
IdentityObject ido = new IdentityObject(
sessionManager.getConnectionManager(), studnum);
String firstname = ido.Properties().getFirstName();
request.setAttribute("studnum", studnum);
request.setAttribute("firstname", firstname);
request.setAttribute("username", sessionManager.getUserName());
// ... 20 more setAttribute calls ...
RequestDispatcher dispatcher = request.getRequestDispatcher("Home.jsp");
dispatcher.include(request, response);
} catch (Exception e) {
sessionManager.HandleException(e.getMessage());
} finally {
sessionManager.getConnectionManager().CloseConnection();
}
}
}
Every servlet in the application follows this exact pattern:
- Create a
SessionManagerfrom the raw request/response - Manually open a database connection
- Instantiate business objects, passing the connection around
- Set 10-30 request attributes as loose
Stringkey-value pairs - Dispatch to a JSP
- Manually close the connection in a
finallyblock (hopefully)
And behind those business objects? Raw JDBC with CallableStatement, manually binding every parameter:
// MyBradley (Old): StudentRecordDataAccess.java (class names changed)
public class StudentRecordDataAccess {
private static String INSERT = "{callStudentRecordInsert({an amount of ?s})}";
private static String SELECT = "{callStudentRecordSelectByID({an amount of ?s})}";
private ConnectionManager connectionManager;
private CallableStatement callableStatement;
protected boolean Insert(StudentRecordProperties properties)
throws ClassNotFoundException, SQLException {
callableStatement = connectionManager.Connection().prepareCall(INSERT);
SetParameters(properties);
int retval = callableStatement.executeUpdate();
callableStatement.close();
return retval != 0;
}
}
Every domain concept in the system is implemented as this same three-class stack: a Properties bean with getters and setters, a DataAccess class wrapping raw JDBC, and an Object class as the public facade. It's 234 Java files across 55 packages, all wired together by a 200-line web.xml.
Cataloging the pain points
Before migrating, make an inventory. In MyBradley:
There is no connection pooling. Every request opens a new database connection and closes it when done. Under load, like during the first couple minutes of class registration, the database server is managing hundreds of short-lived connections instead of reusing a small pool. HikariCP alone would be a meaningful upgrade.
Resource management is manual everywhere. If an exception is thrown between OpenConnection() and CloseConnection(), you leak a connection. The codebase is full of try/catch blocks that may or may not clean up properly. There are no try-with-resources statements.
The SessionManager is a god object. It stores connection managers, user identity, flags, and business state in HttpSession. Three different constructor overloads extract slightly different subsets of session attributes. It's the kind of class where you're afraid to add a field because you don't know what will break.
There is no dependency injection. Every servlet manually creates its collaborators. Testing means deploying to Tomcat and clicking through a browser. The code wasn't designed to be unit-testable.
Configuration is scattered across XML, encrypted XML, and hardcoded strings. The database credentials live in an XML file encrypted with 3DES using a key that's hardcoded in the Java source. CAS URLs are in web.xml. Email addresses are hardcoded in servlet classes.
Security runs through a legacy CAS client. The application uses Yale's original CAS filter library (pre-Apereo), configured through web.xml init-params. Server names are commented-out and swapped manually for different environments:
<!-- web.xml: spot the deployment process -->
<param-value>localhost:8080</param-value>
<!--<param-value>app-server-1.example.edu</param-value>-->
<!--<param-value>app-server-2.example.edu</param-value>-->
<!--<param-value>app-server-3.example.edu</param-value>-->
The application works. It has worked for years. But every one of these pain points is a reason new features take longer than they should, bugs are harder to find, and onboarding a new developer takes weeks instead of days.
Part 2: The migration strategy, one layer at a time
The one rule of incremental migration: never be in a state where nothing works. You want to swap planks on the ship while it's still sailing. Here's the layer-by-layer strategy that MyBradley follows.
Phase 0: Set up the Spring Boot shell
Before touching any business logic, create the Spring Boot project that will become the new home. This is pure scaffolding:
- Generate a Spring Boot project (start.spring.io or your IDE)
- Set packaging to WAR (you're still deploying to your existing Tomcat)
- Add your database JDBC driver
- Add Spring Security with your CAS dependencies
- Add Thymeleaf (your JSP replacement)
- Configure
application.propertieswith your database and CAS URLs
Package as a WAR, not a JAR. Your ops team deploys to Tomcat. Your load balancer points at Tomcat. Your CAS service URLs reference paths on Tomcat. Don't fight that battle yet. Spring Boot's SpringBootServletInitializer lets you run as a WAR in an existing servlet container while still getting all of Spring Boot's auto-configuration:
public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(MybradleyApplication.class);
}
}
At the end of Phase 0, you have a Spring Boot app that starts up, authenticates via CAS, and shows a blank home page. Nothing else. Deploy it to a test server and verify the CAS round-trip works.
Phase 1: Migrate security first
Security is the outermost layer. In MyBradley, it's a CAS filter in web.xml:
<filter>
<filter-name>CAS Filter</filter-name>
<filter-class>edu.yale.its.tp.cas.client.filter.CASFilter</filter-class>
<init-param>
<param-name>edu.yale.its.tp.cas.client.filter.loginUrl</param-name>
<param-value>https://sso.example.edu/cas/login</param-value>
</init-param>
<init-param>
<param-name>edu.yale.its.tp.cas.client.filter.validateUrl</param-name>
<param-value>https://sso.example.edu/cas/serviceValidate</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
In the new MyBradley, this becomes a Spring Security configuration with the modern Apereo CAS client:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${cas.server-login-url}")
private String casServerLoginUrl;
@Value("${cas.server-url-prefix}")
private String casServerUrlPrefix;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
CasAuthenticationFilter casFilter,
CasAuthenticationEntryPoint entryPoint,
LogoutFilter logoutFilter) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/css/**", "/js/**", "/assets/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/admin/**").hasAnyAuthority(adminAuthorities)
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionFixation().newSession()
.maximumSessions(1)
)
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
)
.exceptionHandling(e -> e.authenticationEntryPoint(entryPoint))
.addFilter(casFilter)
.addFilterBefore(new SingleSignOutFilter(), CasAuthenticationFilter.class);
return http.build();
}
}
What you gain: environment-specific config via properties instead of commented-out XML. HSTS, session fixation protection, and frame options that the old app doesn't have at all. Proper single sign-out through Spring Security's filter chain. Role-based authorization (/admin/** restricted to a whitelist) instead of ad-hoc checks in each servlet. And you can write integration tests against the SecurityFilterChain without deploying to Tomcat.
Phase 2: Migrate controllers, keep the data layer
This is the insight that makes incremental migration practical: you don't have to migrate the data layer when you migrate the controllers. MyBradley proves it. Every controller in the new application is clean Spring MVC:
// MyBradley (New): HomeController.java -- 18 lines total
@Controller
public class HomeController {
@GetMapping("/")
public String home(@AuthenticationPrincipal UserDetails userDetails, Model model) {
model.addAttribute("username", userDetails.getUsername());
model.addAttribute("hasHolds", false);
return "home";
}
}
Compare this to the 200+ line Home servlet it replaces. The SessionManager is gone. The manual connection handling is gone. The RequestDispatcher is gone. Spring gives you @AuthenticationPrincipal instead of digging through session attributes, Model instead of request.setAttribute(), and a view name return instead of dispatcher.include().
But look at the repository layer underneath:
// MyBradley (New): PersonalInfoRepository.java -- still calling stored procedures
@Repository
public class PersonalInfoRepository {
private final JdbcTemplate jdbcTemplate;
public PersonalInfoRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public PersonalInfoDTO getPersonalInfo(String studentId) {
PersonalInfoDTO dto = new PersonalInfoDTO();
populateBasicIdentity(dto, studentId); // stored proc
populateAcademicRecord(dto, studentId); // stored proc
populateAddresses(dto, studentId); // stored proc
populatePreferences(dto, studentId); // stored proc
return dto;
}
private void populateBasicIdentity(PersonalInfoDTO dto, String studentId) {
SimpleJdbcCall call = new SimpleJdbcCall(jdbcTemplate)
.withProcedureName("StudentSelectById")
.declareParameters(new SqlParameter("student_id", Types.INTEGER));
// ... map results to DTO
}
}
That's the same stored procedure that MyBradley calls through raw CallableStatement. The database hasn't changed. The stored procedures haven't changed. But JdbcTemplate handles resource cleanup, SimpleJdbcCall handles parameter binding, and Spring's @Repository annotation makes the whole thing injectable and testable.
JdbcTemplate is the bridge. You can wrap every existing stored procedure call in a JdbcTemplate-backed repository and immediately get connection pooling via HikariCP (configured once in application.properties), automatic resource cleanup, constructor injection so you can test with mocks, and Spring's DataAccessException hierarchy instead of raw SQLException.
Phase 3: Replace JSPs with Thymeleaf templates
JSP replacement is the most visible part of the migration, but it's also the most mechanical. For each JSP:
- Create a corresponding
.htmlfile insrc/main/resources/templates/ - Replace
<%= request.getAttribute("foo") %>withth:text="${foo}" - Replace JSTL
<c:forEach>withth:each - Replace
<jsp:include>with Thymeleaf fragments
The real win is layout composition. MyBradley (Old) includes the header and sidebar by having each JSP call <jsp:include page="Header.jsp" />. MyBradley (New) uses Thymeleaf fragment layouts, where a single layout.html defines the page shell and each view fills in content blocks. Change the sidebar once, it changes everywhere. No more copy-paste across 60 JSPs.
Phase 4: Upgrade the data layer (when you're ready)
This is where Employee Meal Plan shows the end state. Once your controllers and services are stable, you can migrate from JdbcTemplate to Spring Data JPA for domains where it makes sense:
// EmployeeMealPlan: The JPA entity
@Entity
@Table(name = "meal_plans", schema = "app")
@Data
@NoArgsConstructor
public class MealPlan {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(name = "base_price", nullable = false, precision = 10, scale = 2)
private BigDecimal basePrice;
@Column(name = "tax_rate", nullable = false, precision = 5, scale = 4)
private BigDecimal taxRate;
@Column(name = "meals_count")
private Integer mealsCount;
private Boolean active = true;
}
// EmployeeMealPlan: The repository -- the entire data access layer
@Repository
public interface MealPlanRepository extends JpaRepository<MealPlan, Long> {
List<MealPlan> findByMealsCountGreaterThan(Integer count);
List<MealPlan> findByTitleContainingIgnoreCase(String title);
}
That 13-line interface replaces what would be a Properties class, a DataAccess class, a Factory class, and an Object class in MyBradley. Probably 200+ lines of manual JDBC boilerplate.
Part 3: What you get for free
The case for Spring Boot isn't any single feature. It's everything you stop doing by hand.
Connection pooling
MyBradley opens and closes a fresh database connection on every HTTP request. MyBradley adds two lines to application.properties:
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
Now 10 connections are shared across all requests. The database server went from managing hundreds of ephemeral connections to 10 stable ones. Response times drop. Connection timeouts disappear. Zero lines of Java.
Externalized configuration
MyBradley encrypts database credentials with 3DES using a key that's hardcoded in the Java source and stores them in an XML file. Switching environments means manually editing XML and redeploying.
MyBradley uses Spring profiles:
# application-dev.properties
spring.datasource.url=jdbc:yourdriver://dbhost.example.edu:5000/devdb
# application-production.properties
spring.datasource.url=jdbc:yourdriver://dbhost.example.edu:5000/proddb
Switch environments with --spring.profiles.active=production. No code changes. No recompilation. No commented-out server names.
Observability
Employee Meal Plan includes Spring Boot Actuator with a Prometheus metrics endpoint:
management.endpoints.web.exposure.include=health,info,metrics,prometheus
That gives you request latency histograms, JVM memory metrics, HikariCP pool utilization, and custom health checks, all exportable to Grafana or whatever your ops team uses. MyBradley has e.printStackTrace() and silent catches.
Structured logging
Employee Meal Plan uses Logback with a Logstash encoder that outputs JSON with correlation IDs:
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>correlationId</includeMdcKeyName>
</encoder>
Every log line from a single request shares a correlation ID. When a user reports a problem, you search for one ID and see the entire request lifecycle. MyBradley calls sessionManager.HandleException(e.getMessage()), which may or may not print something to stdout.
Security headers
The old application sends none. The new one sends HSTS, X-Frame-Options, Referrer-Policy, and session fixation protection out of the box. These are features you were always supposed to have but couldn't justify hand-coding in every servlet's response.
Rate limiting
Employee Meal Plan adds Bucket4j rate limiting on purchase endpoints:
bucket4j.enabled=true
bucket4j.filters[0].url=^/purchase/.*
bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=10
bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes
Try adding rate limiting to 43 individual servlets in MyBradley. Now stop trying.
Part 4: The migration checklist
Here's the concrete sequence that MyBradley follows:
What to migrate first
| Priority | Component | Why first |
|---|---|---|
| 1 | Build system (Maven/Gradle) | Everything else depends on reproducible builds |
| 2 | Security (CAS/LDAP/OAuth) | It's the outermost layer; get it right before touching pages |
| 3 | Database connectivity | HikariCP + JdbcTemplate replaces manual connection management |
| 4 | Highest-traffic pages | Migrate the home page first so users see the new UI immediately |
| 5 | CRUD pages | Forms, lists, detail views. These are mechanical. |
| 6 | Edge cases and admin tools | Lowest traffic, least urgency |
What to carry forward
Not everything from the old system is bad. MyBradley explicitly preserves:
- Stored procedures. They encode business rules that have been refined for years. Wrap them in
SimpleJdbcCalland move on. You can migrate to JPA queries later, or not. - The database schema. Don't combine a Spring Boot migration with a database redesign. That's two high-risk projects disguised as one.
- CAS authentication. The protocol is the same. You're upgrading the client library (Yale to Apereo) and the configuration mechanism (XML to Spring Security), not the identity infrastructure.
- WAR deployment. Your Tomcat servers work. Your deployment scripts work. Keep packaging as WAR. Embedded Tomcat is nice, but it's not worth a simultaneous ops migration.
What to leave behind
| Old pattern | New pattern |
|---|---|
web.xml servlet mappings |
@Controller + @GetMapping
|
SessionManager god object |
@AuthenticationPrincipal + service injection |
ConnectionManager.OpenConnection() |
HikariCP auto-configured by Spring Boot |
request.setAttribute("key", value) x 30 |
DTOs populated by service layer |
RequestDispatcher.include(JSP) |
Return a Thymeleaf view name |
Properties/DataAccess/Object classes |
@Repository + JdbcTemplate (or JPA) |
| 3DES encrypted XML config | application-{profile}.properties |
HandleException(e.getMessage()) |
@ControllerAdvice + structured logging |
| Commented-out server names in XML | Spring profiles |
Part 5: The honest tradeoffs
What's hard
Stored procedure mapping is tedious. MyBradley's PersonalInfoRepository calls 9+ stored procedures to build a single DTO. Each one requires a SimpleJdbcCall with manually declared parameters and a custom RowMapper. It's better than raw JDBC, but it's still boilerplate. Budget time for it.
Thymeleaf and JSP think differently. JSPs are essentially Java code that emits HTML. Thymeleaf templates are HTML documents with special attributes. If your JSPs contain significant scriptlet logic (<% ... %>), you'll need to move that logic into the controller or a service before the template migration makes sense.
The old and new apps can't share sessions. If you're running both simultaneously during migration (old app handles pages you haven't migrated yet, new app handles migrated pages), users may need to authenticate separately to each. Plan your CAS service URLs accordingly.
Database-specific quirks don't disappear. If your legacy database doesn't fully implement the JDBC spec, you'll still need workarounds. Spring Boot doesn't magically fix them, but it does give you a single place to put the workaround instead of scattering it across 43 servlets.
What's worth it
MyBradley is 234 Java files in 55 packages. MyBradley, which already handles the same core pages (home, schedule, grades, transcript, personal info, registration, holds, advisors), is 8 controllers, 7 services, 8 repositories, and 20 DTOs. Roughly 2,700 lines of Java. The functionality nearly is the same. The code is a fraction of the size.
Employee Meal Plan goes further: JPA entities, dual data sources, payment gateway integration, field-level encryption, rate limiting, structured logging, Prometheus metrics, custom health checks. A new developer could understand the whole thing in a day. One developer built it.
Conclusion
Your servlet application isn't a failure. It's a success that outlived its architecture. Raw servlets, manual JDBC, session-based state management, XML configuration: these were the standard approach when it was written. They solved real problems. They just solve them in ways that create new problems as the application grows and the team changes.
Spring Boot doesn't ask you to throw that investment away. Start with the build system. Then security. Then connection pooling. Then one controller. The application works the entire time. The codebase gets smaller with each phase. And you pick up things like observability and security hardening that would have been impractical to bolt onto the old architecture.
MyBradley (Old) has 43 servlets and 234 Java files. MyBradley (New) has 8 controllers and clean separation of concerns. Employee Meal Plan has JPA, dual data sources, payment processing, and production observability in under 3,000 lines of Java. The servlet app isn't dead. It just needs Spring Boot, a plan, and one controller at a time.
All code examples in this article are drawn from applications at Bradley University. The migration from MyBradley (Old) to MyBradley (New) is ongoing. Employee Meal Plan runs on Spring Boot 3.5.6.
Top comments (0)