Most developers are familiar with circular dependencies - when module A depends on module B, and module B depends on module A. Build systems catch these easily, and we know to avoid them. However, there's a more subtle form of circular dependency that builds successfully but creates the same architectural problems: conceptual circular dependencies.
Note
This article is adapted from my contribution to LY Corporation's internal "Review Committee Report" series, where we share knowledge gathered from code reviews to maintain high development productivity. I've rewritten it here to share with the broader software engineering community.
Imagine we have a photo-provider module that specializes in providing photos, and a home-gallery module that displays photos in a gallery. We want to display photos from the photo-provider in the home gallery.
Here's a simplified version of the code:
The photo-provider module:
// PhotoProvider.kt
class PhotoProvider {
fun getPhotos(): List<HomeGalleryPhotoItem> {
// Fetch photos from repository
return listOf(
HomeGalleryPhotoItem("vacation.jpg", "Summer Vacation", ...),
...
)
}
}
// Data model in provider module
data class HomeGalleryPhotoItem(
val fileName: String,
val description: String,
...
)The home-gallery module:
// build.gradle.kts
dependencies {
implementation(project(":photo-provider"))
}
// HomeGallery.kt (in a different module)
class HomeGallery(private val photoProvider: PhotoProvider) {
fun displayPhotos() {
val photos = photoProvider.getPhotos()
// Display photos in home gallery
photos.forEach { renderPhoto(it) }
}
private fun renderPhoto(photo: HomeGalleryPhotoItem) {
// Render photo in UI
}
}Do you see any problems with this code?
The issue is that PhotoProvider defines and returns HomeGalleryPhotoItem. This class contains a reference to "HomeGallery" in its name, creating a conceptual circular dependency:
- The provider module knows about a specific consumer (HomeGallery)
- The provider is tightly coupled to one specific use case
- Other modules wanting to use PhotoProvider must either use a model named for a different feature, or photo-provider needs to add new data models and APIs for each new use case
Consider what happens when we add a museum-gallery module that also needs photos. Should the museum gallery use HomeGalleryPhotoItem? Or should we add MuseumGalleryPhotoItem and a new API to photo-provider? Either choice reveals that this abstraction is leaking implementation details.
This is different from the typical circular dependency that build systems detect. There's no explicit bidirectional reference, so the modules compile fine. Even Go, with its notoriously strict enforcement against circular dependencies at the package level, cannot catch this issue. The compiler sees no technical violation - photo-provider doesn't import home-gallery, so everything builds successfully. However, the provider module has implicit knowledge about its consumer through naming conventions, which violates the Dependency Inversion Principle.
This is what makes conceptual circular dependencies so insidious: they pass all automated checks while still creating the same architectural problems as real circular dependencies. No static analysis tool or compiler will save you from this - it requires human judgment during code review.
The solution is to use more generic terminology in the provider and establish proper abstraction boundaries.
Improved photo-provider module:
// PhotoProvider.kt
class PhotoProvider {
fun getPhotos(): List<PhotoItem> {
// Fetch photos from repository
return listOf(
PhotoItem("vacation.jpg", "Summer Vacation", ...),
...
)
}
}
// Generic data model in provider module
data class PhotoItem(val fileName: String, val description: String, ...)Improved home-gallery module:
// HomeGallery.kt
class HomeGallery(private val photoProvider: PhotoProvider) {
fun displayPhotos() {
val photos = photoProvider.getPhotos()
// Map to HomeGallery-specific model if needed
val galleryItems = photos.map { it.toHomeGalleryItem() }
galleryItems.forEach { renderPhoto(it) }
}
private fun PhotoItem.toHomeGalleryItem(): HomeGalleryPhotoItem {
return HomeGalleryPhotoItem(this.fileName, this.description, ...)
}
private fun renderPhoto(photo: HomeGalleryPhotoItem) {
// Render photo in UI
}
}
// HomeGallery-specific model
data class HomeGalleryPhotoItem(
val fileName: String,
val description: String,
...
)By using a generic PhotoItem in the provider, we've removed the terminology dependency. This results in:
- The provider knows nothing about its consumers
- Each consumer can map the generic model to its own domain-specific model
- The provider is properly reusable across different features
- Proper separation of concerns is maintained
- The provider doesn't bloat with consumer-specific models and APIs
Avoid creating circular dependencies through naming conventions. Keep provider modules generic and let consumers handle domain-specific adaptations.
The provider should speak in its own vocabulary, not in the language of its consumers. When you see a provider module returning types named after specific features or consumers, that's a red flag for conceptual circular dependency.
Remember: Even languages with the strictest circular dependency checks (like Go) cannot protect you from this pattern. This is a problem that requires careful code review and architectural thinking, not just relying on the compiler.
Nice post