Encapsulation and separation of internal components from public ones is probably one of the most underrated programming techniques when it comes to achieving long-lasting maintainability.
Luckily, Java features an underrated package-private visibility modifier which helps a lot in hiding unwanted implementation details. Unfortunately, if the number of internal classes is significant, it doesn’t scale well… luckily, ArchUnit is there for us.
Public vs Private
Separation of private from public lets you decrease coupling and gain the freedom to change implementation details without worrying about introducing unwanted breaking changes.
A real-life example would be one of my libraries – parallel-collectors. Thanks to minimizing the surface of the public API I had the chance to reorganize the internal architecture a few times without risking introducing any breaking changes at the API level.
Had I not done that, my hands would have been tied since someone could have used some of my internal classes directly.
The same applies to any other module or unit that needs to interact with something else.
Embracing package-private
Giving up on the internal package structure lets us embrace the package-private modifier and restrict the visibility of classes that should not be accessed from outside the package.
Let’s have a look at a typical package structure:
In this option, unfortunately, we need to keep all the internal classes public because package-private visibility works within one package and not the whole hierarchy.
Having said that, if we want to leverage package-private, we’d need to place them all in a single package:
Unfortunately, this approach doesn’t scale well with the number of classes.
Introducing ArchUnit
Luckily, we can have both – internal package structure and visibility restrictions thanks to ArchUnit, which is a test library that can be used for enforcing architectural conventions.
For example, we can recreate the functionality of hierarchical package-private modifier, by restricting access to classes in sub-packages of com.pivovarit.movies, to classes residing in the whole package hierarchy:
public class ArchitectureTest { private static final JavaClasses classes = new ClassFileImporter() // ... .importPackages("com.pivovarit"); @Test void com_pivovarit_movies_shouldNotExposeInternalClasses() { classes().that().resideInAPackage("com.pivovarit.movies.*") .should() .onlyBeAccessed().byClassesThat() .resideInAPackage("com.pivovarit.movies..") .check(classes); } }
And now, if we create a class outside the package and use the public API(Rentals), tests are green:
package com.pivovarit; import com.pivovarit.movies.Rentals; public class Starter { public static void main(String[] args) { Rentals instance = Rentals.instance(); boolean rent = instance.rent(42); } }
But, if we try to access MovieDetailsRepository directly, we’ll end up with a violation:
package com.pivovarit; import com.pivovarit.movies.repository.MovieDetailsRepository; public class Starter { public static void main(String[] args) { MovieDetailsRepository movieDetailsRepository = new MovieDetailsRepository(); // java.lang.AssertionError: Architecture Violation } }
Expanding the Idea
Naturally, ArchUnit can be used to enforce myriads of conventions, for example, in the project mentioned earlier, it’s used to enforce zero-dependencies policy:
@Test void shouldHaveZeroDependencies() { classes().that().resideInAPackage("com.pivovarit.collectors") .should() .onlyDependOnClassesThat() .resideInAnyPackage("com.pivovarit.collectors", "java..") // ... .check(classes); }
…or enforce the existence of a single public class:
@Test void shouldHaveSingleFacade() { classes().that().arePublic() .should().haveSimpleName("ParallelCollectors") .andShould().haveOnlyPrivateConstructors() .andShould().haveModifier(FINAL) // ... .check(classes); }
More examples can be found on the official page.
Conclusion
Separating internal components from public ones is one of the best things you can do to increase the maintainability of your software. Unfortunately, Java’s native tools are limited, but ArchUnit can cover cases that Java can’t.
Of course, the above examples are just a base that you can build upon. Real-life is full of edge-cases.
However, ArchUnit should help you harness the surface of your public API, but at the end of the day, it’s your job to do that. ArchUnit won’t help if you keep adding more and more exceptions to your rules.
Examples backing this article can be found on GitHub.