On enums, annotations and domain modeling

cyclone fence in shallow photography

In this post I’d like to review a case where the use of the very common @JsonProperty annotation leads to unintended coupling.

Let’s take a flight reservation system as our domain example. Each passenger can select his meal preference. We represent this preference with an enum:

enum class MealPreference {
  NONE,
  VEGETARIAN,
  VEGAN,
  LACTO_OVO,
  KOSHER,
  INDIAN
}

This enum is part of the Passenger record:

data class Passenger(
    val firstName: String,
    val lastName: String,
    val emailAddress: String,
    val mealPreference: MealPreference
)

Our app would like to let the passenger edit his profile and change his meal preference. There are some considerations we need to take into account:

  • The list of preferences could change – we want to maintain the list in a central place so that we don’t need to update separate frontend and backend code. The app can query the backend for a list of valid drop-down values.
  • We would obviously like to show human readable text in our app, not uppercase constants with underscores

One really simple approach is to add the @JsonProperty annotation:

enum class MealPreference {
  @JsonProperty("None")
  NONE,

  @JsonProperty("Vegetarian")
  VEGETARIAN,

  @JsonProperty("Vegan")
  VEGAN,

  @JsonProperty("Lacto/Ovo")
  LACTO_OVO,

  @JsonProperty("Kosher")
  KOSHER,

  @JsonProperty("Indian")
  INDIAN;

  val allValues
    get() = values()
}

This looks like a great solution! When we request the passenger information, we’ll get the human readable value. We will also be safe from enum renames breaking the frontend code. The frontend can request allValues and get the full human readable list for displaying in drop-downs, etc.

On second glance, there’s an implicit assumption here: We are equating the @JsonProperty with the display name of the value!

Unfortunately, later on, another business process decided to save the Passenger record to the database. Incidentally, the selected method for saving the data was to save it as a single document, so the Passenger record was serialized into json and saved. As part of this serialization, our enum was serialized into its @JsonProperty value.

Congratulations! We now have a hardwired link between the data saved in the DB and the data displayed in the app.

We can still rename the enum, but on the day we will decide to localize the text, we’re going to find out that we can’t just do it. We now have to first run a project of migrating the DB value into something stable and decoupling it from the app’s displayed value.

What would have been a better approach?

Instead of overloading the use of @JsonProperty, we could have defined a dedicated field for the display name:

enum class MealPreference(val displayName: String) {
  NONE("None"),
  VEGETARIAN("Vegetarian"),
  VEGAN("Vegan"),
  LACTO_OVO("Lacto/Ovo"),
  KOSHER("Kosher"),
  INDIAN("Indian");

  val displayValues
    get() = values().map { it.displayName }
}

The app has a dedicated field, and it can get a list of values via displayValues.

When saving to the DB, we save the enum value. That’s still not ideal, since a rename in the enum might break the DB data, so let’s decouple that too by adding a dbValue field:

enum class MealPreference(val displayName: String, val dbValue: String) {
  NONE(displayName = "None", dbValue = "none"),
  VEGETARIAN(displayName = "Vegetarian", dbValue = "vegetarian"),
  VEGAN(displayName = "Vegan", dbValue = "vegan"),
  LACTO_OVO(displayName = "Lacto/Ovo", dbValue = "lacto_ovo"),
  KOSHER(displayName = "Kosher", dbValue = "kosher"),
  INDIAN(displayName = "Indian", dbValue = "indian");

  companion object {
    private val dbValueToEnum = values().associateBy { it.dbValue }

    fun fromDbValue(value: String): MealPreference = dbValueToEnum.getValue(value)
  }

  val displayValues
    get() = values().map { it.displayName }

}

When serializing the Passenger record, we need to make sure that the dbValue is used. To that purpose, we define an internal class that will represent the DB record:

data class Passenger(
    val firstName: String,
    val lastName: String,
    val emailAddress: String,
    val mealPreference: MealPreference
) {

  companion object {
    fun fromDbRecord(dbRecord: DbRecord) = dbRecord.toPassenger()
  }
  
  fun toDbRecord() = DbRecord(firstName, lastName, emailAddress, mealPreference.dbValue)

  data class DbRecord(
      val firstName: String,
      val lastName: String,
      val emailAddress: String,
      val mealPreference: String
  ) {
    fun toPassenger() = Passenger(firstName, lastName, emailAddress, MealPreference.fromDbValue(mealPreference))
  }
}

We now have complete decoupling between the app representation, the business logic code and the DB record representation.

On the downside, there is more boilerplate here – we’re defining a copy of the Passenger class that is only used as a simple DB record, but in my opinion the savings in future development time and reduced decoupling make up for this downside.

As an additional side-benefit, the Passenger class is now free to change structure if we need to do so, without breaking the DB record. For example, Passenger could change to this in the future, with no need to run a DB migration:

data class Passenger(
    val person: Person,
    val mealPreference: MealPreference
) {

  companion object {
    fun fromDbRecord(dbRecord: DbRecord) = dbRecord.toPassenger()
  }

  fun toDbRecord() = DbRecord(person.firstName, person.lastName, person.emailAddress, mealPreference.dbValue)

  data class DbRecord(
      val firstName: String,
      val lastName: String,
      val emailAddress: String,
      val mealPreference: String
  ) {
    fun toPassenger() = Passenger(
        Person(firstName, lastName, emailAddress),
        MealPreference.fromDbValue(mealPreference)
    )
  }
}

Notice that DbRecord remains unchanged. No DB migration is necessary!