Boas práticas para a implementação de APIs no Spring Boot com Kotlin
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á?
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 enxegar 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:
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:
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 😄