Back to Blog

Add REST API Legacy Java Application Without Refactoring

Published: June 24, 2026
Add REST API Legacy Java Application Without Refactoring

A client needed a mobile app. The problem: their business logic lived in a 15-year-old Java desktop application, nobody on the team had touched the core in three years, and the words "full rewrite" had been circulating long enough to become an inside joke at standup. They needed to add a REST API to their legacy Java application without refactoring the core — and they needed it to ship in six weeks, not six months. If you need to add REST API legacy Java application work without touching the existing codebase, this pattern is purpose-built for exactly that constraint.

The full rewrite is the most seductive and most expensive mistake in software engineering. Large projects succeed less than 10% of the time (Standish CHAOS). Small projects succeed ~90% of the time. A rewrite is the largest project you can choose. But adding a thin REST API layer on top of existing Java code — without touching a single line of the legacy logic — is a small project. It is a facade, not a migration.

This guide is exactly how to add REST API legacy Java application without refactoring. It is the playbook we used on that six-week project. It worked.

Why Add REST API Legacy Java Application Without Refactoring

When you add REST API legacy Java application work to a project, the first question is always whether to rewrite or wrap. If your legacy Java application still works, the business value is in the logic, not the delivery mechanism. The accounting calculations that have been running for a decade are correct. The inventory rules are battle-tested. The report engine may be ugly, but it produces the numbers the CFO trusts. The answer to how to add REST API legacy Java application without refactoring is always "wrap it with a facade."

Scheduling a REST API legacy Java project that does not touch existing code lets your mobile app, web frontend, or integration partner call those trusted services without risking the system that runs the business. It is the first step of the Strangler Fig pattern — build the new interface beside the old system, prove it works, and only then consider replacing pieces of the legacy core.

The alternative is a rewrite that starts with "eight months, clean slate" and ends with a fourteen-month timeline, an incomplete migration, and a production incident because the new accounting module rounded differently than the old one. We have made that bet. We lost it. (The Java monolith migration story is longer than this post but a good companion read.)

Step 1: Create a Spring Boot Facade Project

The facade pattern is the architectural backbone of this approach. When you add REST API legacy Java application code with this pattern, you create a new Spring Boot application that sits next to your legacy code, not inside it. The facade exposes REST endpoints that delegate to existing Java services.

Colorful programming code on a screen representing the development work to add REST API legacy Java application without refactoring

Create a new Spring Boot project using Spring Initializr with the Web and Actuator starters. Place it in a separate directory from your legacy project. The build configuration references your legacy JAR as a dependency.

HTML
1<dependency>
2    <groupId>com.legacy</groupId>
3    <artifactId>legacy-core</artifactId>
4    <version>1.0</version>
5    <scope>system</scope>
6    <systemPath>${project.basedir}/lib/legacy-core.jar</systemPath>
7</dependency>

The critical constraint: your new project depends on the legacy JAR, but the legacy project never imports anything from the new one. The dependency is one-way. This is what keeps the legacy core untouched. The entire point of choosing to add REST API legacy Java application without refactoring is that the dependency stays unidirectional — if it goes both ways, you have coupled your new API to the legacy internals and lost the isolation.

Step 2: Build the Facade Service Layer

The facade classes are Spring @Service beans that wrap existing legacy service calls. Their job is to accept clean inputs from REST controllers, call the legacy code, and return clean outputs. They translate domain concepts between the REST world and the legacy world. Every facade class you write when you add REST API legacy Java application serves this one translator purpose.

JAVA
1@Service
2public class OrderFacade {
3
4    private final LegacyOrderService legacyOrderService;
5    private final OrderMapper orderMapper;
6
7    public OrderFacade(LegacyOrderService legacyOrderService, OrderMapper orderMapper) {
8        this.legacyOrderService = legacyOrderService;
9        this.orderMapper = orderMapper;
10    }
11
12    public OrderDto getOrder(String orderId) {
13        LegacyOrder legacyOrder = legacyOrderService.findOrderById(orderId);
14        return orderMapper.toDto(legacyOrder);
15    }
16
17    public OrderDto createOrder(CreateOrderRequest request) {
18        LegacyOrder legacyOrder = legacyOrderService.createOrder(
19            request.getCustomerId(),
20            request.getItems(),
21            request.getShippingAddress()
22        );
23        return orderMapper.toDto(legacyOrder);
24    }
25}

