Guarding your app against unwanted dependencies

Guarding your app against unwanted dependencies

In larger organizations, managing dependencies can be challenging. It’s easy to end up with multiple dependencies that serve the same purpose. In this blog post, I want to share an easy way to protect yourself against unwanted dependencies.

Detecting unwanted dependencies

Let’s say you want to use Moshi to work with JSON files. You don’t want two JSON libraries inside your app, so let’s block the usage of GSON. To do this, we can scan the Gradle dependency graph and fail the build if we encounter any dependency on GSON. Adding this snippet to your root-level build.gradle.kts file should do the trick:

val disallowedDependencies = setOf<String>(
    "com.google.code.gson:gson"
)

fun traverse(dependency: DependencyResult) {
    if (dependency !is ResolvedDependencyResult) return // Can't validate unresolved result
    
    val module = dependency.selected.id as? ModuleComponentIdentifier
    val identifier = module?.moduleIdentifier?.toString()
    if (disallowedDependencies.contains(identifier)) {
        throw GradleException(
            "Dependency $identifier not allowed (introduced by ${dependency.from})")
    }
    dependency.selected.dependencies.forEach(::traverse)
}

subprojects {
    configurations.configureEach {
        incoming.afterResolve {
            resolutionResult.root.dependencies.forEach(::traverse)
        }
    }
}

This scans all configurations of every module for unwanted dependencies and throws an exception if any is encountered. You can see if it works by adding a dependency on GSON.

Distinguishing direct and transitive dependencies

Sometimes it’s necessary to handle direct and transitive dependencies differently. For example, you might want to prohibit developers from using kotlin-reflect directly, but you still want to allow third-party libraries to use it. To support this, we can check if a dependency comes from a Gradle project (direct), or from another library (transitive) and deal with it accordingly.

val disallowedDirectDependencies = setOf<String>(
    "org.jetbrains.kotlin:kotlin-reflect"
)

fun traverse(dependency: DependencyResult) {
    // ...
    if (disallowedDependencies.contains(identifier)) {
        throw GradleException(
            "Dependency $identifier not allowed (introduced by ${dependency.from})")
    }
    val isDirectDependency = dependency.from.id is ProjectComponentIdentifier
    if (isDirectDependency && disallowedDirectDependencies.contains(identifier)) {
        throw GradleException(
            "Direct dependency $identifier not allowed (introduced by ${dependency.from})")
    }
    // ...
}

Some optimizations

Because we apply this logic to every Gradle project, we can make some optimizations. If we encounter a dependency on another project, we can skip verifying it, as its downstream dependencies will be validated separately:

fun traverse(dependency: DependencyResult) {
    // ...
    val identifier = module?.moduleIdentifier?.toString()
    if (identifier == null) {
        return // Skip project dependencies -> configuration will be validated separately
    }
    // ...
}

We’re also verifying every configuration, which might not be what you want. For example, you could allow the usage of some dependencies for tests, but not for production code. In many cases it’s enough to check the final classpath of a project:

subprojects {
    configurations
        .matching { it.name.contains("RuntimeClasspath", ignoreCase = true) }
        .configureEach {
            // ...
        }
}

The final product

Putting all of this together, we have an easy way to guard our app against any unwanted dependencies, which will hopefully save us some headaches in the future.

val disallowedDependencies = setOf<String>(
    "com.google.code.gson:gson" // Adapt to your own requirements
)

val disallowedDirectDependencies = setOf<String>(
    "org.jetbrains.kotlin:kotlin-reflect" // Adapt to your own requirements
)

fun traverse(dependency: DependencyResult) {
    if (dependency !is ResolvedDependencyResult) return // Can't validate unresolved result
    
    val module = dependency.selected.id as? ModuleComponentIdentifier
    val identifier = module?.moduleIdentifier?.toString()
    if (identifier == null) {
        return // Skip project dependencies -> configuration will be validated separately
    }

    if (disallowedDependencies.contains(identifier)) {
        throw GradleException(
            "Dependency $identifier not allowed (introduced by ${dependency.from})")
    }
    val isDirectDependency = dependency.from.id is ProjectComponentIdentifier
    if (isDirectDependency && disallowedDirectDependencies.contains(identifier)) {
        throw GradleException(
            "Direct dependency $identifier not allowed (introduced by ${dependency.from})")
    }

    dependency.selected.dependencies.forEach(::traverse)
}

subprojects {
    configurations
        .matching { it.name.contains("RuntimeClasspath", ignoreCase = true) }
        .configureEach {
            incoming.afterResolve {
                resolutionResult.root.dependencies.forEach(::traverse)
            }
        }
}