God objects, Kotlin to the rescue
March 07, 2020
In our Laravel backend project, there is a User class, which is among one of the first classes created. Over the years, it has accumulated all sorts of business-related methods, degenerating into thousands of lines of unmanageable mess. This is a typical example of an all-knowing god object.
Defining methods directly on the User class seems natural in many cases. For example, to enable User model and call it like this:
$user->notify(new OrderShipped($order));This style is fluent and intuitive, and many developers on the project have followed suit and put many business logic in the User model. Unfortunately, it does not scale very well, as almost every piece of business logic effectively interacts with the User.
So in larger projects, we may want to do a cleaner architecture. Suppose we want to track the following status of a user in a FollowService, we may come up with something like this:
class FollowService(
private followRepo: FollowRepository
) {
fun isFollowedBy(user1: User, user2: User): Boolean {
// Call method on `followRepo` ...
}
}Now we can check if user2 has followed user1 by calling
followSevice.isFollowedBy(user1, user2)As it turns out, Kotlin’s extension methods can help us solve this issue elegantly. In Kotlin, we can define a method as an extension on another existing type, even in the context of a class. So we can rewrite the method definition to:
class FollowService(
private followRepo: FollowRepository
) {
fun User.isFollowedBy(other: User): Boolean {
// `user1` available as implicit `this`
}
}And we invoke the method in this way:
val star: User = userRepo.findOneStar()
val fan: User = userRepo.findOneFan()
with(followService) {
assertThat(star.isFollowedby(fan)).isTrue()
}To call the method, we first grab an instance of FollowService, probably via dependency injection. Then we use Kotlin’s standard library function with or apply to open a block. Inside the block, there is an implicit this, referring to the FollowService instance. But since the isFollowedBy method is defined as an extension on the User type, we can only call it on a User instance. If we move the isFolloweBy method call out of the with
In this way, our business logic lives in service classes. These classes usually involve some side effects like expensive network requests. We don’t want database calls to hide inside some innocent-looking getter methods on our models. So when we call services, we want to do it explicitly. Using this approach, in order to access a service’s functionality, we must first demarcate a scope in which methods of the service are defined. Inside the scope, our business entities are empowered by the service to perform specific business logic. We have the choice to make the methods appear as if they were defined on the entities. As our FollowService
Note that it’s also possible to achieve a bit of extra fluency by using the infix keyword. And our method call becomes:
assertThat(star isFollowedBy fan).isTrue()