The facade never exposes legacy types to the controller layer. It owns the translation. The REST layer is clean, and the legacy layer is untouched. This is not a complicated pattern. It is a disciplined one.

The error handling surface is where most teams slip. Legacy services throw exceptions. Those exceptions have legacy names — LegacySystemException, ServiceFailedFault, ProcessingException42. They carry no HTTP semantics. The facade translates them.

JAVA
1@Service
2public class OrderFacade {
3
4    // ...
5
6    public OrderDto getOrder(String orderId) {
7        try {
8            LegacyOrder legacyOrder = legacyOrderService.findOrderById(orderId);
9            return orderMapper.toDto(legacyOrder);
10        } catch (LegacyNotFoundException e) {
11            throw new OrderNotFoundException("Order " + orderId + " not found");
12        } catch (LegacySystemException e) {
13            throw new OrderServiceUnavailableException("Order system unavailable", e);
14        }
15    }
16}

Each legacy exception maps to a specific HTTP error code. The @ExceptionHandler in the controller layer then maps facade exceptions to HTTP responses. This keeps error translation in one place and HTTP semantics out of the legacy code.

JAVA
1@RestControllerAdvice
2public class GlobalExceptionHandler {
3
4    @ExceptionHandler(OrderNotFoundException.class)
5    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
6        return ResponseEntity.status(HttpStatus.NOT_FOUND)
7                .body(new ErrorResponse("ORDER_NOT_FOUND", e.getMessage()));
8    }
9
10    @ExceptionHandler(OrderServiceUnavailableException.class)
11    public ResponseEntity<ErrorResponse> handleUnavailable(OrderServiceUnavailableException e) {
12        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
13                .body(new ErrorResponse("ORDER_SERVICE_DOWN", e.getMessage()));
14    }
15}

If you have ever debugged a REST endpoint that returned a 500 with "see server logs for details" because nobody translated the underlying exception, you know why this matters. The facade is where you stop that. This is the detail that separates a clean add REST API legacy Java application implementation from one that passes row-count checks but breaks under real traffic.

Step 3: Handle the Data Model Impedance Mismatch

Legacy data models are rarely designed for REST API consumption. They use primitive types, poorly named fields, and internal identifiers that mean nothing to a mobile app developer. The mapper in the facade translates between them.

JAVA
1@Component
2public class OrderMapper {
3
4    public OrderDto toDto(LegacyOrder legacyOrder) {
5        return OrderDto.builder()
6                .orderId(legacyOrder.getOrderNumber()) // legacy calls it orderNumber
7                .customerName(legacyOrder.getCustName()) // legacy calls it custName
8                .items(legacyOrder.getLineItems().stream()
9                        .map(this::toItemDto)
10                        .collect(Collectors.toList()))
11                .totalAmount(formatCurrency(legacyOrder.getTotalAmt()))
12                .status(translateStatus(legacyOrder.getStatusCode()))
13                .build();
14    }
15
16    private String translateStatus(String legacyCode) {
17        // Map legacy status codes to clean API enum values
18        switch (legacyCode) {
19            case "0": return "PENDING";
20            case "1": return "CONFIRMED";
21            case "2": return "SHIPPED";
22            case "9": return "CANCELLED";
23            default: return "UNKNOWN";
24        }
25    }
26
27    private String formatCurrency(BigDecimal amount) {
28        return String.format("%.2f", amount);
29    }
30}

This is mundane work. It is also the work that prevents legacy domain quirks from leaking into your API responses, where they become breaking changes you must support forever. A REST API that returns custName instead of customerName because nobody built a mapper is an API that will annoy every consumer for its entire lifespan.

Step 4: Manage Transactions Across the Boundary

Transaction management is where the facade pattern gets honest about its limits when you add REST API legacy Java application code. If your REST endpoint calls one legacy service method, transaction management is simple — the legacy method runs in its own transaction context, and the facade does not intervene.

