All posts

How to Migrate ColdFusion to Java Spring Boot with Opsera Forge

Banner titled "How to Migrate ColdFusion to Java Spring Boot with Opsera Forge" with a circular flow from ColdFusion through Analyze, Plan, Convert, Test and Review to Spring Boot

Key Takeaways

  • Each part of a ColdFusion app has a clear home in Spring Boot: .cfm pages become controllers with Thymeleaf views, .cfc components become services, and queryExecute SQL moves into repositories
  • Forge's modernization score, ForgeScore, rated the example app 64 out of 100 across 446 files before any code changed, and named the exact files behind each risk
  • The plan keeps the existing MySQL schema and Flyway migrations, so the old and new apps can share one database during the move
  • The ColdFusion app and the new Spring Boot app run side by side behind NGINX, and each group of pages moves over only after it matches the old behavior, with a rollback target of under 15 minutes
  • Forge split the work into 69 Work Orders worth 416 story points, and only four of them can start before others are done
  • The first Work Order ran from Claude Code end to end, with 4 passing tests and a live Spring endpoint, and every step showed up on Forge's Kanban board

Suppose we have a company that runs its customer portal and billing on a ColdFusion app built years ago. The app still works, but the team finds it hard to hire ColdFusion developers, and every change to the large page files feels risky.

The company wants to move the app to Java, where its other teams already work. This walkthrough follows that move to Java 21 and Spring Boot 3 with Opsera Forge, the orchestration layer that sits above AI coding agents such as Claude Code and Cursor, using a real open-source ColdFusion app so you can repeat each step on your own code.

Here is the whole path at a glance, from the first ForgeScore to the day the last page moves.

Seven-step path from scoring the app with ForgeScore, agreeing the goal, picking a modular monolith, planning Work Orders, building in Java, proving parity and moving routes behind NGINX with rollback

What a ColdFusion app is made of

ColdFusion is a server-side way of building web apps in CFML, the ColdFusion Markup Language. CFML code runs on an engine, either Adobe ColdFusion (commercial) orLucee (open source).

Most ColdFusion apps are built from the same few parts:

  • .cfm pages: templates that mix HTML with CFML tags, or handlers that read a submitted form, change data, and redirect
  • .cfc components: files that group related functions, such as everything to do with customers, much like a class in Java
  • Application.cfc: one file that sets up the app, starts sessions and runs checks on every request
  • Inline SQL: queries written straight into pages and components with <cfquery> or queryExecute(), run against a database connection called a datasource
  • Scheduled tasks: URLs the engine calls on a timer to run background jobs
  • REST APIs: endpoints for other systems, often built with a small framework such as Taffy

Both engines run on the Java Virtual Machine (JVM). That makes Java a natural next home, because the move changes the language and framework while the runtime underneath stays familiar.

Why companies move ColdFusion apps to Java

For the company in our story, the reasons are practical. The app works, but it is getting harder to staff, change, and run with confidence.

  • People: the rest of the engineering team writes Java, so ColdFusion work queues behind one or two specialists
  • Change risk: large .cfm handlers mix form handling, business rules, and SQL, so a small edit can break something far away
  • Tooling: Spring Boot brings dependency injection, Spring Security, test tooling and monitoring that the team already uses elsewhere
  • One platform: running every service on Java means one build pipeline, one way to deploy and one set of security checks

The team also ruled out two shortcuts. A full rewrite in one go would freeze the app for months and switch everyone over on a single day. Pointing an AI coding tool at the whole codebase would produce lots of Java quickly, with no agreed plan for what the new app must keep doing or how to prove it does.

The example app: a ColdFusion customer portal with billing

The app in this story is Saaster, an open-source ColdFusion base for software businesses. It handles sign-up, login with two-step verification (MFA), customers, plans, add-on modules, invoices, card payments through the Payrexx payment provider, admin settings, scheduled jobs, and a REST API.

It runs on Lucee 6 with MySQL 8, NGINX in front, and Docker Compose to start it all. Database changes are kept as numbered files that Flyway, a migration tool, applies in order, and a TestBox test suite covers parts of the app.

Before touching any code, it helps to see where each ColdFusion piece ends up. This is the map the team works towards, using real files from the repo.

ColdFusion pieceExample in SaasterWhat it becomes in Spring Boot
.cfm page handlerswww/backend/core/handler/sysadmin/modules.cfmA Spring MVC controller, a command object, a validator and a service
.cfm viewsBackend and frontend templates under www/backend and www/frontendThymeleaf templates, Spring's way of rendering HTML pages on the server
.cfc componentswww/backend/core/com/sysadmin.cfcSpring service classes with clear transaction boundaries
Inline SQL with queryExecute()Admin queries in sysadmin.cfcRepositories on Spring JDBC or Spring Data JDBC, with explicit column lists
Application.cfc request checksRole checks, CSRF checks and session handling in www/Application.cfcSpring Security filter chains
Taffy REST resourceswww/api/resources/authenticate.cfcSpring REST controllers that keep the same routes, headers and JSON
Scheduled task URLswww/backend/core/com/scheduler.cfcSpring @Scheduled jobs that keep the database locking rules
Flyway migrationsconfig/db/core/V1__baseline.sql to V24The same Flyway migrations, kept as they are
TestBox testswww/tests/specsJUnit 5, MockMvc and Testcontainers tests
Lucee behind NGINXcompose.yml and the NGINX configA Java 21 Docker image, with NGINX splitting traffic between old and new

Scoring the ColdFusion app with ForgeScore

The team's first step is to create a modernization project in Forge and point it at the repo's GitHub URL. Forge reads the whole codebase and gives it a ForgeScore, a modernization score out of 100 that rates the code across eight areas, from how well it guards its entry points to how easy it is to change.

Saaster came back at 64 out of 100, rated Established. That is a solid score for a working app, with clear weak spots that the move to Java can fix by design.

Forge screen with a ForgeScore of 64 out of 100 rated Established, eight dimension scores and a security snapshot showing 1 secret, 2 CVEs and 2 end-of-life components

The ColdFusion app scored 64 out of 100, with the security snapshot underneath. In Forge: open the project, then Assessment > ForgeScore in the left panel.

Forge's overview also sizes the job before anyone estimates it by hand:

  • 446 files, including 194 .cfm files and 100 .cfc components
  • 55 HTTP endpoints across pages and the REST API
  • 29 SQL migration and seed files
  • 65 test files
Forge application overview text with the scale figures highlighted: 446 files, 55 HTTP endpoints, 194 CFM files, 100 CFC components and 65 test files

Forge sizes the codebase before the plan starts. In Forge: Assessment > App Overview.

The security snapshot on the ForgeScore screen shows a C+ for security, a B on the CISQ code quality standard, 1 secret in the code, 2 known vulnerabilities (CVEs), 2 packages past end of life, and no circular dependencies.

Bar chart of the eight ForgeScore areas from System Gravity at 74 down to Data Weight at 56, with Trust Boundaries at 58 and Data Weight marked lowest against an overall score of 64

The two lowest scores were Data Weight at 56, which looks at how the app reads and writes data, and Trust Boundaries at 58, which looks at how it checks who can do what. Forge's top findings each point at a specific file:

  • API keys checked in one place: www/api/resources/authenticate.cfc reads the apiID and apiKey headers and validates them inside that one resource, so every new API route has to repeat the pattern
  • A real-looking password in example config: config/example.config_dev.cfm ships saaster2024 as the scheduler password
  • Handlers that do everything: www/backend/core/handler/sysadmin/modules.cfm parses the form, applies business rules, and runs SQL in one file
  • Full-row reads: SELECT * in admin queries in www/backend/core/com/sysadmin.cfc
  • Writes in a loop: creating a module inserts one price row per currency, one query at a time

Forge also listed what is worth carrying over, such as SQL that already uses bound parameters almost everywhere, CSRF protection that is backed by tests, and a disciplined set of numbered database migrations.

Writing the Living Specs: what the Java app must keep doing

With the score in hand, Forge writes down what the app does today and what the migration has to achieve. Forge calls these Living Specs: linked documents covering the goal, the requirements, and the target architecture, kept in step with the code as the work moves on.

The first document is the intent profile. Forge's first draft scoped the work as a security clean-up inside the ColdFusion app, because that is where the lowest scores pointed. The team replied in Forge's chat that the goal is a full move to Java 21 and Spring Boot 3, and Forge rewrote the intent around that.

