Give the enum back its class!

In this post, i want to tackle an „issue“ that is nothing really tragic (like mixing horizontal and vertical architectures), but something that has been one of if not the biggest factors for bugs i have seen lately: Enums.

Who might be interested in this post?

What, why and how I’ll tackle in a second, but let me start with whom this post is for.

If you are working on a language, that highly encourage object oriented styles of programming and provides enum classes (not just „enumerations“ aka. enums without class), than this is for you. Languages like Typescript and C++, that have enums but only as a form of enumerations, and languages like go and PHP, that don’t have the concept of enums altogether, are not target of this post. But if you are interested in the concept, want to learn something about enum classes or just are bored, I’d be happy if you continue on reading.

I will continuously use Kotlin in this post, but the same is true for other languages that support enum classes. Kotlin is a language that support enum classes and I am fluent in it, so I feel most comfortable when using it.

What are enum classes?

I might be wrong, but I think that most, if not all developers encounter enums quite early in their career. And enums are structured like this:

enum Example {
    VALUE_1,
    VALUE_2;
}

An enum has a name and a list of values, which are written in UPPER_SNAKE_CASE by some mystic convention every developer adheres to (me included). And that is it, right?

In languages like Typescript for example: Yes. But in other languages: No.

Whilst Typescript thinks about enums as a „Set“ of values with a name in a global context, more „object oriented“ languages think about enums like sealed classes. The values are compile time instances of the enum they have been defined in. And this is the reason why they are allowed in Java annotations for example (and also why they are not allowed to have an individual constructor). For the above example, you might think about the enum like this:

"Example" is an abstract class.
The compiler creates two sub classes of the "Example" class, called VALUE_1 and VALUE_2, which may call Examples constructor, but may not have any parameters themself.
They are statically accessible.
No other instance of this "Example" class are allowed.

Enums, in a sens, are static sealed classes.

This is not necessarily the truth as stated, more like an idiom, a way of thinking about enum classes. Java is on language where this idiom matches. Another one is Kotlin.

How do enums then cause bugs?

Alright, now we are in the realm of actually using enums. Where do you use them?

Basically: Whenever you want to represent a finite set of possible values.

Let’s imagine a use case for enums, something like a state for users. A user in our application can have multiple different states, which might be something like: NOT_VERIFIED, VERIFIED, BANNED, DELETED.

Please. Let us not start a debate about whether or not DELETED is a state of a user, or if you should instead remove him from the database. Let us just take this as an example. This example state (in Kotlin) would look like this:

enum class UserState {
   NOT_VERIFIED,
   VERIFIED,
   BANNED,
   DELETED;
}

And the actual user might look like this:

@Entity
class User {
    @Enumerated(EnumType.STRING)
    @Column(name = "state")
    var state: UserState = UserState.NOT_VERIFIED
}

In this example, a user is not verified by default. If the database holds another value than NOT_VERIFIED, it will be taken. Also, for the sake of simplicity, I’ll leave out Hibernate from now on. Just keep in mind there is some magical persistence DAO, that saves the user.

We than have a service, that defines operations on this user, a UserService. This service will be invoked when the registree actually clicks on the link he received via email, which in term „verifies“ him. Also, this UserService does actions like update password, and validate login. For us we can focus on the method, whether or not a user can login. It might look like this:

class UserService {
    fun canLogin(email: String, password: String): Boolean {
        val user = findUser(email) ?: return false

        return user.hasMatchingPassword(password)
                       && user.state != UserState.BANNED
                       && user.state != UserState.DELETED
    }
}

A user might login if:

  • A User can be found for the provided email
  • The password of the found user matches the provided one
  • The found user is not banned
  • The found user is not deleted

And now let’s introduce a new state: TEMPORARILY_BANNED. This state is like banned, but only for a specified period of time, which is provided in a separate field.

How does this now cause a bug

When we add this case we don’t actually see that something is missing. The login is only prevented if a very certain subset of values is matched. Previously BANNED and DELETED, but now also TEMPORARILY_BANNED (under the pretenses that the time has not been run out now).

