God objects, Kotlin to the rescue
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 Laravel’s elegant notification API, we put a trait on the 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)
However, this design leaves something to be desired. When consuming this API, we could get confused about the order of these two parameters. Which user should come first? It’s reasonable to assume that the subject comes first, but to be sure, we’ll have to check the documentation on the method. Such inconvenience disrupts our coding flow.
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
block, our IDE will complain that the method cannot be found.
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
example shows, this can sometimes lead to more fluent and ergonomic API designs.
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()