1. Refatorando nossos Controllers com ResponseEntity
Até aqui, utilizamos retornos diretos como ContactResponseDTO ou Page<ContactResponseDTO> nos métodos de nossos controllers. Embora essa abordagem funcione bem, ela não oferece controle detalhado sobre o que está sendo retornado na resposta HTTP, como cabeçalhos, status específicos ou metadados adicionais - para isso precisamos usar anotações como @ResponseStatus(HttpStatus.OK) e outros artifícios de implementação.
O uso da classe ResponseEntity<T> resolve essa limitação, fornecendo uma interface simplificada para uso. Essa classe encapsula:
- o corpo da resposta (um DTO, por exemplo),
- o status HTTP (200 OK, 201 Created, 204 No Content, 404 Not Found, etc),
- e opcionalmente cabeçalhos adicionais.
1.1 Por que usar ResponseEntity?
Adotar o ResponseEntity proporciona mais clareza, flexibilidade e aderência aos padrões HTTP, além de preparar a aplicação para requisitos mais avançados, como:
- inclusão de cabeçalhos de autenticação ou cache,
- retorno de localizações (
Location) após criação de recursos, - respostas com conteúdos customizados ou sem corpo (
204 No Content), - e suporte facilitado a testes e a respostas específicas em diferentes cenários.
Além disso, ele torna explícito para quem lê o código qual é o status retornado pela requisição, o que melhora a manutenção e a legibilidade da aplicação, sendo o uso de ResponseEntity a implementação preferida pela comunidade Spring.
✨ Vantagens do uso de ResponseEntity
| Recurso | Benefício |
|---|---|
| Controle explícito do status | Podemos retornar 200, 201, 204, 400, 404, 500, etc. |
| Headers customizados | Podemos adicionar cabeçalhos HTTP (como Location) |
| Corpo da resposta opcional | Podemos retornar apenas status, sem corpo (noContent()) |
| Adequação a testes e versionamento | Facilita asserções em testes e torna o contrato mais claro |
1.2 Sintaxe básica de ResponseEntity
O ResponseEntity possui uma forma mais verbosa de uso, como mostrado abaixo
return ResponseEntity
.status(HttpStatus.CREATED)
.body(contactResponseDTO);
E uma forma mais enxuta com métodos auxiliares:
return ResponseEntity.ok(dto); // 200 OK com corpo
return ResponseEntity.noContent().build(); // 204 No Content
1.3 Refatorando um exemplo de endpoint
Antes da refatoração:
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteContact(@PathVariable Long id) {
contactRepository.deleteById(id);
}
Após refatoração:
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteContact(@PathVariable Long id) {
if (!contactRepository.existsById(id)) {
throw new ResourceNotFoundException("Contato não encontrado: " + id);
}
contactRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
Observe que com ResponseEntity ganhamos clareza semântica. Além disso, adicionamos a cláusula de verificação para checar se o id passado como argumento existe em nossa base de dados, já que por algum motivo desconhecido havia me esquecido de implementar isso anteriormente. 🫠
1.4 Padrão a ser adotado nos métodos
A seguir estão os padrões que iremos aplicar na refatoração dos métodos da controller:
| Tipo de operação | Código HTTP | Exemplo Spring |
|---|---|---|
| Buscar recurso | 200 OK | ResponseEntity.ok(resource) |
| Criar recurso | 201 Created | ResponseEntity.status(CREATED).body(resource) |
| Atualizar recurso | 200 OK | ResponseEntity.ok(resource) |
| Atualizar parcial | 200 OK | ResponseEntity.ok(resource) |
| Deletar recurso | 204 NoContent | ResponseEntity.noContent().build() |
| Recurso não encontrado | 404 Not Found | Lançar ResourceNotFoundException para ser tratada globalmente |
1.5 Refatorando os métodos do Controller
Além da refatoração do endpoint de deleção de um contato, vejamos a refatoração de mais alguns dos métodos do ContactController e AddressController, substituindo os retornos diretos pelos retornos com ResponseEntity. O objetivo é tornar os endpoints mais claros e preparados para evoluções, como headers, cache, redirecionamentos ou alterações no corpo da resposta.
Para manter a aula leve e compreensível, mostraremos alguns métodos como exemplo a seguir. A versão final de todos os métodos será apresentada ao final da seção, como fizemos na Aula 05.
🧱 Exemplo 1: createContact()
Na aula anterior nosso método estava da seguinte forma:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ContactResponseDTO createContact(@Valid @RequestBody ContactRequestDTO dto) {
// Mapeia os campos simples
Contact contact = new Contact(dto.getNome(), dto.getEmail(), dto.getTelefone());
// Mapeia os endereços manualmente
var addresses = dto.getAddresses().stream()
.map(addrDto -> {
Address address = new Address();
address.setRua(addrDto.getRua());
address.setCidade(addrDto.getCidade());
address.setEstado(addrDto.getEstado());
address.setCep(addrDto.getCep());
address.setContact(contact);
return address;
}).toList();
contact.setAddresses(addresses);
Contact saved = contactRepository.save(contact);
return modelMapper.map(saved, ContactResponseDTO.class);
}
Agora, vamos refatorar nosso método e deixá-lo como mostrado a seguir:
@PostMapping
public ResponseEntity<ContactResponseDTO> createContact(@Valid @RequestBody ContactRequestDTO dto) {
Contact contact = modelMapper.map(dto, Contact.class);
contact.getAddresses().forEach(address -> address.setContact(contact));
Contact saved = contactRepository.save(contact);
ContactResponseDTO responseDTO = modelMapper.map(saved, ContactResponseDTO.class);
return ResponseEntity.status(HttpStatus.CREATED).body(responseDTO);
}
No primeiro método a criação de um novo contato é realizada de maneira bastante manual. Inicialmente, os campos do objeto Contact são populados diretamente a partir dos valores do DTO, utilizando um construtor explícito. Em seguida, os endereços são mapeados um a um através de um stream, onde cada AddressRequestDTO é convertido manualmente em uma instância da entidade Address. Durante esse processo, também é feita a associação entre cada endereço e o contato recém-criado, garantindo o vínculo bidirecional necessário para persistência correta com JPA. Após o mapeamento, a lista de endereços é atribuída ao contato e, por fim, o contato é salvo no banco e convertido em um ContactResponseDTO utilizando o ModelMapper.
Essa abordagem, embora funcional, gera um acúmulo de responsabilidades no controller e uma repetição considerável de código, o que pode dificultar a manutenção à medida que a aplicação cresce.
Já o segundo método apresenta uma versão mais enxuta e elegante da mesma funcionalidade. Nessa versão refatorada, o ModelMapper é utilizado diretamente para mapear o ContactRequestDTO para a entidade Contact, eliminando a necessidade de instanciar o objeto manualmente e escrever código repetitivo para setar os atributos. O único passo que permanece manual é a associação entre os endereços e o contato — e isso é feito de forma simples e clara, com um forEach. Após o salvamento da entidade no banco, o resultado é novamente convertido para o DTO de resposta com o ModelMapper.
Outro ponto de melhoria é o uso do ResponseEntity para retornar a resposta da API. Com isso, temos controle explícito sobre o status HTTP (201 Created), o que torna a resposta mais alinhada às práticas RESTful e facilita o envio de cabeçalhos adicionais, se necessário.
Essa refatoração traz diversos benefícios: o código fica mais limpo, a responsabilidade de conversão entre DTOs e entidades é centralizada no ModelMapper, e o controller passa a ser responsável apenas por orquestrar as chamadas — o que é exatamente seu papel. Além disso, a nova versão favorece a legibilidade, testabilidade e manutenção do código, características essenciais em projetos profissionais e de médio a longo prazo.
Uma melhoria posterior seria a implementação de uma camada de serviço, por meio da extração da lógica e simplificação ainda maior do nosso Controller. Abordaremos esse padrão organizacional posteriormente na aula.
🧱 Exemplo 2: getAllContacts()
Na aula anterior nosso método estava da seguinte forma:
@GetMapping
public Page<ContactResponseDTO> getAllContacts(Pageable pageable) {
Page<Contact> contacts = contactRepository.findAll(pageable);
return contacts.map(contact -> modelMapper.map(contact, ContactResponseDTO.class));
}
Agora, vamos refatorar nosso método e deixá-lo como mostrado a seguir:
@GetMapping
public ResponseEntity<Page<ContactResponseDTO>> getAllContacts(Pageable pageable) {
Page<Contact> contacts = contactRepository.findAll(pageable);
Page<ContactResponseDTO> responseDTO = contacts
.map(contact -> modelMapper.map(contact, ContactResponseDTO.class));
return ResponseEntity.ok(responseDTO);
}
Como percebem, seguimos a mesma linha de refatoração que fizemos anteriormente, ou seja, a diferença entre os dois métodos está na forma como a resposta é construída e retornada para o cliente. Ambos realizam a mesma tarefa essencial: recuperar todos os contatos de forma paginada e convertê-los para um Page
Na primeira versão a resposta da requisição é retornada de maneira direta. O Spring Boot, por padrão, interpreta o tipo de retorno e aplica um status HTTP 200 (OK) automaticamente. Esse estilo é válido e perfeitamente funcional, principalmente em projetos menores ou em endpoints que não exigem personalizações adicionais no cabeçalho da resposta ou no status. No entanto, essa abordagem oferece menos controle sobre o que está sendo retornado, já que não há flexibilidade para modificar status HTTP, cabeçalhos ou outras configurações da resposta de forma explícita.
Já a versão refatorada segue uma prática mais robusta e alinhada às boas práticas em APIs RESTful: utiliza o ResponseEntity, para representar toda a resposta HTTP, incluindo corpo, status e cabeçalhos. Ao encapsular a resposta em um ResponseEntity.ok(…), o método deixa claro e explícito que está retornando uma resposta com status HTTP 200 (OK), além de permitir, se necessário, o uso de outros métodos como .status(), .headers(), ou até mesmo .noContent() para outros cenários.
🤠 Resumo
Refatorar os métodos do controller para retornar ResponseEntity é um pequeno ajuste com impacto positivo na legibilidade, testabilidade e padronização da API. Essa abordagem permite que, futuramente, adicionemos headers, links, status alternativos ou tratamentos especiais sem precisar alterar a assinatura do método.
Além disso, a documentação gerada pelo Swagger/OpenAPI também pode ser enriquecida com status HTTP mais precisos e podemos alterar a injeção de dependência que vinhamos usando com @Autowired para injeção via construtor!fatorações e rever o nosso código completo do controller.