How to Proactively Prevent Tech Debt with ArchUnit

Garrett James Cassar
6 min readSep 26, 2023

--

Complexity and Tech Debt has a funny way of compounding.

Software isn’t the sort of thing that you get to just throw away very often. It sticks around. You build on foundations that were laid down before, like layering a cake. And like layering a cake the wobbliness of that cake compounds with each previous wobbly layer.

Any good developer might understandably feel a great calling to delayer and unwobble this cake before adding additional layers. But without any guiding stars or navigation devices, it can start feeling like unwobbling a cake on the back of a pick up truck.

So what is the best way to remove tech debt and unwobble the cake?

Easy, don’t create it in the first place. While tech debt is as inevitable as time itself, I believe that a little proactivity can go a long way.

Here are two of my Guiding Stars that I’ve adapted (mercilessly plagiarized) from the excellent books, Clean architecture, Domain driven design and building microservices, and a navigation device I like to use called ArchUnit.

About ArchUnit
ArchUnit testing is a remarkably simple concept. By leveraging package structure and class types ArchUnit test simply scans your code and creates rule violations based on best practices that your team might decide should be best practices. An example read simply, could be something such as “REST Controllers should not reference database entity classes” or “Controllers should all live in a package with ‘controller’ in the package”. If you’re not sure what I’m on about, hopefully the examples below will make that clear.

Principle 1 Layer Separation
Organizing your code in distinct layers is an exercise of isolating code which helps with communication with external systems and technologies and having a distinct service layer which services business use cases. This helps to isolate what you expose to from inside your application. This helps to make your application easier to maintain and distinguish your domain from others. You could say that it creates high cohesion and lower coupling.

This usually takes the form of

  1. Having a distinct class which represents the data you would like to pick up from other systems or technologies (DTO)
  2. Translating that into an internal representation of that data.
  3. Translating that back into a class (DTO) which represents the data you want to communicate across to another external system.

External System connections -> Connectors
Translation classes -> Adaptors
Internal representation of classes -> Domain objects
Handling use cases -> Services

Rule 1:
All controllers methods should only reference their relevant DTO as a parameter.

Background:
A DTO serves as a template for deserialization into the messages your connectors use. In REST, this would be the HTTP message passed between two teams, in Kafka it would be the log you produce to your topic or consume from a topic, in JDBC, this will be the SQL representation of your object.

Enforcement

    @Test
fun `Connectors should only reference dtos or primitive types in their parameters`() {
val importedClasses: JavaClasses = ClassFileImporter().importPackages(BASE_PACKAGE)

val haveDtoOrPrimitiveParameterTypes = object : ArchCondition<HasParameterTypes>("Connectors should only reference dtos or primitive types in their parameters") {
override fun check(parameterTypes: HasParameterTypes, events: ConditionEvents) {
val paramTypes = parameterTypes.rawParameterTypes

val disallowedTypes = paramTypes.filter {
(it.packageName.contains(BASE_PACKAGE) && !it.packageName.contains("dto"))
}

if (disallowedTypes.isNotEmpty()) {
events.add(SimpleConditionEvent.violated(parameterTypes, "Method $parameterTypes has disallowed parameter types $disallowedTypes"))
}
}
}

val dtoParamRule = methods()
.that().areDeclaredInClassesThat().resideInAnyPackage("..connectors..")
.should(haveDtoOrPrimitiveParameterTypes)
.`as`("Connector classes should only reference adapters and services")

dtoParamRule.check(importedClasses)
}

Footnote:
This rule has deliberately been made less opinionated and more flexible. You could increase the strictness of the rule by ensuring that REST Resources only reference HTTP request and responses DTO, or that kafka readers only reference Kafka message DTOs.

Rule 2:
Your connectors should only reference services and adaptors from within the project.

Background:
By ensuring that connectors only reference adaptors and services. You are making sure one connector does not reference another. This helps to ensure that no business logic is done at the connector level and that most business logic happens at the service layer.

Enforcement

    @Test
fun `Connector classes should only reference adapters and services`() {
val importedClasses: JavaClasses = ClassFileImporter().importPackages(BASE_PACKAGE)

val canOnlyAccessAdaptersAndServices = object : ArchCondition<JavaClass>("can only access classes in the adaptor and service packages") {
override fun check(javaClass: JavaClass, events: ConditionEvents) {
val disallowedDependencies = javaClass.directDependenciesFromSelf.filter { dependency ->
dependency.targetClass.packageName.contains(BASE_PACKAGE) && !(
dependency.targetClass.packageName.contains("adaptor")
|| dependency.targetClass.packageName.contains("service")
|| dependency.targetClass.packageName.contains("dto"))
}

if (disallowedDependencies.isNotEmpty()) {
events.add(SimpleConditionEvent.violated(javaClass, "$javaClass has disallowed dependencies $disallowedDependencies"))
}
}
}

val accessAdaptorsAndServicesRule = classes()
.that().resideInAnyPackage("..connectors..")
.should(canOnlyAccessAdaptersAndServices)
.`as`("Connector classes should only reference adapters and services")

accessAdaptorsAndServicesRule.check(importedClasses)
}

