Summary
In Spring Boot microservices, developers often version APIs by copy-pasting controller classes, especially when the business logic change is minimal. This leads to significant code duplication, frequently caught by static analysis tools like SonarQube, which blocks the build due to quality gates. The specific scenario involves two controllers (v1 and v2) sharing nearly identical validation logic but passing different parameters (e.g., a boolean flag) to the service layer. The goal is to refactor this into a clean, maintainable, and extensible architecture that satisfies SonarQube’s no duplication rule while preserving Spring’s annotation-based routing.
Root Cause
The immediate cause of the build failure is high code duplication reported by SonarQube. However, the underlying architectural roots are:
- Violation of DRY Principle (Don’t Repeat Yourself): The validation logic is duplicated across
OldControllerV1andNewControllerV2. - Misuse of Controllers for Logic: Developers treat controllers as the primary location for business logic and validation rather than as routing adapters.
- Tight Coupling of Versioning to Class Structure: Mapping API versions to distinct classes forces structural duplication when the underlying logic is similar.
- Lack of Abstraction: There is no shared abstraction to handle the common workflow, forcing manual replication of code for every new API version.
Why This Happens in Real Systems
- Time Pressure: In fast-paced release cycles, copying a working controller (v1) to create a new endpoint (v2) is the fastest path to delivery, even if it creates technical debt.
- “Just a Flag” Fallacy: When the difference between versions is a single boolean or enum parameter, developers underestimate the architectural impact, viewing the change as “too small to justify abstraction.”
- Legacy Constraints: In systems where older versions cannot be changed (backward compatibility guarantees), developers hesitate to touch existing code, preferring to isolate new versions in separate classes.
- Tooling Gaps: SonarQube is often introduced late in the process; without it, the duplication goes unnoticed until the quality gate fails, causing a scramble to refactor.
Real-World Impact
- Maintenance Nightmare: Fixing a validation bug or updating a field requires changes in multiple controller classes. Missing one results in inconsistent API behavior.
- Technical Debt Accumulation: High duplication lowers the maintainability score, making the codebase harder to navigate for junior and senior engineers alike.
- Deployment Risks: Copied code often carries hidden assumptions from the original version. When parameters change (like the boolean flag), subtle logic bugs can emerge if the copied code isn’t meticulously audited.
- CI/CD Bottlenecks: SonarQube blocks the pipeline, forcing developers to stop and refactor immediately, disrupting the release cadence.
Example or Code
To eliminate duplication, we move the common logic into a single generic controller and use Spring’s path variable capabilities or strategy patterns to handle the version-specific behavior. Here is the refactored implementation.
1. The Unified Controller
We define a single controller handling both versions. The version is captured as a path variable.
@RestController
@RequestMapping("/api")
public class UnifiedProcessController {
private final ProcessService service;
private final ProcessStrategyFactory strategyFactory;
public UnifiedProcessController(ProcessService service, ProcessStrategyFactory strategyFactory) {
this.service = service;
this.strategyFactory = strategyFactory;
}
@PostMapping("/v{version}/process")
public ResultDto process(@PathVariable int version, @RequestBody RequestDto request) {
// 1. Common Validation (applies to all versions)
validateRequest(request);
// 2. Select the strategy based on version
ProcessStrategy strategy = strategyFactory.getStrategy(version);
// 3. Execute logic via strategy (encapsulating the version difference)
return strategy.execute(request, service);
}
private void validateRequest(RequestDto request) {
// ... lots of common validation logic ...
if (request == null) {
throw new IllegalArgumentException("Request cannot be null");
}
}
}
2. Strategy Interface and Implementations
Instead of a boolean flag, we use a Strategy pattern to define version-specific behaviors.
public interface ProcessStrategy {
ResultDto execute(RequestDto request, ProcessService service);
}
@Component
public class V1ProcessStrategy implements ProcessStrategy {
@Override
public ResultDto execute(RequestDto request, ProcessService service) {
// V1 specific logic or parameter passing
return service.execute(request, true); // true == old mode
}
}
@Component
public class V2ProcessStrategy implements ProcessStrategy {
@Override
public ResultDto execute(RequestDto request, ProcessService service) {
// V2 specific logic or parameter passing
return service.execute(request, false); // false == new mode
}
}
3. Strategy Factory
A factory manages the lookup of strategies, making it easy to add V3 or V4 later.
@Component
public class ProcessStrategyFactory {
private final Map strategies = new ConcurrentHashMap();
public ProcessStrategyFactory(List strategyList) {
// Auto-register all strategies found in the context
for (ProcessStrategy strategy : strategyList) {
// Assuming a method getVersion() exists on the strategy interface
// or using a naming convention. For this example, we assume a map registration.
if (strategy instanceof V1ProcessStrategy) strategies.put(1, strategy);
if (strategy instanceof V2ProcessStrategy) strategies.put(2, strategy);
}
}
public ProcessStrategy getStrategy(int version) {
ProcessStrategy strategy = strategies.get(version);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported API version: " + version);
}
return strategy;
}
}
How Senior Engineers Fix It
Senior engineers approach this problem by decoupling the routing (Spring annotations) from the business logic (validation and execution).
- Identify the Constant and Variable: They recognize that the HTTP route and validation are constant, while the business logic parameter (the boolean flag) is the variable.
- Apply the Strategy Pattern: They encapsulate the variable behavior (the flag) into distinct strategy classes. This adheres to the Open/Closed Principle—the system is open for extension (adding v3) but closed for modification (no changes to the controller).
- Leverage Spring’s Dependency Injection: Instead of manually instantiating logic, they inject the strategies via a Factory or a Map, utilizing Spring’s component scanning.
- Use Path Variables: They collapse multiple
@RequestMappingclasses into one using path variables (/api/v{version}/process), which centralizes the endpoint definition. - Refactor Validation: If validation varies slightly between versions, they might use a “Validator Chain” or composition, but if it is identical, it stays in the unified controller.
Why Juniors Miss It
Junior engineers often lack the experience to see beyond the immediate requirement (“Create a new v2 endpoint”).
- Focus on “It Works”: The primary goal is often just getting the new endpoint functional. Copy-pasting a working controller guarantees that the validation logic works immediately without debugging.
- Lack of Design Pattern Awareness: Juniors may not be familiar with the Strategy Pattern or how to implement it in Spring. They see the boolean flag as a simple parameter, not as a signal to refactor the architecture.
- Underestimation of Debt: They view duplication as a “style issue” rather than a functional maintenance risk. They assume that if the code is identical, it doesn’t matter if it’s in two places.
- Annotation Confusion: There is often a misconception that each API version requires a distinct class to map
@RequestMapping, leading them to believe that separating versions by class is the “Spring way.”