José Iêdo

I changed my vision about code design

April 2, 2025
4 min read
Table of Contents

After almost 2 years at the same project I learned a lot about the impact of my old code. Facing the consequences of my decisions were and still is frustrating. I had some feelings:

  • I should’ve followed some Design Pattern since the beginning and made a modular code!
  • I should’ve followed a Modular Architecture! But when I applied something like that, the code was complex and looked like a pile of overengineering, so I started to look a lot to open source projects and follow some different programming teachers.

Here is some strange things that I learned:

Using Repositories in a Controller

_What the hell is that? Repositories must be in a Service! Controllers should handle just http things!

This was my reaction when I saw Rafael Ponte posting this on LinkedIn, then I just scrolled and forgot about it. After almost a year, I watched some lessons from Alberto Souza, where he put emphazis on not being attached to “book practices” and analyze, experiment and have your own conclusions.

Now, C’mon! Look at this code:

class UserService constructor(
    private val userRepository: UserRepository,
    private val userMapper: UserMapper
) {
 
    fun updateUser(id: Long, userDTO: UserDTO): UserDTO? {
		val existingUser = userRepository.findById(id) ?: throw NotFoundException() // Global ExceptionHandler would handle this
        val updatedUser = userRepository.save(userMapper.toEntity(userDTO))
	    return UserMapper.toDTO(updatedUser)
    }
}
 
class UserController constructor(
    private val userService: UserService
) {
 
    @PutMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    fun updateUser(@PathVariable id: Long, @RequestBody userDTO: UserDTO): ResponseEntity<UserDTO> {
        return userService.updateUser(id, userDTO)
    }
}

What the controller or service is doing here that is complex enough to force myself to create another layer of abstraction? Am I getting any advantage? Ok..single responsability principle, controllers must deal only with the http codes etc. But honestly, it is that worth to abstract to a service? Let’s ignore the “rules” of Uncle bob and just have our own conclusions

Here is the same code without using the service class:

class UserController constructor(
    private val userRepository: UserRepository,
) {
 
    @PutMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    fun updateUser(@PathVariable id: Long, @RequestBody userDTO: UserDTO): ResponseEntity<UserDTO> {
        val existingUser = userRepository.findById(id) ?: throw NotFoundException() // Global ExceptionHandler would handle this
        val updatedUser = userRepository.save(userMapper.toEntity(userDTO))
	    return UserMapper.toDTO(updatedUser)
    }
}

I don’t think this code is that complex. And if things become complex enough, it’s really easy to refactor to a new class. With intellij is just a matter of remembering the shortcuts.

Adding business rules in the model

But the rules should be in the services…?

This one is about cohesion, and is a very famous pattern called Rich Domains. It’s also a matter about following the OOP principles. Let’s look at this code:

@Service
class UserService @Autowired constructor(
    private val userRepository: UserRepository
) {
    fun updateUser(id: Long, userDTO: UserDTO): UserDTO? {
        val existingUser = userRepository.findById(id).orElse(null) ?: return null
 
        // Business logic is handled in the service
        existingUser.name = userDTO.name
        existingUser.email = userDTO.email
        existingUser.age = userDTO.age
 
        val updatedUser = userRepository.save(existingUser)
        return UserMapper.toDTO(updatedUser)
    }
}

We can refactor our code and make the service only an “orchestrator”

 
@Entity
data class User(
    @Id
    val id: Long,
    var name: String,
    var email: String,
    var age: Int
) {
    fun updateDetails(name: String, email: String, age: Int) {
        // Business logic encapsulated within the domain entity
        this.name = name
        this.email = email
        this.age = age
    }
}
 
class UserService @Autowired constructor(
    private val userRepository: UserRepository
) {
    fun updateUser(id: Long, userDTO: UserDTO): UserDTO? {
        val existingUser = userRepository.findById(id).orElse(null) ?: return null
existingUser.updateDetails()
        return UserMapper.toDTO(updatedUser)
    }
}

Conclusion

When I started studying design patterns, I had a bad time understanding when should I use some pattern, the explanations were so vague and the code was complex. (This was worse when I studied a bit about clean architecture, but I’m going to write another post about that later)

At the end, there is no perfect code. The clean code thing is a lie and all we can do is try to have a gradual evolution in our code. I’m still learning how to do that, and I hope I can do it soon. Let’s see how my opinion will change in the next years.