Criar sua Primeira REST API com Spring Boot

1. Visão geral

Este artigo te guia na criação de uma REST API completa com Spring Boot, usando o exemplo de gerenciamento de Order (pedido). Após ler, você será capaz de construir o projeto, escrever um controller que lida com CRUD, retornar erros de acordo com o padrão HTTP, e escrever testes para a API usando MockMvc.

2. Inicializar o projeto

Crie um projeto via Spring Initializr com as seguintes dependências:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-web fornece Tomcat incorporado e Spring MVC. spring-boot-starter-validation é usado para validar o corpo da requisição (seção 5). A parte spring-boot-starter-test vem com JUnit 5, AssertJ, MockMvc.

Verifique se a aplicação funciona:

@SpringBootApplication
public class OrderApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApiApplication.class, args);
    }
}

Execute mvn spring-boot:run, você verá o Tomcat iniciando na porta 8080.

3. Definir o modelo

public class Order {
    private Long id;
    private String customerName;
    private BigDecimal amount;

    public Order(Long id, String customerName, BigDecimal amount) {
        this.id = id;
        this.customerName = customerName;
        this.amount = amount;
    }

    // getters, setters
}

Este é um modelo simples, ainda não relacionado com JPA. O objetivo deste artigo é dominar a camada REST primeiro, deixando a persistência para outro artigo.

4. Escrever o Service para armazenar dados temporariamente

@Service
public class OrderService {

    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idSequence = new AtomicLong(0);

    public Order create(Order order) {
        long id = idSequence.incrementAndGet();
        Order saved = new Order(id, order.getCustomerName(), order.getAmount());
        store.put(id, saved);
        return saved;
    }

    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void deleteById(Long id) {
        store.remove(id);
    }
}

ConcurrentHashMap funciona como um “banco de dados” temporário, permitindo que você execute a demonstração sem precisar configurar um BD. AtomicLong gera IDs sequenciais incrementados, sendo seguro quando múltiplas threads chamam create.

Observação: Esta forma de armazenar dados é apenas para aprendizado, os dados são perdidos quando a app reinicia. Em produção, você precisa de Spring Data JPA ou equivalente.

5. Escrever o REST Controller

5.1. Endpoints para criar e listar

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
        Order order = new Order(null, request.customerName(), request.amount());
        Order saved = orderService.create(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<Order> findAll() {
        return orderService.findAll();
    }
}

@RestController combina @Controller e @ResponseBody, retornando JSON em vez de uma view. @RequestMapping("/api/orders") anexa um prefixo comum para todos os endpoints nesta classe. @PostMapping recebe o body via @RequestBody, Spring converte automaticamente JSON para OrderRequest graças ao Jackson incluído em spring-boot-starter-web.

OrderRequest é um record usado para validar entrada, separado de Order:

public record OrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal amount
) {}

5.2. Endpoints para obter por ID e deletar

@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
    return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteById(id);
    return ResponseEntity.noContent().build();
}

@PathVariable captura o valor {id} da URL. Quando o pedido não é encontrado, a API retorna 404 Not Found em vez de null — este é um ponto facilmente negligenciado ao criar sua primeira REST API.

Teste com curl:

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"customerName":"Nguyen Van A","amount":150000}

6. Tratamento centralizado de erros

Se não tratar, erros de validação retornarão uma response padrão difícil de ler. Use @RestControllerAdvice para padronizar:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

Esta classe captura qualquer MethodArgumentNotValidException lançada por @Valid, agrupa erros por campo e retorna 400 Bad Request com um body legível:

{
  "amount": "must be greater than 0",
  "customerName": "must not be blank"
}

Observação: Não deixe exceptions padrão do Spring se exporem em produção — pode expor stack trace e afetar a segurança.

7. Escrever testes

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
        Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
        given(orderService.create(any(Order.class))).willReturn(saved);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"customerName":"Tran Thi B","amount":200000}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.customerName").value("Tran Thi B"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound());
    }
}

@WebMvcTest apenas carrega a camada web (controller, filter, validation), não inicializa o contexto completo, então os testes executam rapidamente. @MockBean substitui o OrderService real por um mock, ajudando a testar o controller de forma independente. Esses dois casos de teste verificam tanto o caminho de sucesso quanto o de erro — um hábito que você deve ter para todo endpoint.

Verificar ainda mais com AssertJ na camada de serviço:

@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
    OrderService service = new OrderService();

    Order first = service.create(new Order(null, "A", BigDecimal.TEN));
    Order second = service.create(new Order(null, "B", BigDecimal.ONE));

    assertThat(first.getId()).isEqualTo(1L);
    assertThat(second.getId()).isEqualTo(2L);
}

8. Erros comuns

  • Esquecer de @RequestBody: Spring não vinculará JSON ao objeto, o campo sempre fica null sem aviso claro de erro.
  • Retornar null em vez de ResponseEntity.notFound(): cliente recebe 200 OK com body null, fácil de causar bug no frontend.
  • Não validar entrada: API recebe amount negativo, nome vazio… causando erro de lógica em camadas inferiores que é difícil de rastrear.
  • Usar @Controller em vez de @RestController: deve adicionar @ResponseBody manualmente em cada método, fácil esquecer.

Observação: Se sua API atende vários clientes (mobile, web, parceiros), considere versionamento desde o início (por exemplo /api/v1/orders) para evitar quebra de compatibilidade no futuro.

9. Conclusão

Você construiu uma REST API básica com Spring Boot: desde modelo, serviço, controller, tratamento centralizado de erros até testes com MockMvc. Próximo passo, você deve substituir ConcurrentHashMap por Spring Data JPA para que os dados persistam realmente, e adicione @RestControllerAdvice para tratar mais exceções como EntityNotFoundException.

spring-bootrest-apijavaspring-web