DEV Community

Cover image for Why your company's JSP app isn't dead - it just needs Spring Boot
Austin
Austin

Posted on

Why your company's JSP app isn't dead - it just needs Spring Boot

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Every servlet in the application follows this exact pattern:

  1. Create a SessionManager from the raw request/response
  2. Manually open a database connection
  3. Instantiate business objects, passing the connection around
  4. Set 10-30 request attributes as loose String key-value pairs
  5. Dispatch to a JSP
  6. Manually close the connection in a finally block (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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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>-->
Enter fullscreen mode Exit fullscreen mode

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:

  1. Generate a Spring Boot project (start.spring.io or your IDE)
  2. Set packaging to WAR (you're still deploying to your existing Tomcat)
  3. Add your database JDBC driver
  4. Add Spring Security with your CAS dependencies
  5. Add Thymeleaf (your JSP replacement)
  6. Configure application.properties with 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a corresponding .html file in src/main/resources/templates/
  2. Replace <%= request.getAttribute("foo") %> with th:text="${foo}"
  3. Replace JSTL <c:forEach> with th:each
  4. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 SimpleJdbcCall and 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)