Breaking Circular Dependencies in Kotlin with the Mediator Pattern
Michael Odhiambo

Michael Odhiambo @mikesplore

About: Yoooh

Location:
Mombasa
Joined:
May 5, 2024

Breaking Circular Dependencies in Kotlin with the Mediator Pattern

Publish Date: Jul 13
1 0

Have you ever run into a StackOverflowError when starting your application and wondered what went wrong? If you're working with dependency injection in Kotlin, chances are you've encountered the dreaded circular dependency problem. In this post, I'll share how I solved this issue in a real-world application using the Mediator Pattern.

The Problem: When Dependencies Go Round and Round

Picture this scenario: You have two repositories that need each other to function properly.

  • TokenRepositoryImpl needs UsersRepository to look up user profiles
  • UsersRepositoryImpl needs TokenRepository to invalidate tokens

This creates a classic chicken-and-egg problem. When your dependency injection framework (in my case, Koin) tries to create these objects, it gets caught in an infinite loop:

  1. To create TokenRepositoryImpl, it needs to create UsersRepository first
  2. To create UsersRepository, it needs to create TokenRepository first
  3. Back to step 1, and... 💥 BOOM! StackOverflowError

Here's what that looks like in the wild:

Exception in thread "main" java.lang.StackOverflowError
    at kotlin.reflect.jvm.internal.KClassImpl.hashCode(KClassImpl.kt:306)
    at java.base/java.util.concurrent.ConcurrentHashMap.get(ConcurrentHashMap.java:936)
    at org.koin.ext.KClassExtKt.getFullName(KClassExt.kt:25)
    ...many more lines of recursive calls...
Enter fullscreen mode Exit fullscreen mode

The Solution: Enter the Mediator Pattern

The mediator pattern is a behavioral design pattern that reduces coupling between components by making them communicate indirectly through a mediator object. It's perfect for breaking circular dependencies!

Let me walk you through how I implemented this pattern in a Kotlin/Koin project:

Step 1: Create a Mediator Interface

First, I defined an interface that declares methods both repositories need from each other:

interface TokenUserMediator {
    /**
     * Invalidates the refresh token for a user.
     */
    fun invalidateUserTokens(username: String): Pair<Boolean, String?>

    /**
     * Retrieves a user profile by username.
     */
    fun getUserByUsername(username: String): Profile?
}
Enter fullscreen mode Exit fullscreen mode

This interface serves as a contract that both repositories can depend on, breaking the direct dependency between them.

Step 2: Implement the Mediator

Next, I created an implementation that delegates calls to the appropriate repositories:

class TokenUserMediatorImpl : TokenUserMediator, KoinComponent {
    // Use Koin's lazy injection
    private val tokenRepository: TokenRepository by inject()
    private val usersRepository: UsersRepository by inject()

    override fun invalidateUserTokens(username: String): Pair<Boolean, String?> {
        return tokenRepository.invalidateUserTokens(username)
    }

    override fun getUserByUsername(username: String): Profile? {
        return usersRepository.getUserByUsername(username)
    }
}
Enter fullscreen mode Exit fullscreen mode

The magic here is using Koin's inject() function which lazily resolves dependencies only when the methods are called, not when the mediator is constructed. This breaks the initialization deadlock!

Step 3: Update Your Repositories

Now, I modified both repositories to depend on the mediator interface instead of directly on each other:

// TokenRepositoryImpl now depends on the mediator
class TokenRepositoryImpl(
    private val mediator: TokenUserMediator
): TokenRepository {
    // ...existing code...

    override fun extractUserFromToken(call: ApplicationCall): Profile? {
        // ...when we need user info...
        return mediator.getUserByUsername(username)
    }
}

