| title | Solving the Implicit Search Priority Problem in AppContext |
|---|
In the first article about AppContext, we described a pitfall with implicit search order:
case class Dependency1(name: String)
object Dependency1 {
given AppContextProvider[Dependency1] = AppContextProvider.of(Dependency1("dep1:module"))
}
class Component(using AppContextProviders[(Dependency1, Dependency2)]) {
def doSomething(): String = {
s"${AppContext[Dependency1].name}, ${AppContext[Dependency2].name}"
}
}
val dep1 = Dependency1("dep1:local")
val dep2 = Dependency2("dep2:local")
val c = Component(using AppContextProviders.of(dep1, dep2))
println(c.doSomething()) // Prints "dep1:module, dep2:local" - not what we want!The problem was that AppContextProvider[Dependency1] defined in Dependency1's companion object takes priority over the one extracted from AppContextProviders, because Scala's implicit search gives high priority to the companion object of the result type.
We had a workaround - AppContextProviders.checkAllAreNeeded - to detect such issues at compile time. But now we can solve the problem properly. I don't know why I missed this during writing a first variant, now it looks trivial.
It turns out we can easy solve this problem by introducing an intermediate lookup type. If we search for a different type, which requere AppContextProvider[X], Scala compiler won't look in the companion object.
We introduce AppContextProviderLookup[T]:
trait AppContextProviderLookup[T] {
def get: T
}
trait AppContextProviderLookupLowPriority {
// Low priority fallback: delegate to AppContextProvider[T]
given fromProvider[T](using provider: AppContextProvider[T]): AppContextProviderLookup[T] with {
def get: T = provider.get
}
}
object AppContextProviderLookup extends AppContextProviderLookupLowPriority {
// High priority: lookup from AppContextProviders in scope
given fromProviders[Xs <: NonEmptyTuple, X, N <: Int](
using providers: AppContextProvidersSearch[Xs],
idx: TupleIndex.OfSubtype[Xs, X, N]
): AppContextProviderLookup[X] with {
def get: X = providers.getProvider[X, N].get
}
}Then we change AppContext.apply to use this new type:
object AppContext {
def apply[T](using AppContextProviderLookup[T]): T =
summon[AppContextProviderLookup[T]].get
}That's all.
When AppContext[Dependency1] is called inside a class with AppContextProviders[(Dependency1, ...)]:
- Scala searches for
AppContextProviderLookup[Dependency1] - It looks in
AppContextProviderLookup's companion object (notDependency1's!) - It finds
fromProviderswhich requiresAppContextProvidersSearch[Xs] - The
AppContextProviders[(Dependency1, ...)]in scope satisfies this requirement - The value from
AppContextProvidersis used
When no AppContextProviders is in scope:
- Scala searches for
AppContextProviderLookup[T] fromProvidersdoesn't apply (noAppContextProvidersSearchavailable)- Falls back to
fromProviderwhich delegates toAppContextProvider[T] - The companion-defined provider is used as expected
Now it works as expected:
class Component(using AppContextProviders[(Dependency1, Dependency2)]) {
def doSomething(): String = {
s"${AppContext[Dependency1].name}, ${AppContext[Dependency2].name}"
}
}
val dep1 = Dependency1("dep1:local")
val dep2 = Dependency2("dep2:local")
val c = Component(using AppContextProviders.of(dep1, dep2))
println(c.doSomething()) // Now prints "dep1:local, dep2:local"!The values from AppContextProviders now take priority over companion-defined defaults, making dependency injection predictable.
checkAllAreNeeded is no longer needed and has been removed.