Spring Boot para iniciantes: criando sua primeira API REST em Java
Para criar sua primeira API REST com Spring Boot, você precisa de quatro peças: um projeto gerado no Spring Initializr, uma entidade JPA que representa a tabela, um repository para acessar o banco e um controller que recebe as requisições HTTP. Com Java 17 ou mais recente e Spring Boot 3.x, dá para ter um CRUD funcionando, com validação e tratamento de erros, em uma tarde de estudo.
Neste tutorial vamos montar uma API de cadastro de clientes, camada por camada, explicando o motivo de cada decisão. Se os termos GET, POST, status 201 ou JSON ainda são novos, leia antes o que é uma API REST.
1. Gerando o projeto no Spring Initializr
Acesse start.spring.io e configure:
- Project: Maven. Language: Java.
- Spring Boot: a versão 3.x estável mais recente (evite as marcadas como SNAPSHOT ou M).
- Group:
br.com.exemplo. Artifact:clientes-api. - Java: 17 ou 21.
- Dependencies: Spring Web, Spring Data JPA, Validation e H2 Database.
Clique em Generate, descompacte e abra a pasta no IntelliJ IDEA, VS Code (com o Extension Pack for Java) ou Eclipse. O H2 é um banco em memória, ótimo para aprender: não precisa instalar nada, e os dados somem quando a aplicação para. Depois você troca por PostgreSQL ou MySQL mudando a dependência e as propriedades de conexão.
Rode o projeto com ./mvnw spring-boot:run (no Windows, mvnw.cmd spring-boot:run). Se aparecer "Started ClientesApiApplication", está funcionando na porta 8080.
2. Estrutura do projeto
Organize o código em pacotes por responsabilidade. Para um projeto pequeno, esta divisão é suficiente:
src/main/java/br/com/exemplo/clientesapi/
ClientesApiApplication.java
cliente/
Cliente.java (entidade JPA)
ClienteRepository.java (acesso ao banco)
ClienteService.java (regras de negócio)
ClienteController.java (endpoints HTTP)
ClienteRequest.java (dados de entrada)
ClienteResponse.java (dados de saída)
erro/
RecursoNaoEncontradoException.java
TratadorDeErros.java
Por que separar controller e service? O controller cuida só de HTTP (receber, validar formato, devolver status). O service concentra as regras, o que facilita testes e reaproveitamento.
3. A entidade e o repository
package br.com.exemplo.clientesapi.cliente;
import jakarta.persistence.*;
@Entity
@Table(name = "clientes")
public class Cliente {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String nome;
@Column(nullable = false, unique = true, length = 150)
private String email;
protected Cliente() { } // exigido pelo JPA
public Cliente(String nome, String email) {
this.nome = nome;
this.email = email;
}
public void atualizar(String nome, String email) {
this.nome = nome;
this.email = email;
}
public Long getId() { return id; }
public String getNome() { return nome; }
public String getEmail() { return email; }
}
Repare no pacote jakarta.persistence: no Spring Boot 3 os antigos imports javax.persistence não funcionam mais. Esse é o erro mais comum de quem copia tutoriais antigos.
package br.com.exemplo.clientesapi.cliente;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClienteRepository extends JpaRepository<Cliente, Long> {
boolean existsByEmail(String email);
}
Só com essa interface você ganha findAll, findById, save, deleteById e outros. O método existsByEmail é gerado automaticamente a partir do nome.
4. DTOs com validação
Não exponha a entidade diretamente na API. Records do Java são perfeitos para isso:
package br.com.exemplo.clientesapi.cliente;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ClienteRequest(
@NotBlank @Size(max = 100) String nome,
@NotBlank @Email @Size(max = 150) String email) { }
package br.com.exemplo.clientesapi.cliente;
public record ClienteResponse(Long id, String nome, String email) {
static ClienteResponse de(Cliente c) {
return new ClienteResponse(c.getId(), c.getNome(), c.getEmail());
}
}
5. O service
package br.com.exemplo.clientesapi.cliente;
import br.com.exemplo.clientesapi.erro.RecursoNaoEncontradoException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ClienteService {
private final ClienteRepository repository;
public ClienteService(ClienteRepository repository) {
this.repository = repository; // injeção pelo construtor
}
public List<ClienteResponse> listar() {
return repository.findAll().stream().map(ClienteResponse::de).toList();
}
public ClienteResponse buscar(Long id) {
return ClienteResponse.de(encontrar(id));
}
@Transactional
public ClienteResponse criar(ClienteRequest req) {
if (repository.existsByEmail(req.email())) {
throw new IllegalArgumentException("E-mail já cadastrado");
}
return ClienteResponse.de(repository.save(new Cliente(req.nome(), req.email())));
}
@Transactional
public ClienteResponse atualizar(Long id, ClienteRequest req) {
Cliente c = encontrar(id);
c.atualizar(req.nome(), req.email()); // salvo ao fim da transação
return ClienteResponse.de(c);
}
@Transactional
public void remover(Long id) {
repository.delete(encontrar(id));
}
private Cliente encontrar(Long id) {
return repository.findById(id)
.orElseThrow(() -> new RecursoNaoEncontradoException("Cliente " + id + " não encontrado"));
}
}
6. O controller
package br.com.exemplo.clientesapi.cliente;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/clientes")
public class ClienteController {
private final ClienteService service;
public ClienteController(ClienteService service) {
this.service = service;
}
@GetMapping
public List<ClienteResponse> listar() {
return service.listar();
}
@GetMapping("/{id}")
public ClienteResponse buscar(@PathVariable Long id) {
return service.buscar(id);
}
@PostMapping
public ResponseEntity<ClienteResponse> criar(@RequestBody @Valid ClienteRequest req) {
ClienteResponse criado = service.criar(req);
return ResponseEntity.created(URI.create("/clientes/" + criado.id())).body(criado);
}
@PutMapping("/{id}")
public ClienteResponse atualizar(@PathVariable Long id, @RequestBody @Valid ClienteRequest req) {
return service.atualizar(id, req);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> remover(@PathVariable Long id) {
service.remover(id);
return ResponseEntity.noContent().build();
}
}
O @Valid faz o Spring checar as anotações do record antes de chamar o método. Se algo falhar, ele lança MethodArgumentNotValidException, que vamos tratar a seguir.
7. Tratamento de erros centralizado
package br.com.exemplo.clientesapi.erro;
public class RecursoNaoEncontradoException extends RuntimeException {
public RecursoNaoEncontradoException(String msg) { super(msg); }
}
package br.com.exemplo.clientesapi.erro;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class TratadorDeErros {
@ExceptionHandler(RecursoNaoEncontradoException.class)
public ProblemDetail naoEncontrado(RecursoNaoEncontradoException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail conflito(IllegalArgumentException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail invalido(MethodArgumentNotValidException e) {
Map<String, String> campos = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(f -> f.getField(),
f -> String.valueOf(f.getDefaultMessage()), (a, b) -> a));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Dados inválidos");
pd.setProperty("campos", campos);
return pd;
}
}
ProblemDetail existe desde o Spring Framework 6 e segue o padrão Problem Details para erros HTTP. Usar IllegalArgumentException para o e-mail duplicado é uma simplificação didática; em projetos maiores, crie exceções específicas para cada regra.
8. Testando
curl -i -X POST http://localhost:8080/clientes -H "Content-Type: application/json" -d "{\"nome\":\"Ana\",\"email\":\"ana@exemplo.com\"}"
curl -i http://localhost:8080/clientes
curl -i http://localhost:8080/clientes/99
Espere 201 com cabeçalho Location no primeiro, 200 com a lista no segundo e 404 com JSON de erro no terceiro. Envie um e-mail inválido e veja o 400 com o campo problemático.
Erros comuns e próximos passos
- Imports
javaxem vez dejakartano Boot 3. - Classes fora do pacote da classe principal: o Spring não as encontra.
- Usar
@Autowiredem campo: funciona, mas injeção pelo construtor facilita testes. - Anotações como
@Transactionalou@Cacheableignoradas ao chamar um método da mesma classe. Veja o caso real em @Cacheable não funciona por auto-invocação. - 403 em POST depois de adicionar Spring Security, por causa do CSRF. Veja este caso real.
Depois disso, versione o projeto com Git e GitHub, escreva testes com @WebMvcTest e @DataJpaTest, troque o H2 por PostgreSQL e empacote a aplicação com Docker.
Sites e sistemas sob medida
A PruPru desenvolve APIs e sistemas em Java com Spring Boot para pequenas empresas, da modelagem do banco à publicação.
Ver serviços →Perguntas frequentes
Preciso saber Java antes de aprender Spring Boot?
Sim, pelo menos o básico: classes, interfaces, coleções, exceções e um pouco de streams. Sem isso, o Spring parece mágica e os erros ficam difíceis de entender.
Qual versão do Java usar com Spring Boot 3?
O Spring Boot 3 exige no mínimo Java 17. Versões LTS como 17 e 21 são as escolhas mais seguras para projetos novos.
Maven ou Gradle?
Os dois funcionam bem. Maven é mais comum em vagas e tutoriais em português, por isso costuma ser mais fácil para começar. Gradle é mais flexível e popular em projetos Android e em algumas empresas.
Como ver os dados do H2 enquanto testo?
Adicione spring.h2.console.enabled=true no application.properties e acesse /h2-console no navegador. Confira a URL do banco no log de inicialização, já que nas versões recentes o nome do banco em memória pode ser gerado automaticamente.