If your REST endpoint must call three legacy service methods as a logical unit, you have options:

JAVA
1@Service
2public class OrderFacade {
3
4    @Transactional
5    public OrderDto placeOrder(CreateOrderRequest request) {
6        // All three calls run in a single Spring-managed transaction
7        OrderDto order = createOrder(request);
8        inventoryService.reserveStock(request.getItems());
9        notificationService.sendConfirmation(request.getCustomerId(), order.getOrderId());
10        return order;
11    }
12}

The @Transactional annotation on the facade method creates a Spring transaction that coordinates all three calls. If one fails, the facade can throw an exception and let Spring handle the rollback. If your legacy services manage their own transactions internally (via container-managed transactions or manual BEGIN/COMMIT), you must be more careful — each legacy call will commit independently, and you need compensating rollback logic.

The rule of thumb: let the legacy system manage its own transactions for read operations. Use facade-level @Transactional only when writing across multiple legacy services in a single operation, and document the rollback behavior explicitly so the next developer does not assume it is atomic when it is not. This transaction boundary rule applies to any add REST API legacy Java application project.

Step 5: Add JWT Authentication

The legacy system almost certainly has its own authentication. You are not going to touch it. The new REST API layer gets its own authentication via Spring Security and JWT. When you add REST API legacy Java application security this way, both auth systems coexist without conflict.

JAVA
1@Configuration
2@EnableWebSecurity
3public class SecurityConfig {
4
5    @Bean
6    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
7        http
8            .csrf(csrf -> csrf.disable())
9            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
10            .authorizeHttpRequests(auth -> auth
11                .requestMatchers("/api/public/**").permitAll()
12                .requestMatchers("/api/admin/**").hasRole("ADMIN")
13                .anyRequest().authenticated()
14            )
15            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
16        return http.build();
17    }
18}

The JWT token carries the user identity. The facade layer can extract this identity from the SecurityContextHolder and pass it to legacy services that need to know who is making the call. Some legacy systems accept a user ID as a method parameter. Others use a thread-local or session-based user context. The facade bridges the gap.

JAVA
1@Service
2public class OrderFacade {
3
4    public OrderDto getOrder(String orderId) {
5        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
6        String userId = auth.getName();
7
8        // Pass the authenticated user's identity to the legacy system
9        LegacyOrder legacyOrder = legacyOrderService.findOrderById(orderId, userId);
10        return orderMapper.toDto(legacyOrder);
11    }
12}

This keeps JWT logic entirely in the new layer. The legacy system never knows a token exists. It receives the user identity in the format it already understands.

Step 6: Test the Facade Without Touching the Legacy System

The ability to test the REST API without modifying the legacy codebase is the entire point of this pattern. Every time we add REST API legacy Java application code for a client, the test suite is what gives the team confidence to deploy. You write integration tests against the facade, mocking or stubbing the legacy service dependencies.

JAVA
1@SpringBootTest
2@AutoConfigureMockMvc
3class OrderControllerTest {
4
5    @Autowired
6    private MockMvc mockMvc;
7
8    @MockitoBean
9    private LegacyOrderService legacyOrderService;
10
11    @Test
12    void shouldReturnOrderWhenLegacySystemResponds() throws Exception {
13        LegacyOrder legacyOrder = new LegacyOrder();
14        legacyOrder.setOrderNumber("ORD-001");
15        legacyOrder.setCustName("Acme Corp");
16        legacyOrder.setTotalAmt(new BigDecimal("299.99"));
17        legacyOrder.setStatusCode("1");
18
19        when(legacyOrderService.findOrderById("ORD-001"))
20                .thenReturn(legacyOrder);
21
22        mockMvc.perform(get("/api/orders/ORD-001")
23                .header("Authorization", "Bearer " + getTestToken()))
24                .andExpect(status().isOk())
25                .andExpect(jsonPath("$.orderId").value("ORD-001"))
26                .andExpect(jsonPath("$.customerName").value("Acme Corp"));
27    }
28
29    @Test
30    void shouldReturn404WhenLegacySystemThrowsNotFoundException() throws Exception {
31        when(legacyOrderService.findOrderById("NOT-FOUND"))
32                .thenThrow(new LegacyNotFoundException("Order not found"));
33
34        mockMvc.perform(get("/api/orders/NOT-FOUND")
35                .header("Authorization", "Bearer " + getTestToken()))
36                .andExpect(status().isNotFound())
37                .andExpect(jsonPath("$.error").value("ORDER_NOT_FOUND"));
38    }
39}

