How to Refactor a Java App Onto Clean Spring Boot Architecture
For Backend engineers refactoring a legacy Java app · Based on Mosh Spring Boot Architecture Skill
// TL;DR
Backend engineers inheriting a tangled Java codebase can use this methodology to systematically decouple and stabilize it. You'll identify every place a class instantiates a concrete dependency, extract interfaces, migrate to constructor injection, and register beans with the correct stereotype annotations. You'll also fix version drift by removing explicit version tags and letting spring-boot-starter-parent manage them, and lock down reproducible builds with the Maven Wrapper. The result is a codebase where swapping implementations requires zero changes, dependencies fail loudly at startup, and builds behave identically across every machine.
How do I find what to decouple in a legacy app?
Start by identifying every place one class depends on another concrete class — especially anywhere you see `new SomeService()` inside a method body. These are your tight-coupling hotspots. A classic example: a `NotificationService` that directly instantiates `EmailNotificationService` inside its logic. Every one of these makes the code rigid, untestable, and hard to change. List them before touching anything, so you refactor deliberately rather than reactively.
How do I extract interfaces and migrate to constructor injection?
For each hotspot, use IntelliJ's Refactor → Extract → Interface to pull an interface (e.g., `NotificationService` with a `send(String message)` method) from the concrete class. Make the concrete class implement it and annotate only the concrete class with `@Service`. Then rewrite the dependent class to declare a single constructor accepting the interface type, storing it in a private field. If there's exactly one constructor, drop any `@Autowired` — Spring infers it. Only keep `@Autowired` where multiple constructors genuinely exist.
This is where legacy anti-patterns get eliminated. Replace field injection (which hides dependencies and defeats testing) and setter injection on required fields (which crashes at runtime with a NullPointerException) with constructor injection. Now missing dependencies fail loudly at startup, and your classes become trivially unit-testable by passing mocks into the constructor.
The payoff shows immediately: to add SMS notifications, you create `SmsNotificationService implements NotificationService`, annotate it, and the dependent class requires zero changes — the Open Closed Principle in action. Apply this judiciously, though; don't extract interfaces for classes that will only ever have one implementation.
How do I stabilize dependency versions and builds?
Legacy pom.xml files are often littered with explicit `
Next, prefer starters over hand-picked libraries. If you're pulling in Tomcat, Spring MVC, and Jackson separately, replace them with `spring-boot-starter-web`, which bundles a tested, compatible set. For development-only tools like DevTools, add `
Finally, commit the Maven Wrapper (`mvnw`, `mvnw.cmd`, and `.mvn/wrapper/maven-wrapper.properties`) to the repo. This pins the exact Maven version so every developer's machine and your CI pipeline produce identical builds — no more 'works on my machine' surprises caused by a colleague's differing Maven install.
What order should I refactor in?
Work from the outside in and stabilize before restructuring. First fix version management and the Maven Wrapper so builds are reproducible while you work. Then tackle decoupling one hotspot at a time, running the app after each change to confirm beans still wire correctly. Watch for `NoUniqueBeanDefinitionException` — if two implementations of one interface are both annotated, disambiguate with `@Primary` or `@Qualifier`. Move environment-specific values (ports, credentials, custom keys) out of source and into `application.properties`, injecting them with `@Value("${property.key}")`.
Next step: Audit your codebase for `new` calls on service classes and explicit version tags in pom.xml. Fix the build reproducibility first, then refactor one dependency to constructor injection and verify it still runs before moving to the next.
// FREQUENTLY ASKED QUESTIONS
How do I refactor without breaking everything at once?
Refactor incrementally and verify after each change. Stabilize builds first — remove version tags and commit the Maven Wrapper — then decouple one dependency at a time, running the app after each to confirm beans wire correctly. IntelliJ's Extract Interface refactor updates references safely. This one-hotspot-at-a-time approach keeps the app runnable throughout the migration.
What if two existing classes implement the same interface?
Spring will throw a NoUniqueBeanDefinitionException because it can't decide which bean to inject. Resolve it by marking one implementation with @Primary as the default, or use @Qualifier("beanName") on the constructor parameter to name the specific bean you want. This is common in legacy apps with multiple existing strategies for the same behaviour.
Should I extract interfaces for every class in the legacy app?
No. Only extract interfaces where you anticipate swapping implementations or need testability. Blindly applying the Open Closed Principle everywhere creates an over-engineered mess of pointless single-implementation interfaces. Target true variation points and integration boundaries — payment providers, notification channels, external services — and leave simple, stable classes as concrete.