Install
openclaw skills install java-circular-dependency-breakerBreak circular dependencies in Java multi-module Gradle/Maven projects using interface extraction and business service separation. Triggers: 'circular depend...
openclaw skills install java-circular-dependency-breakerTwo battle-tested patterns for breaking circular dependencies between Java multi-module projects (Gradle or Maven).
api project(':module-a') and api project(':module-b') forming a cycle, or Maven project with <dependency><artifactId>module-a</artifactId></dependency> forming a cycleThis skill applies the Mediator Pattern — introducing an intermediary (interface or business service) to decouple modules that directly depend on each other. Instead of Module A referencing Module B's concrete class directly, the mediator sits between them, allowing both to evolve independently.
javap)Checkpoint 0 — Confirm preconditions before choosing a pattern:
./gradlew :module-a:dependencies --configuration compileClasspath | grep module-bmvn dependency:tree -pl module-a | grep module-b./gradlew compileJava compileTestJavamvn compile test-compile⚠️ Stop and ask the user if:
Use when: Module A references a concrete class in Module B, and the class has no cross-module method dependencies.
| Item | Description |
|---|---|
| Input | A concrete @Service / @Component class in Module B that is referenced by Module A |
| Constraint | The class's public methods must NOT depend on services/classes from Module A |
| Time estimate | 15–30 min for a class with ≤10 public methods |
| Risk | Low — changes are mechanical and compiler-verified |
Create the interface in a shared module (or new *-interface module)
IXxxService (or your project's interface naming convention)Checkpoint 1 — Before proceeding, verify:
- Interface methods are a strict subset of the concrete class's public methods (no extras)
- Return types and parameter types in the interface do not depend on the source module
- Run
javap -public com.example.module.NotificationServiceand diff against interface
Implement the interface in the target module
implements IXxxService to the concrete class@Service / @Component annotationsCheckpoint 2 — Confirm:
./gradlew :module-b:compileJavapasses (ormvn -pl module-b compile)- Spring context loads without bean name conflicts (check
@Qualifierif needed)
Update all callers in the source module
import from com.example.module.Class to com.example.interface.IXxxServiceCheckpoint 3 — Confirm:
- Zero remaining imports of the concrete class in the source module (
grep -r "import com.example.module.NotificationService" module-a/src/returns empty)- Test mocks updated:
@Mock BeanName→@Mock IBeanName(do this before removing the gradle dependency)
Remove the dependency from source module's build file
build.gradle): Delete api project(':module-b'), keep api project(':module-interface')pom.xml): Delete the module-b dependency <dependency> blockCheckpoint 4 — Final verification:
./gradlew :module-a:compileJavaormvn -pl module-a compilepasses./gradlew :module-a:compileTestJavaormvn -pl module-a test-compilepasses./gradlew :module-a:dependencies --configuration compileClasspath | grep module-bormvn dependency:tree -pl module-a | grep module-breturns EMPTY
⚠️ If compilation fails: Re-add the dependency (
build.gradleorpom.xml), fix the error, then remove again. Do not leave the codebase in a broken state.
// BEFORE: OrderService depends on concrete NotificationService
// module-a/.../OrderService.java
@Service
public class OrderService {
private final NotificationService notificationService; // concrete
}
// AFTER: Interface extracted
// module-interface/.../INotificationService.java
public interface INotificationService {
void startProcess(String key);
}
// module-b/.../NotificationService.java
@Service
public class NotificationService implements INotificationService { ... }
// module-a/.../OrderService.java
@Service
public class OrderService {
private final INotificationService notificationService; // interface
}
| Criterion | Verification |
|---|---|
| Module A no longer depends on Module B | Gradle: ./gradlew :module-a:dependencies --configuration compileClasspath | grep module-b returns empty; Maven: mvn dependency:tree -pl module-a | grep module-b returns empty |
| All callers use interface type | grep -r "import com.example.module.NotificationService" module-a/src/ returns empty |
| Spring context loads | ./gradlew :module-a:compileTestJava or mvn -pl module-a test-compile passes |
| Tests pass | ./gradlew test or mvn test on all affected modules passes |
Use when: A class has some methods that depend on services from the source module, preventing the entire class from migrating to the target module.
| Item | Description |
|---|---|
| Input | A class in the source module with mixed dependencies (some internal, some cross-module) |
| Constraint | The class must be splittable — methods depending on source module services can be cleanly separated |
| Time estimate | 30–60 min depending on method count and test coverage |
| Risk | Medium — requires careful analysis to avoid logic duplication |
Analyze method dependencies of the class to be migrated
Checkpoint 1 — Before splitting, verify:
- Every method is categorized (no "unclassified" methods remain)
- Methods marked for extraction only call source module services (not a mix of both)
- If a method calls both, consider extracting a smaller helper method first
- STOP — present the split plan to the user and wait for explicit confirmation before proceeding. Do not autonomously decide which methods stay vs. move.
Migrate the base class to the target module
implements IXxxServicepublic if they were previously private/protected and needed by the business serviceCheckpoint 2 — After migration, verify:
- Base class has zero imports from the source module
./gradlew :module-b:compileJavaormvn -pl module-b compilepasses- All extracted methods are gone from the base class (grep for their names to confirm)
Create the Business Service in the source module
XxxBusinessServiceCheckpoint 3 — After creation, verify:
- Business Service only calls base class methods via the interface (never direct concrete class)
- No logic duplication: extracted methods delegate to base class rather than re-implementing
./gradlew :module-a:compileJavaormvn -pl module-a compilepasses
Update all callers
XxxBusinessServiceCheckpoint 4 — Final verification:
- All references to the original concrete class in the source module are gone
./gradlew compileJava compileTestJavaormvn compile test-compilepasses on all affected modules- Run the full test suite (
./gradlew testormvn test) — behavior must be identical after refactoring
⚠️ If tests fail: Use
git diffto compare pre/post refactoring. Most common cause: a method was extracted but still called another method that stayed in the base class. Fix by adding that method to the interface.
// BEFORE: UserService has mixed dependencies
// module-a/.../UserService.java
@Service
public class UserService {
private final UserRepository repo; // internal
private final PermissionService auth; // from module-a!
public UserProfile getById(Long id) { return repo.selectById(id); } // stays
public List<String> listAdmins(Long id) { // extract
return auth.listAdmins(repo.selectById(id).getAppId());
}
}
// AFTER: Base class migrated, Business Service extracted
// module-b/.../UserService.java (pure — zero imports from module-a)
@Service
public class UserService implements IUserService {
private final UserRepository repo;
public UserProfile getById(Long id) { return repo.selectById(id); }
}
// module-a/.../UserBusinessService.java (orchestrates cross-module logic)
@Service
public class UserBusinessService {
private final IUserService userService; // migrated base class via interface
private final PermissionService auth; // source-module dependency
public List<String> listAdmins(Long id) {
// delegates to base class method instead of duplicating repo access
return auth.listAdmins(userService.getById(id).getAppId());
}
}
| Criterion | Verification |
|---|---|
| Base class is pure | grep "import com.example.module-a" module-b/src/.../BaseClass.java returns empty |
| Business Service delegates correctly | All extracted methods call base class via interface, never direct concrete class |
| No logic duplication | diff extracted methods against originals — should delegate, not duplicate |
| Full compilation | ./gradlew compileJava compileTestJava or mvn compile test-compile passes on all affected modules |
| Behavior preserved | ./gradlew test or mvn test passes — no functional changes |
Can the class be moved to target module without breaking anything?
│
├─ YES → Use Pattern 1: Interface Extraction
│ (create interface, implement, replace references)
│
└─ NO (some methods depend on source module services)
│
└─ Use Pattern 2: Business Service Extraction
(split class: base → target module, business → source module)
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Missed a caller | grep only covers .java files, but XML/JSON configs may reference bean names | Also search in *.xml, *.yml, *.properties |
| Interface method signature mismatch | Return type or parameter type changed during copy | Use javap -public on concrete class to verify |
| Spring bean name collision | Two beans with same class name in different modules | Use @Qualifier or explicit bean names |
@Mock in tests still references concrete class | Test files not updated alongside main code | Update test mocks to interface type simultaneously |
| Business Service duplicates logic | Extracted method had inline logic that should be reused | Delegate to base class method instead of duplicating |
If ./gradlew :module-a:compileJava (or mvn -pl module-a compile) fails after removing the dependency:
| Scenario | Symptom | Fix |
|---|---|---|
| Spring bean name collision | NoUniqueBeanDefinitionException | @Qualifier("beanName") or @Primary |
| Missing bean after interface change | NoSuchBeanDefinitionException | Check @ComponentScan includes new module |
| Test mock references concrete class | BeanNotOfRequiredTypeException | Update @Mock to interface type |
Lombok @RequiredArgsConstructor | No symptom — works automatically | No change needed (generates based on fields) |
| Generics in method signatures | javap shows raw types | Keep generics as-is; verify with javap -public -v |
If refactoring causes widespread test failures:
# 1. Immediately restore the gradle dependency
git checkout -- module-a/build.gradle
# 2. Revert interface references back to concrete class
git checkout -- module-a/src/
# 3. Keep the interface and implementation in place (no harm)
# 4. Fix the root cause, then retry the migration
Never leave the codebase in a non-compiling state between commits.
When class A depends on class B (same package), and both need to move:
Example: MessageSender + QueueConfig — both migrate, but only MessageSender needs an interface since external callers only reference it.
Copy-paste ready commands for the entire workflow:
# === Phase 0: Diagnosis ===
# Find which module depends on which
# Gradle:
./gradlew :module-a:dependencies --configuration compileClasspath | grep module-b
# Maven:
mvn dependency:tree -pl module-a | grep module-b
# Find all references to a class across the project
grep -r "NotificationService" --include="*.java" --include="*.xml" module-a/src/
# Extract public method signatures from a concrete class
javap -public com.example.module.NotificationService
# === Phase 1: Pattern 1 — Interface Extraction ===
# Verify interface matches concrete class
javap -public com.example.interface.INotificationService
# Confirm zero imports remain after migration
grep -r "import com.example.module.NotificationService" module-a/src/ || echo "CLEAN"
# === Phase 2: Pattern 2 — Business Service Extraction ===
# Verify base class has no source-module imports
grep "import com.example.module-a" module-b/src/.../UserService.java || echo "CLEAN"
# === Phase 3: Verification ===
# Compile all affected modules in order
# Gradle:
./gradlew :module-interface:compileJava
./gradlew :module-b:compileJava
./gradlew :module-a:compileJava
./gradlew :module-a:compileTestJava
# Maven:
mvn -pl module-interface compile
mvn -pl module-b compile
mvn -pl module-a compile
mvn -pl module-a test-compile
# Full test suite
# Gradle:
./gradlew :module-a:test :module-b:test
# Maven:
mvn -pl module-a,module-b test
# Final dependency check — must return EMPTY
# Gradle:
./gradlew :module-a:dependencies --configuration compileClasspath | grep module-b
# Maven:
mvn dependency:tree -pl module-a | grep module-b