The tests validate that the facade, mapper, error translation, and controller all work together. The legacy system is replaced with a mock. This means you can run these tests in CI without a database, without a legacy server, and without touching a single line of legacy code.

The expand-contract approach to modernize legacy Java without rewriting uses the same pattern at a larger scale — replace one module at a time while keeping the system running. The facade is the first module.

Using the Facade as the First Step Toward Modernization

The REST API facade is not the end state. It is the thin end of the wedge. Once the API layer is serving production traffic, each legacy service behind the facade becomes a candidate for extraction. The facade's interface is the contract. If the legacy implementation behind it changes — replaced by a microservice, a database query, or an external API — the REST endpoint stays the same.

This is the Strangler Fig pattern in miniature. First you build the new interface (the REST API facade). Then you prove it works (tests, production traffic). Then, one legacy service at a time, you replace the implementations behind the facade. The business never notices. The mobile app never recompiles.

Black and white photo of a coding workspace with a laptop, mug, and notebook, representing how to add REST API legacy Java application development

The six-week project I mentioned at the start of this article shipped on time. The facade took three weeks. The remaining three were testing and hardening. The legacy code was never touched. Eighteen months later, the client had extracted three legacy services behind the facade into standalone microservices — each one a bounded, testable, independently deployable unit that the facade already knew how to call. If you need to add REST API legacy Java application code and ship it quickly, this timeline is realistic — three weeks for the facade, three for hardening, zero changes to the legacy core.

That is the whole strategy. Add the REST API first. Move the logic out later. Never stop shipping. If you are evaluating whether to add REST API legacy Java application work to your own project, start with this: the facade costs nothing to undo, creates no coupling you cannot reverse, and proves the architecture before you invest in a full extraction. The rewrite can wait. The API ships this sprint.

If you are looking at a fifteen-year-old Java system and wondering how to expose an API without starting the rewrite conversation, this is the playbook. A thin facade, a clean mapper, rigorous error translation, JWT security on the new layer, and tests that prove it works without touching the core.

Server racks in a data center representing the infrastructure needed to add REST API legacy Java application

The rewrite solves a problem you do not have yet. Adding a REST API solves the problem you have right now — and it does it without touching the code that runs the business.

Frequently Asked Questions

The safest approach is to create a thin Spring Boot layer that wraps your existing Java code via the facade pattern. You add a new Spring Boot project alongside your legacy application, inject or reference existing Java service classes through shared JARs or classpath dependencies, and expose those services via @RestController endpoints. The key principle is to never refactor the legacy code — only call it through clean interfaces that translate between REST concepts and the existing domain model.

Yes. You can use JAX-RS with Jersey, plain servlets, or even Java's built-in HttpServer from com.sun.net.httpserver. Spring Boot is the most practical choice because it handles JSON serialization, dependency injection, embedded servers, and security filters out of the box — all of which you would need to build yourself otherwise.

The legacy system's existing transaction management can continue handling its own transactions. The Spring Boot facade layer manages its own transaction boundaries. The critical rule is one transaction per service call. If your REST endpoint calls three legacy services, either wrap them in a single Spring @Transactional method that coordinates all three, or accept that each call runs in its own transaction and handle rollback logic explicitly.

Add Spring Security to the new Spring Boot layer with JWT-based authentication. The legacy system continues using its existing authentication. The new API layer validates JWT tokens, extracts the user identity, and either passes it to legacy services or maps it to the legacy system's user context via a thread-local or shared session. This avoids changing the legacy auth code entirely.

The facade pattern creates a new interface that sits between REST controllers and legacy code, translating clean API requests into legacy system calls. The facade hides legacy complexities — data model quirks, error handling, transaction management — behind a clean method signature. REST controllers talk only to the facade, never directly to legacy services.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio