Boas práticas para a implementação de APIs no Spring Boot com Kotlin

Post-it anexado na parede

No artigo onde criamos o CRUD básico para a App Ceep, tiveram alguns detalhes que deixamos pra trás, como por exemplo, o acesso direto que o Controller tem ao Repository, as respostas dos endpoints entre outros detalhes que serão discutidos e resolvidos neste artigo 😄


Quer aprender mais sobre Kotlin tanto no mundo mobile como no back-end? Então confira este agregador de conteúdo onde listo todos os conteúdos que escrevi de Kotlin e os que serão publicados mais pra frente 😉



Preparando o ambiente

Durante a produção deste artigo, fiz algumas atualizações na configuração de build do projeto. Agora está com a versão 2.0.1 do Spring Boot e a 1.2.40 do Kotlin.

Nessa nova versão do Spring Boot, alguns métodos foram atualizados em relação ao Spring Data, como é caso da função exists(), que agora é existsById().

Sendo assim, recomendo que baixe novamente o projeto para mantermos o mesmo ambiente. Fique à vontade em consultar as mudanças que foram feitas em código e também na configuração de build.

Com tudo atualizado, podemos continuar com a implementação, bora lá?

Rapaz animado na frente do notebook

Injeção de dependência

Atualmente, o nosso Controller depende de uma instância da interface NoteRepository para realizar as operações de CRUD com o banco de dados, então criamos uma property utilizando o lateinit do Kotlin:

class NoteController {

@Autowired
lateinit var noteRepository: NoteRepository
    //others methods
}

A princípio, é difícil de enchegar o problema desta implementação, mas, perceba que temos os seguintes pontos de atenção:

  • Qualquer membro do projeto têm acesso;
  • Para cada dependência, precisaremos lembrar de anotar com o @Autowired;
  • Cada dependência faz parte de uma property var, que por sua vez, pode mudar de valor a qualquer momento.

Recebendo as dependências via construtor

Pensando justamente nesse tipo de detalhe, uma solução que podemos aplicar, é fazendo com que a dependência seja atribuída via construtor:

private val noteRepository: NoteRepository

@Autowired
constructor(noteRepository: NoteRepository){
this.noteRepository = noteRepository
}

A solução para uma property pública é facilmente resolvida com o private (também poderia ser feita com o lateinit 😅), entretanto, garantimos a imutabilidade com o val.

Flexibilidade para adicionar mais dependências

Além desta vantagem, receber as dependências via construtor nos permite declarar mais de uma dependência com uma única anotação.

Considerando um exemplo prático, suponhamos que o nosso Controller realizasse algum tipo de autenticação, dessa forma, seria necessário verificar a informação de usuário que foi armazenada.

Como já vimos, teríamos que ter acesso ao Repository de usuário, logo, poderíamos adicioná-lo da seguinte maneira:

private val noteRepository: NoteRepository
private val userRepository: UserRepository

@Autowired
constructor(noteRepository: NoteRepository,
userRepository: UserRepository){
this.noteRepository = noteRepository
this.userRepository = userRepository
}

Deixando o código mais enxuto

Uma outra característica bacana, é que podemos até mesmo usar o construtor primário para declarar as dependências:

class NoteController @Autowired constructor (
private val noteRepository: NoteRepository)

O nosso código fica mais enxuto, mas perceba que foi necessário escrever a keyword constructor! Será que não tem uma maneira mais enxuta?

A partir da versão 4.3 do Spring, se tivermos apenas um construtor, não há a necessidade do @Autowired para injetar dependências:

class NoteController(
private val noteRepository: NoteRepository)

Dessa forma chegamos ao ponto no qual estamos acostumados a declarar as nossas classes no Kotlin 😉

Isolando a regra de negócio com a camada Service

Além de receber requisições e devolver uma resposta, o nosso Controller mantém contato direto com o Repository de notas…

“Tudo está funcionando, qual o problema nesta situação?”

Pode parecer estranho, mas essa abordagem não é recomendada! O principal motivo é que a camada Controller tem a finalidade de apenas orientar a requisição para a lógica de negócio e devolver a resposta.

“Então o que fazemos para esses casos?”

Quando temos esse tipo de situação, criamos uma camada que fica responsável em manter a regra de negócio da aplicação, como é caso de acessar o banco de dados entre outras operações que vão além de receber e responder requisições. Essa camada é conhecida como Service.

Sendo assim, vamos criar a classe NoteService que vai representar o Service para notas:

package br.com.alexf.ceepws.service

class NoteService {
}

Considerando a ideia do Service, precisamos implementar a parte lógica da aplicação, que por enquanto, é realizar a operação de CRUD. Isso significa que precisamos ter acesso ao Repository de notas, logo, vamos recebê-lo via construtor:

class NoteService(
private val noteRepository: NoteRepository) {
}

Então vamos implementar todos os comportamentos de CRUD que realizamos no Controller:

