Two 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:
Identify the concrete class causing the cycle
./gradlew :module-a:dependencies --configuration compileClasspath | grep module-bmvn dependency:tree -pl module-a | grep module-bConfirm the class is referenced by at least one external module (otherwise just move it, no interface needed)
Compile all affected modules — all tests must pass before refactoring
./gradlew compileJava compileTestJavamvn compile test-compileRead the Decision Tree below to choose the correct pattern
⚠️ 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.
ItemDescriptionInputA concrete @Service / @Component class in Module B that is referenced by Module AConstraintThe class's public methods must NOT depend on services/classes from Module ATime estimate15–30 min for a class with ≤10 public methodsRiskLow — 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.
java
// 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
}
CriterionVerificationModule A no longer depends on Module BGradle: ./gradlew :module-a:dependencies --configuration compileClasspath | grep module-b returns empty; Maven: mvn dependency:tree -pl module-a | grep module-b returns emptyAll callers use interface typegrep -r "import com.example.module.NotificationService" module-a/src/ returns emptySpring context loads./gradlew :module-a:compileTestJava or mvn -pl module-a test-compile passesTests 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.
ItemDescriptionInputA class in the source module with mixed dependencies (some internal, some cross-module)ConstraintThe class must be splittable — methods depending on source module services can be cleanly separatedTime estimate30–60 min depending on method count and test coverageRiskMedium — 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
XxxBusinessServiceprivate final IUserService userService) — this refers to the original bean, so the name must match the original bean nameCheckpoint 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
Callers that used extracted methods → reference XxxBusinessService
xxxService to xxxBusinessService to match the new Spring bean nameprivate UserService userService → private UserBusinessService userBusinessServiceuserService; the new one is userBusinessService. If the field name is not renamed, @Resource, @Autowired + @Qualifier, or framework-specific injection may match the original bean by name and inject the wrong type (the original class instead of the Business Service), causing startup failures or runtime NPEsCallers that used base methods → reference interface (already handled by Pattern 1)
If a caller needs both base and extracted methods, inject both fields (names must differ): java
private final IUserService userService; // base methods, bean name stays userService
private final UserBusinessService userBusinessService; // extracted methods, bean name is userBusinessService
Checkpoint 4 — Final verification:
- All field names referencing
XxxBusinessServiceare renamed (grep -r "UserBusinessService userService" module-a/src/returns empty)- 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.
java
// 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 {
// NOTE: internal field name STAYS as userService — it refers to the original bean via interface
private final IUserService userService; // migrated base class via interface (bean name = userService)
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());
}
}
// module-a/.../OrderService.java (caller — field name MUST be renamed!)
@Service
public class OrderService {
// ❌ WRONG: field name not renamed; Spring injects the original UserService bean by name, not UserBusinessService
// @Autowired private UserBusinessService userService;
// ✅ CORRECT: field name matches the new bean name userBusinessService
private final UserBusinessService userBusinessService;
public void notifyAdmins(Long userId) {
List<String> admins = userBusinessService.listAdmins(userId); // call extracted method
}
}
CriterionVerificationBase class is puregrep "import com.example.module-a" module-b/src/.../BaseClass.java returns emptyBusiness Service delegates correctlyAll extracted methods call base class via interface, never direct concrete classField names renamedgrep -r "UserBusinessService userService" module-a/src/ returns empty (field names match the new bean names)No logic duplicationdiff extracted methods against originals — should delegate, not duplicateFull compilation./gradlew compileJava compileTestJava or mvn compile test-compile passes on all affected modulesBehavior preserved./gradlew test or mvn test passes — no functional changes
text
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)
PitfallWhy It HappensFixMissed a callergrep only covers .java files, but XML/JSON configs may reference bean namesAlso search in *.xml, *.yml, *.propertiesInterface method signature mismatchReturn type or parameter type changed during copyUse javap -public on concrete class to verifySpring bean name collisionTwo beans with same class name in different modulesUse @Qualifier or explicit bean names@Mock in tests still references concrete classTest files not updated alongside main codeUpdate test mocks to interface type simultaneouslyBusiness Service duplicates logicExtracted method had inline logic that should be reusedDelegate to base class method instead of duplicatingField name not renamedSpring injects by bean name; field name userService matches the original UserService bean instead of UserBusinessServiceRename xxxService to xxxBusinessService to match the new bean name
If ./gradlew :module-a:compileJava (or mvn -pl module-a compile) fails after removing the dependency:
ScenarioSymptomFixSpring bean name collisionNoUniqueBeanDefinitionException``@Qualifier("beanName") or @PrimaryMissing bean after interface changeNoSuchBeanDefinitionExceptionCheck @ComponentScan includes new moduleTest mock references concrete classBeanNotOfRequiredTypeExceptionUpdate @Mock to interface typeLombok @RequiredArgsConstructorNo symptom — works automaticallyNo change needed (generates based on fields)Generics in method signaturesjavap shows raw typesKeep generics as-is; verify with javap -public -vBusiness Service field name not renamedBeanNotOfRequiredTypeException or wrong bean injected (original type injected into Business Service field)Rename field from xxxService to xxxBusinessService to match the Spring-generated bean name
If refactoring causes widespread test failures:
bash
# 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:
bash
# === 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"
# Verify all Business Service field names are renamed (must return EMPTY)
# Checks field declarations, constructor params, and annotated fields
grep -rE "UserBusinessService\s+userService|@.*UserBusinessService.*userService|UserBusinessService\(.*userService" module-a/src/ || echo "CLEAN"
# Also verify Business Service internal fields still reference the original bean name
grep -rE "IUserService\s+userBusinessService" module-a/src/ || 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