Forge intent document saved as version 2, with the vision highlighted: migrate the ColdFusion app to a Java 21 Spring Boot 3 modular monolith through a staged side-by-side move behind NGINX

The intent after the team's reply, saved as version 2. In Forge: Intent > Intent profile, with the version picker at the top.

The new intent sets out seven core features in priority order:

  1. A full migration of the .cfm handlers, .cfc components, views, API and scheduled jobs to Spring Boot
  2. One Spring Security gateway for browser logins, API calls, scheduled jobs, MFA, CSRF, sessions and audit logs
  3. Repositories on the existing MySQL 8 schema
  4. Spring REST controllers in place of the Taffy API
  5. Scheduled jobs run by Spring
  6. Server-rendered Thymeleaf pages in place of .cfm views
  7. A Docker image for the Spring Boot app, with NGINX moving routes over in stages
Core features list in the Forge intent document, with three acceptance criteria highlighted for side-by-side running, parity validation before cutover and a rollback path to Lucee

Each core feature includes acceptance criteria, including parity tests that check that a migrated page behaves exactly like the old one, plus a rollback path for every route group. In Forge: Intent > Core features.

Forge was just as clear about what is out of scope. There is no new database engine, no microservices, no single-page frontend, no change to pricing or billing rules, and no switch-over in one go. Forge rated its confidence in this intent at 94%.

The second document, the PRD-Spec, turns the intent into 10 high-level user stories with acceptance criteria, separate from the Work Orders that come later, plus a set of success measures. The main ones are:

  • 100% of route groups pass parity tests before they go live, meaning the new pages give the same redirects, messages, and database changes as the old ones
  • 100% of migrated route groups can be rolled back within 15 minutes
  • Every login, failed login, MFA step, API sign-in and admin change writes an audit entry
  • No unresolved data mismatches across customers, bookings, invoices, payments or scheduled jobs
  • Error rates on migrated routes stay below 1% during the first seven days

Choosing the target architecture for the Java app

Forge's architecture document picks a Spring Boot modular monolith: one Java application split into clear modules, sharing the same MySQL database. It explains why the team isn't splitting the app into microservices: customers, bookings, payments, and scheduled jobs all share the same tables and would need cross-service transactions.

Forge architecture executive summary rated 99 percent confidence, describing a Java 21 and Spring Boot 3 modular monolith with parity before redesign and a centralized trust boundary

The architecture summary, which Forge rated at 99% confidence. In Forge: Architecture > Architecture Executive Summary.

The new app is organized by business area, using the existing ColdFusion files as evidence for each module:

Spring Boot moduleWhere the logic lives todayWhat it owns
Identity accessregister.cfm, loginthrottle.cfc, user.cfcLogin, MFA, login throttling, roles, support login
Tenant customercustomer.cfm, customers.cfmCustomers, tenants, links between customers and users
Catalogplans.cfm, modules.cfm, sysadmin.cfcPlans, modules, currencies, prices, translations
Billing paymentpayment.cfmPayrexx payments, invoices, reconciliation
Schedulerscheduler.cfcCreating, claiming and releasing scheduled jobs
APIwww/apiREST routes, API sign-in, OpenAPI docs

A few rules from the document carry straight into the code:

  • No line-by-line ports: each large handler becomes a controller, a command object, a validator, a service, a repository and a parity test
  • Plain SQL first: Spring JDBC or jOOQ for complex queries and the scheduler's row locking, and Spring Data JDBC only where the data shape is simple, with no switch to an ORM during the move
  • One writer per route group: while both apps share the database, only one of them owns each group of pages and each scheduled job

Forge checks the architecture against the intent and the 29 policies it enforces for the project. It covered all 7 core features and rated its confidence in the design at 99%.

Breaking the migration into Work Orders

The approved spec and architecture now become the work plan. Forge breaks the migration into Work Orders: small, self-contained tasks, each with the files to change, the tests to pass and a clear definition of done.

For Saaster, Forge produced 69 Work Orders worth 416 story points, a rough measure of effort, grouped into 10 areas.