// UsersRepositoryImpl also depends on the mediator
class UsersRepositoryImpl(
    private val mediator: TokenUserMediator
) : UsersRepository {
    // ...existing code...

    override fun deleteUser(username: String): Pair<Boolean, String?> {
        // ...when we need to invalidate tokens...
        mediator.invalidateUserTokens(username)
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Wire It Up in Your DI Container

Finally, I updated the Koin module to register the mediator first, then the repositories:

val appModule = module {
    // Register the mediator to break circular dependencies
    single<TokenUserMediator> { TokenUserMediatorImpl() }

    // Repositories that use the mediator
    single<TokenRepository> { TokenRepositoryImpl(get()) }
    single<UsersRepository> { UsersRepositoryImpl(get()) }

    // Other components...
}
Enter fullscreen mode Exit fullscreen mode

How It Works: Breaking the Loop

Let's visualize what we've done:

Before (circular dependency):

TokenRepository ──────► UsersRepository
      ▲                      │
      └──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

After (with mediator):

             TokenUserMediator
                 (Interface)
                ▲           ▲
               /             \
              /               \
TokenRepository             UsersRepository
              ▲               ▲
               \             /
                \           /
             TokenUserMediatorImpl
Enter fullscreen mode Exit fullscreen mode

When the application starts:

  1. Koin creates the TokenUserMediator implementation first
  2. Then it creates both repositories, injecting the mediator into them
  3. When repository methods are called, the mediator's methods handle the communication
  4. The mediator lazily resolves the actual repositories only when needed

The Benefits: Why You Should Care

This pattern isn't just about fixing errors - it offers several advantages:

  1. Clean Architecture: Your code becomes more modular and follows the Dependency Inversion Principle.

  2. Easier Testing: You can test components in isolation by mocking the mediator.

  3. Improved Maintainability: Adding new interactions between repositories becomes simpler - just add methods to the mediator.

  4. Scalability: This pattern works well as your codebase grows, keeping dependencies manageable.

Alternative Approaches

While I found the Mediator Pattern to be the most elegant solution, there are other ways to tackle circular dependencies:

  1. Service Locator: Similar to the mediator but more generic, allowing components to look up dependencies.

  2. Extract Interface: Create interfaces for both components and depend on those instead.

  3. Setter Injection: Use constructor injection for one dependency and setter injection for the other.

  4. Refactoring: Sometimes the best solution is to restructure your code to eliminate the circular dependency entirely.

Real-World Implementation

In my project, this solution resolved a nasty StackOverflowError that was preventing the application from starting. The codebase became more maintainable, and I could add new features without worrying about circular dependencies.

Here's what the actual implementation looked like in my Kotlin backend:

// TokenRepositoryImpl
override fun extractUserFromToken(call: ApplicationCall): Profile? {
    return try {
        val authorizationHeader = call.request.headers[HttpHeaders.Authorization]
        if (authorizationHeader.isNullOrBlank() || !authorizationHeader.startsWith("Bearer ")) {
            logMessage("No Authorization header found or invalid format")
            return null
        }

        val accessToken = authorizationHeader.removePrefix("Bearer ").trim()
        val username = getUsernameFromToken(accessToken)
        if (username != null) {
            return mediator.getUserByUsername(username)  // Using the mediator!
        }
        null
    } catch (e: Exception) {
        logMessage("Error extracting user from token: ${e.message}", LogType.ERROR)
        null
    }
}

// UsersRepositoryImpl
override fun completePasswordReset(token: String, newPassword: String): Pair<Boolean, String?> {
    return transaction {
        try {
            // ... validation logic ...

            mediator.invalidateUserTokens(username)  // Using the mediator!

            // ... rest of the implementation ...

            Pair(true, null)
        } catch (e: Exception) {
            Pair(false, "Error completing password reset: ${e.message}")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The mediator pattern is a powerful tool for breaking circular dependencies in your Kotlin applications. While it does introduce an extra layer of abstraction, the benefits in terms of cleaner architecture and avoiding runtime errors make it well worth the effort.

Next time you encounter a circular dependency problem, give the mediator pattern a try. Your codebase (and your sanity) will thank you!


Have you faced circular dependency issues in your projects? What solutions have you tried? Let me know in the comments!


Code examples in this post are from a real-world Kotlin backend application using Koin for dependency injection. The pattern can be applied in similar ways to other languages and frameworks.

Comments 0 total

    Add comment