PearceCodes

Reflection to Enforce Coding Standards

2022-04-29 - Tags:

Reflection can be used for build time verification of code standards within a package. In this blog post we explore a simple example that ensures that Jackson is properly configured to deserialize an interface as more classes implement that interface.

Some teams rely on code review to enforce conventions. These could be syntactic like style guide rules, or they could be logical “Controller” classes have dependencies on “Services” but not vice versa. In general I think it is preferable to use automated tools to enforce these conventions. Reflection allows our software to reason about itself and it can be coupled with build time verification tools to enforce conventions that pertain to the structure of your application.

In our application we use kotlin data classes to define an API to support StringBuilder like interface. It has two methods:

  1. AddCharacter - adds a character to the end of the string indicated by the stringId
data class NewCharacterEvent(
    override val stringId: String,
    val newCharacter: Char,
) : Message
  1. PrintString - returns the full string that corresponds to the stringId
data class PrintStringEvent(
    override val stringId: String,
) : Message

To simplify our application both messages implement the Message interface:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
    JsonSubTypes.Type(value = NewCharacterEvent::class, name = "NewCharacterEvent"),
    JsonSubTypes.Type(value = PrintStringEvent::class, name = "PrintStringEvent"),
)
interface Message {
    val stringId: String
}

We want to make sure that whenever a new class gets created, the @JsonSubTypes info is updated. This hint tells Jackson to include the "type": "PrintStringEvent" into the json payload to assist in deserialization. Without the hint, the type field will not be included and when trying to deserialize a message Jackson wont know what class to use.

This could be enforced by a code review, but instead I prefer to use Reflections and Junit to verify at build time that this best practice is followed. I wrote two tests:

  1. verifyAllMessagesHaveJacksonMapping verifies that all classes that implement the message interface also have a corresponding JsonSubTypes annotation.
  2. verifyAllDeviceMessagesUseSimpleNameAsJsonTypeName enforces the idea that the type string should normally match the class name.

An example test follows:

class MessageTest {
    companion object : Logging

    @Test
    fun verifyAllMessagesHaveJacksonMapping() {
        val classesThatImplementInterface = getClassesThatImplement(Message::class)
        logger.info("listing detected classes")
        classesThatImplementInterface.forEach { logger.info(it.name) }

        val jsonTypes = getAnnotatedClasses(Message::class)
        logger.info("listing annotations")
        jsonTypes.forEach { logger.info(it.jvmName) }

        val messageClassesNames = classesThatImplementInterface.map { it.name }
        val jsonTypesClassNames = jsonTypes.map { it.jvmName }
        assertThat(jsonTypesClassNames).containsAll(messageClassesNames)
    }

    @Test
    fun verifyAllDeviceMessagesUseSimpleNameAsJsonTypeName() {
        val excludedNames = listOf<String>()

        val jsonTypes = getAnnotatedJsonSubTypes(Message::class)
        jsonTypes.forEach { type ->
            if (!excludedNames.contains(type.name)) {
                assertThat(type.name).isEqualTo(type.value.simpleName)
            }
        }
    }

    private   fun getClassesThatImplement(interfaceType: KClass<*>): Set<Class<out Any>> {
        val reflections = Reflections("datatypes.messages")
        return reflections.getSubTypesOf(interfaceType.java)
            .filter { !it.isInterface }
            .toSet()
    }

    private   fun getAnnotatedClasses(interfaceType: KClass<*>): List<KClass<*>> {
        val jsonTypes = getAnnotatedJsonSubTypes(interfaceType)
        return jsonTypes.map { it.value }
    }

    private   fun getAnnotatedJsonSubTypes(interfaceType: KClass<*>): List<JsonSubTypes.Type> {
        val jsonTypes = interfaceType.annotations.find { it.annotationClass == JsonSubTypes::class } as? JsonSubTypes
        return jsonTypes?.value?.asList() ?: listOf()
    }
}

This is a useful strategy for automating checks you might want to do at build time. This can be used to verify that classes of one package only depend or cannot depend on classes of another creating a package hierarchy, or it can restrict some parts of the code to only use “read-only” DAOs.

Placeholder for github link to sample project.