package br.com.alexf.ceepws.service

import br.com.alexf.ceepws.model.Note
import br.com.alexf.ceepws.repository.NoteRepository

class NoteService(
private val noteRepository: NoteRepository) {

fun all(): List<Note> {
return noteRepository.findAll().toList()
}

fun deleteById(id: Long) {
noteRepository.deleteById(id)
}

fun existsById(id: Long): Boolean {
return noteRepository.existsById(id)
}

fun save(note: Note): Note {
return noteRepository.save(note)
}

fun alter(id: Long, note: Note): Note {
var safeNote = note.copy(id = id)
return save(safeNote)
}

}
É válido notar que a função alter() surgiu agora no Service e encapsula um comportamento que estava dentro de uma função do Controller, ou seja, toda a parte lógica que vai além de receber e responder requisições faz todo o sentido ser implementada pelo Service.

Recebendo o Service como dependência

Agora que o Service foi implementado, ao invés de manter um Repository como dependência do Controller, vamos manter o nosso Service:

class NoteController(
private val noteService: NoteService)

Dependendo da IDE ou editor que esteja utilizando, você pode estar lidando com essa mensagem:

Problema de compilação porque o service não é reconhecido como um bean do Spring

Perceba que o alerta é bem claro, ele indica que o nosso Service não é um Bean que o Spring controla. Para transformar uma classe em um Bean, a maneira mais simples é anotando com @Component.

Porém, já que a nossa classe trata-se de um Service, a própria documentação do Spring sugere o uso da annotation @Service que é uma especialização de @Component para identificar um Service:

@Service
class NoteService(
private val noteRepository: NoteRepository)
A utilidade dessa identificação está relacionada ao processo de scanning (varredura), isso significa que o Spring identifica o Service e realiza ações específicas, caso for necessário/configurado.

Em seguida, precisamos apenas modificar as referências do Repository para o Service chamando as suas funções:

package br.com.alexf.ceepws.controller

import br.com.alexf.ceepws.model.Note
import br.com.alexf.ceepws.service.NoteService
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("notes")
class NoteController(
private val noteService: NoteService) {

@GetMapping
fun list(): List<Note> {
return noteService.all().toList()
}

@PostMapping
fun add(@RequestBody note: Note): Note {
return noteService.save(note)
}

@PutMapping("{id}")
fun alter(@PathVariable id: Long, @RequestBody note: Note): Note {
if (noteService.existsById(id)) {
return noteService.alter(id, note)
}
return Note()
}

@DeleteMapping("{id}")
fun delete(@PathVariable id: Long) {
if (noteService.existsById(id)) {
noteService.deleteById(id)
}
}

}

Veja que todo o comportamento, além de direcionar a requisição e respondê-la, está direcionado ao Service!

Considerando o nosso exemplo, podemos chegar a conclusão que o Service é um Wrapper para Repositories, mas, isso não é verdade! Como eu havia mencionado, ele vai além disso!
Isso significa que, se surgir um novo comportamento para a entidade nota, que vai além de direcionar respostas, o Service que ficará responsável em implementar e manter isso encapsulado para o Controller.

Respondendo as requisições de maneira esperada

Deixamos o nosso Controller bem mais elegante e coeso, porém ainda existe um detalhe que está relacionado na maneira como nos comunicamos com os nossos clientes.

A princípio não existe nenhum problema, afinal, sempre devolvemos uma resposta de sucesso a partir do status code 200… Mas agora vem a pergunta:

“Em todos os endpoints da nossa API, as requisições sempre dão certo?”

Sendo mais prático e próximo ao nosso exemplo, quando tentamos alterar uma nota de id inexistente, o que acontece? Temos um status code 200 com um body vazio como resposta… Agora te pergunto:

Essa requisição realmente deu certo?

Com certeza não! Afinal, a operação desejada não aconteceu! Isso significa que para desenvolvermos uma API com qualidade, a resposta precisa estar de acordo com o que aconteceu. Portanto, vamos verificar como podemos implementar tal comportamento no Spring.

Utilizando o ResponseEntity

Em situações que precisamos ter mais controle sobre a resposta HTTP em um endpoint, o próprio Spring nos oferece a classe ResponseEntity que nos permite manipular os dados HTTP da resposta.

Modificando a função de listar

Para um exemplo mais simples, vamos modificar a função que devolve a lista de notas:

@GetMapping
fun list(): ResponseEntity<List<Note>> {
val allNotes = noteService.all().toList()
return ResponseEntity.ok(allNotes)
}

Repare que a primeira modificação foi diretamente no retorno da função que agora é ResponseEntity<List<Note>>, em outras palavras, estamos retornando uma resposta que vai conter uma lista de notas no corpo da requisição.

Então, no retorno, chamamos, de maneira estática, a função ok() enviando todas as notas do servidor. Isso significa que estamos devolvendo o status code 200 como resposta.