We, or another developer does not see this directly. He has to know and understand all of the preconditions and potential results this might have. Just think about a test cases.

In this simple example right here, it is easy to understand. But someone new to your ecosystem does not know what you build and why. We can drive this example further. If it is only one place, in a place directly linked to the domain aspect in question, than it is easy if you have written it, but even then: You will have to know where it can be found.

How do enum classes help us then?

Knowing this, what could we do differently? One thing might be, to give the user a method, like this:

class User {
    fun stateAllowsLogin(): Boolean {
        return state != UserState.BANNED
                       && state != UserState.DELETED
    }
}

This now changes the UserService method, to just that:

fun canLogin(email: String, password: String): Boolean {
    val user = findUser(email) ?: return false
    return user.stateAllowsLogin()
                   && user.hasMatchingPassword(password)
}

It makes the things more clear and aggregated in one class, which we than can reuse. It would be an architectural design that everybody would have to follow.

But still. When we work on the state itself, we don’t see the consequences of our actions. Also, this approach somewhat violates the single responsibility principle, in that the user must now the states and how to interpret those.

What we would need, is a class only dedicated to those enums to operate and this is exactly what we have with the enum classes.

Let’s utilize the enum class

And that is quite simple. Remember the explanation of enum classes? The values are implementations of the parent. So, with this in mind, we can give these values, i.e. these implementations methods! It might look like this:

enum class UserState {
    NOT_VERIFIED,
    VERIFIED,
    BANNED,
    DELETED;

    fun loginAllowed(): Boolean {
        return false;
    }
}

And now we can simply use this

UserState.VERIFIED.loginAllowed()

And how does this help?

As you might be aware: Implementations my override methods and we can do this here to. In Kotlin we have to explicitly make the function open, so that each „sub-class“ my override it. Like this:

enum class UserState {
    NOT_VERIFIED {
        override fun loginAllowed() = true
    },
    VERIFIED {
        override fun loginAllowed() = true
    },
    BANNED,
    DELETED;

    open fun loginAllowed(): Boolean {
        return false;
    }
}

And now, like this, the service just needs to do this:

class UserService {
    fun canLogin(email: String, password: String): Boolean {
        val user = findUser(email) ?: return false
        return user.hasMatchingPassword(password)
                       && user.state.loginAllowed()
    }
}

The same is also true for fields.

enum class UserState {
    NOT_VERIFIED {
        override val loginAllowed = true
    },
    VERIFIED {
        override val loginAllowed = true
    },
    BANNED,
    DELETED;

    open val loginAllowed: Boolean = false
}

Functions have one advantage though. And this is that you may pass parameters. Let us pick up again, that we add the state TEMPORARILY_BANNED.

enum class UserState {
    NOT_VERIFIED {
        override fun loginAllowed(bannedUntil: LocalDateTime?) = true
    },
    VERIFIED {
        override fun loginAllowed(bannedUntil: LocalDateTime?) = true
    },
    TEMPORARILY_BANNED {
        override fun loginAllowed(bannedUntil: LocalDateTime?): Boolean {
            return bannedUntil?.isBefore(LocalDateTime.now()) ?: false
        }
    },
    BANNED,
    DELETED;

    // LocalDateTime, because we care only about the server time
    open fun loginAllowed(bannedUntil: LocalDateTime?): Boolean {
        return false;
    }
}

And now, this sparks a new debate. Do we even need this status? We could modify the BANNED field and return the same as we currently do in TEMPORARILY_BANNED. This would have the same business value as before. Since we have this centralized logic, we can easily, still maintainable and testable add this case and reduce the logic to this:

enum class UserState {
    NOT_VERIFIED {
        override fun loginAllowed(bannedUntil: LocalDateTime?) = true
    },
    VERIFIED {
        override fun loginAllowed(bannedUntil: LocalDateTime?) = true
    },
    BANNED {
        override fun loginAllowed(bannedUntil: LocalDateTime?): Boolean {
            return bannedUntil?.isBefore(LocalDateTime.now()) ?: false
        }
    },
    DELETED;

    open fun loginAllowed(bannedUntil: LocalDateTime?): Boolean {
        return false;
    }
}

And we can also go further. We can directly pass the user in here, or give the enum functions, that apply the state to the user, modifying. Or we can write fancy stuff like this:

enum class UserState {
    NOT_VERIFIED {
        override fun loginAllowed(bannedUntil: LocalDateTime?) = true
    },
    VERIFIED {
        override fun loginAllowed(bannedUntil: LocalDateTime?) = true
    },
    BANNED {
        override fun loginAllowed(bannedUntil: LocalDateTime?): Boolean {
            return bannedUntil?.isBefore(LocalDateTime.now()) ?: false
        }
    },
    DELETED;

    open fun loginAllowed(bannedUntil: LocalDateTime?): Boolean {
        return false;
    }

    open fun ifLoginAllowed(user: User, supplier: () -> Boolean): Boolean {
        if(loginAlllowed(user.bannedUntil)) {
            supplier()
            return true
        }

        return false
    }
}

This is a bit more complicated but we can now write this:

class UserService {
    fun canLogin(email: String, password: String): Boolean {
        val user = findUser(email) ?: return false

        return user.state.ifLoginAllowed(user) {
                user.hasMatchingPassword(password)
        }
    }
}

Now we have the logic, whether or not something is allowed, central in the place where it is defined. If you add a new state to the enum, you directly see what you can do, where you might have conflicts, blah blah blah. The state defines what is right, what is wrong and maybe even how operations are performed in a certain state.

Using this enum is clear and precise. It is easy. Maintaining it requires us to understand the state itself. If we would not have the logic centralized we still would have to understand what happens when and which impact what state has, but we would never see everything at once. We would never directly see, what impact changes have, what additions we can take and so one. Sure, we can grep it, utilize IDE features to find everything in regards to the enum and so on. But this means, that finding everything is prone to human error and this is a very big factor in bugs.

Can you give a real example

Yes and no. I obviously have to obfuscate a lot, but an abstract explanation can be this:

We have some sort of request. This request may be either TO_DO, STARTED, DONE and CLOSED. Certain actions may only be performed when the request is TO_DO, others may not be done, once CLOSED and so on. The change of a status may also change statuses of other requests.

It was implemented like our previous example. Like this:

class ExampleService {
    fun someOperation(entity: Entity) {
        if(entity.state != State.CLOSED && entity.state != State.DONE) {
             entity.adjust()
        }
    }
}

This logic was shattered across many different classes, being nearly the same.

This should have been easily reproduced in a test, so that we can verify everything is working as expected. The issue was that this test did that to the word, it did not use shared logic at all, neither did any of the other methods that used a similar, if not the same logic.

A refactoring made multiple tests fail and two people where occupied for one and a half days to understand and fix it. It required a lot of mental power to jump through a lot of classes and see the different workflows that required the logic. 3 man days, just for that.

It could have been so easy, if everything was build in accordance to the single responsibility principle, but it was not. The interpretation of SRP was taken differently, it was taken to the extreme and understood that every service was responsible to interpret the states themself. Each service had the responsibility to understand the states it was catered to. But those services were not services meant to only operate on the status, but on their aggregating entities. So, generally the SR principle was broken. A service had multiple responsibilities at the same time. It also violated DRY, because the same logic was shared across multiple services.

So, i hope you can learn from my mistake.

Conclusion

We have looked at how we can centralize logic, that is catered to a finite set of states in an enum class within languages that allow for such. It is more to write, but easier to read and use.

If you are using enums and have the power to give it fields, to give it functions, do it. Or at the very least try it once. It is a little bit more writing stuff, but it will help you in the long run to write highly maintainable applications.

2

Ein Gedanke zu “Give the enum back its class!”

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.