AreaWork OrdersStory points
Sysadmin, catalog, booking and admin pages755
Spring Security gateway, sessions, MFA, CSRF and audit852
Identity, customer account and Thymeleaf pages747
Spring-managed scheduler jobs747
Taffy API replacement with Spring REST and OpenAPI845
Repositories on the existing MySQL schema742
Payrexx, invoices and billing parity639
Spring Boot foundation and side-by-side runtime833
Route inventory and parity test harness631
Rollout, rollback drills and promotion gates525
Forge user stories screen showing 10 epics and 69 stories, with the first Work Order, Create Spring Boot Maven foundation, listed at 5 points and 50 hours

Forge grouped the plan into 10 areas, with points and hours on every Work Order. In Forge: open the User Stories stage, where Forge lists the Work Orders.

Only four Work Orders start with nothing blocking them:

  • WO-001 creates the Spring Boot Maven project
  • WO-002 replaces the real-looking secrets in the example config
  • WO-003 lists every legacy route and which app owns it
  • WO-004 writes down exactly how the scheduler behaves today

Everything else waits for its foundations, so the security filter chains come before the API sign-in, and the NGINX route split comes before any rollback drill.

Dependency diagram of two Work Order chains: the API sign-in chain from WO-010 to WO-055 and the route split chain from WO-013 to WO-065, both starting from WO-003 and WO-001

One Work Order up close: the API sign-in endpoint

Take WO-038, which moves the API's /authenticate endpoint from Taffy to Spring. Here is the heart of the ColdFusion version in www/api/resources/authenticate.cfc:

component extends="taffy.core.resource" taffy_uri="/authenticate" {
    function get(){
        local.apiID = request._taffyrequest.headers.apiID ?: 0;
        local.apiKey = request._taffyrequest.headers.apiKey ?: 0;
        if(local.apiID eq 0 or local.apiKey eq 0){
            return noData().withStatus(400);
        }
        local.qCheckApiKey = queryExecute(
            options = {datasource = application.datasource},
            params = {thisApiID: {type: "numeric", value: local.apiID}},
            sql = "SELECT strApiName, strApiKeyHash, strApiKeySalt, dtmValidUntil
                   FROM api_management WHERE intApiID = :thisApiID LIMIT 1"
        )
        // ...hash check, 401 on a bad or expired key, 60-second JWT,
        // then a row written to api_nonce
        return rep(["token" : local.enocodedJWT]);
    }
}

One file reads the headers, runs the SQL, checks the key, signs the token, and writes the nonce. The Work Order splits that apart while keeping what API clients see exactly the same:

  • Goal: a Spring REST endpoint at /authenticate that keeps the apiID and apiKey header names, the status codes, and the token field
  • Shape: a thin controller that only reads headers and formats the response, with credential checks, token signing, and nonce storage in injectable services and a Spring JDBC repository
  • Done means: MockMvc tests for the 400 cases, a test against a throwaway MySQL container, using Testcontainers, that gets a 200 and finds exactly one api_nonce row, a test that the token expires within 60 seconds, and a 401 test for an expired key that writes no nonce
  • Edge cases: blank headers count as missing, and a 401 never says whether the ID or the key was wrong
  • Size: 8 story points, about 80 hours
  • Blocked by: WO-031, which ports the API key and JWT logic into Spring Security, and WO-015, which adds masked audit events

When a developer connects Claude Code or Cursor to Forge through MCP, the standard way AI tools plug into outside services, Forge hands over the next Work Order with all of this attached. If a blocker is still open, Forge tells the editor not to start the story yet.

Moving pages from ColdFusion to Spring Boot in stages

Timeline of seven migration phases over about 52 weeks, from 3 weeks of mobilization to 6 weeks of stabilization, with Lucee serving unmoved routes until it is retired

The company never switches everyone over on one day. Lucee and Spring Boot run side by side behind NGINX, which already sits in front of the app, and a route ownership list says which app serves each group of pages.

When a route group passes its parity tests, NGINX starts sending its traffic to Spring Boot. If something goes wrong, the team points that route back at Lucee. Rolling back is a routing change, with a target of under 15 minutes, and a database restore is kept for real disasters only.

Forge's plan spreads the move over seven phases and about 52 weeks:

  1. Mobilization, 3 weeks: list every route, workflow, and role, and agree on the parity checklist. No traffic moves.
  2. Spring foundation, 5 weeks: the Spring Boot app, Docker image, health checks, CI, and the NGINX route split. Done when one low-risk route can go live and roll back.
  3. Security parity, 7 weeks: login, logout, MFA, CSRF, sessions, roles, API sign-in, and audit logs in Spring Security. Done when every auth parity test passes.
  4. Repositories, 9 weeks: users, customers, plans, bookings, invoices, payments, and scheduled jobs on the shared MySQL schema. Done when Flyway validates and data reconciles.
  5. API and scheduler, 8 weeks: Spring REST controllers replace Taffy and Spring runs the scheduled jobs. Done with no API breaks and no job run twice.
  6. Admin pages, 14 weeks: Thymeleaf pages for accounts, customers, plans, modules, invoices, payments, and settings. Done when pages match and pass WCAG 2.1 AA.
  7. Stabilization, 6 weeks: widen Spring's share, watch the numbers and decide when to retire Lucee. Done after 30 days without critical parity defects.

Scheduled jobs follow a stricter rule than pages. Only one app may run a given job family at a time, so moving jobs over, or back, means switching the current owner off before the other app takes over.

Running the first Work Order from Claude Code

To see what this looks like in practice, the team runs WO-001, the Work Order that creates the Spring Boot project. It opens the Spring foundation phase; it is one of the four with no blockers, and every later Java Work Order builds on it.

Connecting the editor to Forge

Forge talks to editors through MCP. In Claude Code, one command adds it, using a token from the Connect IDE tab in Forge:

claude mcp add --transport http forge https://forge.opsera.ai/api/mcp \
--header "Authorization: Bearer <your-forge-token>"

In Cursor, the same server goes into .cursor/mcp.json:

{
"mcpServers": {
"forge": {
"url": "https://forge.opsera.ai/api/mcp",
"headers": { "Authorization": "Bearer <your-forge-token>" }
}
}
}

Forge's board also has a Connect IDE tab with a one-click install and a place to create the token.

Forge Connect IDE tab with an Install in IDE button highlighted for one-click setup of the Forge MCP server, and a manual step to create an API token

In Forge: open the project board, then the Connect IDE tab.

From there, the developer asks the editor to pick up the next Work Order. Forge answers with a fixed routine the editor has to follow:

  1. Summarise the Work Order: goal, scope, files, and blockers
  2. Stop if any blocker is still open
  3. Read the project's architecture and the files the Work Order touches
  4. Follow the implementation steps in order, with no stubs or TODOs
  5. Review its own diff, then run the tests and mark each acceptance criterion as pass or fail with evidence
  6. Commit with a message tagged [WO-001], then report the results back to Forge, which updates the board

One step is easy to miss. The approved Work Orders only reach the editor after someone clicks Save Application Context in the project, which pushes them onto Forge's board. Before that, the editor can read a Work Order but cannot claim it or change its status.

What WO-001 asked for

The Work Order lists seven steps and five acceptance criteria. In short, it wants a Java 21, Spring Boot 3 Maven project at the repo root, a context test, and one endpoint at GET /spring/platform that returns runtime set to spring-boot. It also sets three hard rules: no change to www/Application.cfc, no live MySQL needed for tests, and no Spring path that could shadow a Lucee path such as /dashboard.

The controller that came out of it is small on purpose:

@RestController
@RequestMapping("/spring")
public class PlatformController {
static final String RUNTIME = "spring-boot";
static final String APPLICATION = "saaster";
@GetMapping("/platform")
public PlatformInfo platform() {
return new PlatformInfo(
RUNTIME,
APPLICATION,
Runtime.version().feature(),
SpringBootVersion.getVersion());
}
}

A small security config makes GET /spring/platform the only public path, so every other request gets a 401 until the security Work Orders bring over the real login rules. The commit touched 12 files: 9 new ones for the Java project, plus updates to readme.md, .gitignore, and the coding instructions the editor reads.

Proving it works: tests, the jar, a live call

The tests run without a database, as the Work Order demands:

$ mvn test
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.saaster.platform.PlatformControllerTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.saaster.SaasterApplicationTests
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

The build produces target/saaster-spring-0.1.0.jar, and starting it next to Lucee gives the expected answers:

$ curl -i http://localhost:8081/spring/platform
HTTP/1.1 200
Content-Type: application/json
{"runtime":"spring-boot","application":"saaster","javaVersion":21,"springBootVersion":"3.5.16"}
$ curl -i http://localhost:8081/dashboard
HTTP/1.1 401

All five acceptance criteria pass, and nothing in the ColdFusion app changed. With WO-001 done, three Work Orders that were waiting only on it can start: Flyway validation in WO-005, cross-runtime logging in WO-006, and the health checks in WO-007.

Tracking the Work Order on Forge's Kanban board

The team does not need to ask the developer how far along a Work Order is. Every step the editor takes shows up on Forge's Kanban board, where each Work Order is a card that moves from Backlog to In Progress, In Review, and Completed.

Forge Kanban board with 68 Work Orders in Backlog and WO-001, Create Spring Boot Maven foundation, highlighted in the In Review column

WO-001 sits in In Review while the other 68 Work Orders wait in Backlog. In Forge: Application Context > the project > Stories > Kanban.

Opening the card shows the full history. The Activity tab logs each status change, and the Comments tab holds the editor's report against every acceptance criterion.

Activity tab of WO-001 with the timeline highlighted, showing status changes from backlog to in progress and then to in review

Each move on the board is logged with a time. In Forge: click the card, then Activity.

Comments tab of WO-001 with the editor's report highlighted, listing five acceptance criteria marked PASS with evidence such as the Maven test run and the built jar

The editor's report lists each acceptance criterion as PASS with its evidence, plus the test results and the live check. In Forge: click the card, then Comments.

A reviewer reads that report, checks the change, and moves the card to Completed. From there, the board's Dependencies view shows which Work Orders are now free to start.

Adapting this plan to your own ColdFusion app

Saaster is a fairly modern ColdFusion app, so a few things change when you run the same steps on an older codebase.

  • Tag-based code: older apps write logic in tags such as <cfif> and <cfquery> inside page files, with little separation into .cfc components. Expect to design more of the service layer during the move.
  • Adobe ColdFusion: apps on Adobe's engine often use built-in features such as cfdocument for PDFs, cfmail, or the ColdFusion administrator's datasources. List them in the mobilization phase, because each one needs a Java library or a Spring feature to replace it.
  • No tests: if your app has no test suite, write the everyday tasks staff and customers perform as a checklist first. Forge turns that list into acceptance criteria, and it becomes your parity test plan.
  • No reverse proxy: if traffic reaches ColdFusion directly, put NGINX or a load balancer in front before Phase 2, so pages can move one group at a time.
  • Shared database with other systems: keep the schema authoritative and add changes only through your migration tool, exactly as this plan does with Flyway.

The order stays the same whatever the app: score the code, agree what it must keep doing, pick the target shape, break the work into small tasks, and move traffic in stages.

To try it on your own ColdFusion app, start a project from Forge's legacy modernization flow, or book a modernization assessment with the Opsera team.

Questions.

Can ColdFusion code be converted to Java automatically?

Parts of it can be translated, but a line-by-line port carries over the same mixed handlers that make the app hard to change. The plan in this walkthrough rebuilds each route group as a controller, service, and repository, and proves it matches the old behavior with parity tests.

Do I have to change the database when moving from ColdFusion to Spring Boot?

No, keeping the existing schema lets the ColdFusion app and the Spring Boot app share one database during the move. Saaster keeps MySQL 8 and its Flyway migrations throughout.

How long does a ColdFusion to Java migration take?

It depends on the size of the app and its test coverage. For Saaster, with 446 files and 55 HTTP endpoints, Forge's plan runs to about 52 weeks across seven phases, with the old app live the whole time.

Does this work for Adobe ColdFusion as well as Lucee?

Yes, the steps are the same, and the main extra work is replacing Adobe-only features such as PDF generation or administrator-managed data sources with Java libraries or Spring configuration.

Can I roll back if a migrated page breaks?

Yes, each route group has a rollback path that sends its traffic back to the ColdFusion app through NGINX, with a target of under 15 minutes.

Two people at work on code, seen from behind their monitors.

See it running on your own pipelines.

Forty-five minutes with our engineers. Bring a pipeline you are unhappy with.

Book a demoNo install required