“Ué, mas qual é a diferença de responder com o ResponseEntity ou a lista de notas diretamente?”

Na prática não há diferença alguma seguindo esse exemplo. Mas, dessa maneira, seguimos um padrão que dá muito mais significado para o nosso código, como também, flexibilidade para personalizar a resposta HTTP, como por exemplo, devolver headers ou outros dados que fazem parte do protocolo HTTP.

Modificando a função de salvar

Agora que sabemos como implementar o ResponseEntity e seus benefícios, vamos para a próxima função, a que salva as notas:

@PostMapping
fun add(@RequestBody note: Note): ResponseEntity<Note> {
val savedNote = noteService.save(note)
return ResponseEntity.ok(savedNote)
}

Novamente, temos uma implementação que não é muito diferente da maneira como fizemos anteriormente, pois para salvar uma nota não existe nenhum tipo de restrição.

Modificando a função de alterar

Continuando com a modificação, a próxima função é a de alterar:

@PutMapping("{id}")
fun alter(@PathVariable id: Long, @RequestBody note: Note): ResponseEntity<Note> {
if (noteService.existsById(id)) {
val alteredNote = noteService.alter(id, note)
return ResponseEntity.ok(alteredNote)
}
return ResponseEntity.ok(Note())
}

Não temos muitas mudanças se compararmos com o que fizemos na função de listar e salvar. Porém, como comentei com vocês, não faz muito sentido a resposta ser 200 quando a nota não é alterada!

Isso significa que ao invés de devolver um ok() com uma nota como argumento, precisamos retornar uma outra informação que indique que a operação desejada pelo cliente (alteração de nota) não foi realizada.

Para esses casos, quando uma nota não existe, podemos devolver o famoso 404 Not Found a partir da função notFound():

return ResponseEntity.notFound()

Se retornarmos apenas com essa chamada temos alguns problemas de compilação! O primeiro deles é que a função notFound(), diferentemente da ok(), retorna um ResponseEntity.HeadersBuilder que permite configurar headers para a resposta.

Porém, como desejamos apenas devolver um ResponseEntity, fazemos uma chamada da função build() que devolve um ResponseEntity com toda a configuração que foi feita a partir da chamada do notFound():

@PutMapping("{id}")
fun alter(@PathVariable id: Long, @RequestBody note: Note): ResponseEntity<Note> {
if (noteService.existsById(id)) {
val alteredNote = noteService.alter(id, note)
return ResponseEntity.ok(alteredNote)
}
return ResponseEntity.notFound().build()
}

Dessa forma, quando um cliente realizar uma requisição do tipo PUT para um id que não existe, ele vai receber a resposta correta de acordo com o protocolo HTTP. Inclusive, podemos testar tentando alterar a nota de id 100:

Retornando 404 para requisições PUT quando o id não existe

Perceba que não tivemos nenhuma resposta no corpo da requisição, porém, agora temos um status code 404 que indica que o recurso não foi encontrado.

Modificando a função de remoção

Por fim, precisamos apenas modificar a função para remoção. Basicamente, podemos ter o mesmo comportamento que fizemos na de alteração, ou seja, passar o feedback para o cliente quando acontecer uma tentativa de remoção para uma nota que não existe:

@DeleteMapping("{id}")
fun delete(@PathVariable id: Long): ResponseEntity<Unit> {
if (noteService.existsById(id)) {
noteService.deleteById(id)
return ResponseEntity.ok().build()
}
return ResponseEntity.notFound().build()
}

A única diferença é que agora devolvemos um ResponseEntity<Unit>, pois para esse tipo de comportamento não há necessidade de devolver algum tipo de conteúdo para o corpo da requisição.

Pronto! Agora a nossa API está atendendo os padrões comuns em implementações com o Spring Framework 😃

Para saber mais

Para cada retorno de status code do HTTP, utilizamos uma função do ResponseEntity, porém, não existe uma função para todos os possíveis status code. Quando não tiver uma função, como é o caso do 403 Forbidden, você pode implementar da seguinte maneira:

ResponseEntity.status(HttpStatus.FORBIDDEN).body("not authorized")

A partir das constantes do enum HttpStatus podemos devolver o status code desejado! Inclusive, neste mesmo exemplo, perceba que temos a capacidade de enviar dados para o body também.

Código fonte

Caso surgiu alguma dúvida ou se você tiver interesse em consultar o código fonte, fique à vontade em dar uma olhada no repositório que está no GitHub

Conclusão

Neste artigo, aprendemos como podemos refatorar o nosso Controller para que ele fique mais sucinto e coeso.

De uma maneira mais resumida, aprendemos as receber dependências de uma maneira mais segura, isto é, privada e que não muda o valor, a delegar a responsabilidade que não é do Controller para a camada de Service e a responder as requisições HTTP de maneira adequada.

Aproveitando que chegou até aqui, me conte sobre o que achou das técnicas que foram apresentadas 😄