🐟 Blog

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 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) {

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()

© Attribution Required | 转载请注明原作者与本站链接
© 2021 yujinyan.me