Rule 3:
Services should only reference “domain classes” or primitive class types as a parameter, and never a DTO.

Background:
By ensuring that Services only accept domain level objects, you make sure that services only do business logic on internal domain objects. This has two key benefits.

Firstly your service can be called in the same way no matter which connector it’s being called from, whether it’s a Kafka reader, REST controller or message queue.

Secondly and more importantly, it enables business logic being done on the domain object itself.

Enforcement

    @Test
fun `Service methods should only reference domain classes or primitives and never DTOs`() {
val importedClasses: JavaClasses = ClassFileImporter().importPackages(BASE_PACKAGE)

val haveDomainOrPrimitiveParameterTypes = object : ArchCondition<HasParameterTypes>("should have domain classes or primitives as parameters but never DTOs") {
override fun check(parameterTypes: HasParameterTypes, events: ConditionEvents) {
val paramTypes = parameterTypes.rawParameterTypes

val disallowedTypes = paramTypes.filter {
(it.packageName.contains(BASE_PACKAGE) && !it.packageName.contains("domain"))
}

if (disallowedTypes.isNotEmpty()) {
events.add(SimpleConditionEvent.violated(parameterTypes, "Method ${parameterTypes} has disallowed parameter types $disallowedTypes"))
}
}
}

val serviceParamRule = methods()
.that().areDeclaredInClassesThat().resideInAPackage("..service..")
.should(haveDomainOrPrimitiveParameterTypes)

serviceParamRule.check(importedClasses)
}

Principle 2 Tolerable Reader

Rule 1:
DTOs should never have an enum as a field.

Background:
By having a “Tolerable Reader” we ensure that clients can not cause a serialization error by trying to read a field we can’t recognise.

For example if I had the enum

enum class ActivityDay {
SATURDAY, SUNDAY
}

If we decide that FRIDAY is now a weekend day too and starts sending a message that looks like this:

POST 
{
"Class" : "DancingTheFlemenco",
"ActivityDay" : "FRIDAY"
}

Receiving the request parameter as an enum will cause the application. to blow up with a serialization problem.

Does that mean you shouldn’t use enums at all?

No. Enums are a brilliant way of limiting the variability of your data and creating strong cohesion in your code. See this great video on Testing Testing

Enums should absolutely be used but translated from a String format in the adaptor layer. There are a number of ways that you can deal with unknown enums, which can be the topic of another blog post.

Enforcement

    @Test
fun `Consuming DTOs not have enum fields`() {
val importedClasses: JavaClasses = ClassFileImporter().importPackages(BASE_PACKAGE)

val shouldNotHaveEnumFields = object : ArchCondition<JavaField>("Consuming DTOs should not have enum fields") {
override fun check(javaField: JavaField, events: ConditionEvents) {
if (javaField.rawType.isEnum) {
events.add(SimpleConditionEvent.violated(javaField, "Field $javaField is an enum and thus violates the rule"))
}
val javaType = javaField.type

if(javaType is JavaParameterizedType){
for (typeArg in javaType.actualTypeArguments) {
if (Class.forName(typeArg.name).isEnum) {
events.add(SimpleConditionEvent.violated(javaField, "Field $javaField is an enum and thus violates the rule"))
}
}
}

if(javaType.javaClass.typeParameters.javaClass.isEnum){
events.add(SimpleConditionEvent.violated(javaField, "Field $javaField is an enum and thus violates the rule"))
}

}
}

val enumFieldRule = fields()
.that().areDeclaredInClassesThat().resideInAnyPackage("..dto..")
.should(shouldNotHaveEnumFields)

enumFieldRule.check(importedClasses)
}

Conclusion

Just like layering a wobbly cake and trying to unwobble it later is much harder than layering it straight the first time. Removing existing tech debt is much harder than not creating it in the first place.

ArchUnit can help you define and enforce high level architectural rules that keep a check on the codebase, acting like a spirit level and making sure that your cake is layered well from the beginning.

Please Comment if you have any other good examples of the use of ArchUnit that helps to keep your applications clean and tech debt light.

If you enjoyed this article, clap, comment, follow me on Medium, or share with all of your clever friends.

If you really liked this article and want to give me lots of money for being clever then add me on LinkedIn here.

Clone the code to play around with here

--

--