The Java ecosystem is great for its breadth and depth of libraries. Their availability helps to fill in gaps in the JDK, and they make us all much more productive. The problem with dependencies is the fact that everyones development style and philosophy around breaking changes, versioning, dependency management, and so on is all so varied, and we import these different approaches into our projects when we include a dependency. We have to be sure that these dependencies pay for the cost of including it.
What is the cost of a dependency?
When a dependency is included in a project, we have to assume that the dependency will be included in the project forever. The biggest concerns that we must have with bringing in dependencies are:
- Each dependency brings with it the potential for security vulnerabilities.
- Each dependency typically has other dependencies, forming a transitive dependency chain.
- Transitive dependencies from different direct dependencies may conflict with each other, resulting in runtime exceptions.
- Correctness of the functionality offered by the dependency.
- Support/maintenance offered by the developers of the dependency.
Don't duplicate functionality
Because there are so many libraries out in the Java ecosystem, we will often find ourselves with dependencies on multiple libraries that provide essentially the same functionality, for example Jackson and Gson for JSON processing. Often the choice of which library should be used directly by our project is already made for us by being a transitive dependency of another dependency. For example, Spring is tightly bound to Jackson for its JSON processing needs, and it would seem unwise in this case to use anything other than Jackson for our JSON processing needs.
Understanding dependencies
Fortunately build tools such as Maven and Gradle give us useful tools to understand our dependencies (including all transitive dependencies). Developers should frequently run the following commands on their project, particularly when a dependency is added or upgraded to a newer version:
- For Maven, run
mvn dependency:tree
- For Gradle, run
./gradlew dependencies
When these tools are run, we should always try to justify the dependency, and these should be amplified by all of the transitive dependencies - the more there are, the stronger the justification should be for the dependency to be included.
Dependency scope
Our project files let us define dependencies in a number of different scopes, and these scopes impact the availability of the dependency in certain conditions. In Maven there are six dependency scopes, the most common of which are:
- compile: The default scope if none is specified, compile dependencies are available at build and runtime of the application.
- test: This scope specifies that the dependency is only available at test execution time, and will not be available during build or runtime of the application itself.
It is important to properly apply the correct scope (especially the 'test' scope) so that we do not bring more dependencies in at compile and run time than necessary. For example, a JUnit dependency should be part of the test scope, and in Maven would look as follows:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
Note that in the example above, no version is specified, as it is expected that you would use the JUnit BOM.
Gradle has a similar concept of scopes, and the equivalent JUnit dependency statement in Gradle would be the following:
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.7.2'