Few established linters exist for the Kotlin programming language such as detekt, ktlint, and Android lint. All of these linters are able to verify a single file in isolation however, none of them can verify rules that relate to specific types of code declarations e.g. classes extending BaseUseCase
class or interfaces being annotated with @Logger
annotation.
In this article, I will explain why ArchUnit is not ideal for Kotlin and my motivation behind crafting Konsist.
Read Introduction to Konsist.
It is evident that ArchUnit was primarily architected for Java. You may wonder if ArchUnit can be used for Kotlin. ArchUnit indeed can be applied to the Kotlin project, however, there are some trade-offs. While many Java libraries are interoperable with Kotlin, it doesn’t necessarily translate to optimal integration or functionality. Let’s take a closer look.
Kotlin Multiplatform Support
While ArchUnit has garnered attention as an effective solution for architecture validation in Java projects, it falls short when it comes to Kotlin Multiplatform (KMP) support.
You may think that Kotlin Multiplatform support can be easily added to ArchUnit, but this is not the case. ArchUnit by design imports Java bytecode:
ArchUnit code import
ArchUnit code importArchUnit’s fundamental dependence on Java bytecode is its major drawback in supporting Kotlin Multiplatform. The Kotlin Multiplatform aims to go beyond just the JVM, targeting other platforms such as JavaScript, Native, and more. ArchUnit’s dependency on Java bytecode compromises its compatibility with the broad compilation aspirations of Kotlin Multiplatform.
Konsist on the other hand imports Kotlin code directly by reading and parsing Kotlin (text) files:
Konsist code import
Job Offers
This means that it is compatible regardless of the Kotlin Multiplatform compilation targets:
Take a look at the starter Kotlin Multiplatform project with the Konsist Test.
Kotlin Specific API
Kotlin offers a more expressive API, and certain Java practices don’t seamlessly translate into the Kotlin paradigm. For instance, where Java might leverage methods with multiple overloads, Kotlin often employs default arguments.
Given that ArchUnit is Java-centric, its usage with Kotlin results in numerous issues. Crafting Kotlin-specific checks becomes challenging or nearly impossible to write. Due to ArchUnit’s reliance on Java bytecode validation of Kotlin-specific constructs like extensions, data classes, and operators becomes problematic with ArchUnit. Kotlin compiler employs extensive code generation and various methodologies to accommodate constructs not inherently supported by Java bytecode. Consequently, a single Kotlin source code declaration (such as property or class) might manifest as multiple declarations at the Java bytecode level. These representations can appear disjointed from Kotlin source code, are hard to understand, and may even change with subsequent Kotlin versions.
Here are a few examples of checks that are hard to write with ArchUnit (and existing Kotlin linters):
- UseCase should have a single method called invoke — this works fine in ArchUnit until you define default argument values. The Kotlin compiler will generate multiple invoke method overloads — although Kotlin code is fine the ArchUnit “single method” check will fail.
- Every class should have a test class. This check also works fine until you try to exclude data or value classes usually storing data models — no easy way of doing this
- Checks like “have one public method named…” or “have test subject named” require writing complex ArchUnit extensions.
Konsist supports all Kotlin declarations by design. You can define checks that rely on extensions, default arguments, sealed interfaces/classes, companion objects, etc. Consider this basic test that omits both data and value classes:
@Test fun `every class - except data and value class - has test`() { Konsist .scopeFromProduction() .classes() .withoutSomeModifiers(KoModifier.DATA, KoModifier.VALUE) .assert { it.hasTestClass() } }
See Konsist documentation for more Konsist Test snippets.
Test Syntax
The ArchUnit syntax is quite verbose because ArchUnit API is utilizing a chained method calls approach. This is common practice for many Java libraries, however, the API is less flexible and much more verbose — we can do better using Kotlin.
Consider this ArchUnit test:
@Test fun `classes with 'UseCase' suffix should have single method named 'invoke'`() classes() .that().haveSimpleNameEndingWith("UseCase") .should(haveOnePublicMethodWithName("invoke")) .check(allClasses) }
Looks simple, right? The catch here is that the haveOnePublicMethodWithName
method is not an ArchUnit build-in method — it has to be manually implemented. While it is technically feasible to extend ArchUnit writing a new condition is a quite complex task. It requires learning (not so obvious) internal ArchUnit API and simple conditions can result in quite verbose code (you don’t have to analise this code, just notice the large quantity of it):
fun haveOnePublicMethodWithName(methodName: String) = object : ArchCondition<JavaClass>("have exactly one public method named '$methodName'") { override fun check(javaClass: JavaClass, conditionEvents: ConditionEvents) { val publicMethods = javaClass .methods .filter { it.modifiers.contains(JavaModifier.PUBLIC) } if (publicMethods.size == 1) { val method = publicMethods[0] // When method accepts Kotlin value class then Kotlin will generate // a random suffix to the method name e.g. methodName-suffix val methodExists = method.name == methodName || method.name.startsWith("$methodName-") if (!methodExists) { val message: String = createMessage( javaClass, "contains does not have method named '${method.name}' ${method.sourceCodeLocation}", ) conditionEvents.add(violated(javaClass, message)) } } else { val message: String = createMessage( javaClass, "contains multiple public methods", ) conditionEvents.add(violated(javaClass, message)) } } }
You would encounter a need to write similar custom ArchUnit checks when trying to verify the name of the test subject or making sure that certain classes have a test. This is a lof of code required to express such simple idea.
From my personal experience this is the main reason why ArchUnit is not used in many projects —it is great as an idea, but it does not scale well. ArchUnit API is too complex to write project-specific rules, so most devs don’t take a time to even learn it.
Now consider the alternative — equivalent Konsist test where build-in Konsist API allows to query declarations and write exactly the same guards:
fun `classes with 'UseCase' suffix should have single method named 'invoke'`() { Konsist.scopeFromProject() .classes() .withNameEndingWith("UseCase") .assert { it.function.name == "invoke" && it.numPublicDeclarations() == 1 } }
Konsist API follows different approach. It utilise Kotlin lambdas and follows known Kotlin collection processing syntax. The Konsist API reflects declarations visible in Kotlin code – you know the class is in the Kotlin code you just need to look for a “class” in the Konsist API, you see a property with the given name in the Kotlin code, it will be reflected in the Konsist API as well.
This “mimic Kotlin code base” approach , means that Konsist API is much easier to use and it simplifies the process of crafting custom, project-specific code guards. Developers can tap into Konsist’s streamlined interface to create tailored safeguards, ensuring that code remains robust and in line with specific project architectures.
Summary
Step aside, ArchUnit! Konsist has arrived as Kotlin’s dedicated architectural linter, crafted perfectly to bridge the gaps that once existed. Not only does Konsist gracefully dance with Kotlin Multiplatform Support, but it also seamlessly integrates with Kotlin-specific declarations. And the cherry on top? An intuitive API that mirrors Kotlin’s code ethos, inviting you to experience linter efficiency like never before. Dive in and elevate your Kotlin game!
Follow me on Twitter.
Links
- Konsist project repository
- Konsist documentation
- Konsist Slack channel (at kotlinlang)
- Introducing Konsist: A Cutting-Edge Kotlin Linter
- Refactoring Multi-Module Kotlin Project With Konsist
- Protect Kotlin Project Architecture Using Konsist
- Konsist starter projects (GitHub)
- Android-Showcase (Android project using Konsist)
This article was previously published on proandroiddev.com