Creating Your First REST API with Spring Boot
1. Overview
This guide teaches you how to create a complete REST API with Spring Boot using an example of managing Order (orders). After reading, you’ll be able to set up a project, write a controller handling CRUD operations, return errors in proper HTTP format, and write tests for your API using MockMvc.
2. Project Initialization
Create a project via Spring Initializr with the following dependencies:
<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 provides embedded Tomcat and Spring MVC. spring-boot-starter-validation is used to validate the request body (section 5). The spring-boot-starter-test includes JUnit 5, AssertJ, and MockMvc.
Check that the application runs:
@SpringBootApplication
public class OrderApiApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApiApplication.class, args);
}
}
Run mvn spring-boot:run, and you’ll see Tomcat start up on port 8080.
3. Define the Model
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
}
This is a simple model, not yet using JPA. The goal of this guide is to master the REST layer first; persistence will be covered in another article.
4. Write a Service to Store Temporary Data
@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 acts as a temporary “database”, allowing you to run a demo without configuring a database. AtomicLong generates incrementing IDs safely when multiple threads call create.
Note: This data storage approach is for learning only; data is lost when the app restarts. Production should use Spring Data JPA or equivalent.
5. Write a REST Controller
5.1. Endpoints for Create and List
@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 combines @Controller and @ResponseBody, returning JSON instead of a view. @RequestMapping("/api/orders") sets a common prefix for all endpoints in the class. @PostMapping receives the body via @RequestBody, and Spring automatically converts JSON to OrderRequest thanks to Jackson included in spring-boot-starter-web.
OrderRequest is a record used to validate input, separate from Order:
public record OrderRequest(
@NotBlank String customerName,
@NotNull @Positive BigDecimal amount
) {}
5.2. Endpoints for Get by ID and Delete
@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 extracts the {id} value from the URL. When an order is not found, the API returns 404 Not Found instead of null — this is a point often missed by beginners when building REST APIs.
Try calling with 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. Centralized Error Handling
Without handling, validation errors will return a default JSON response that’s hard to read. Use @RestControllerAdvice to standardize it:
@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);
}
}
This class catches every MethodArgumentNotValidException thrown by @Valid, groups errors by field, and returns 400 Bad Request with an easy-to-read body:
{
"amount": "must be greater than 0",
"customerName": "must not be blank"
}
Note: Don’t let Spring’s default exceptions leak into production — they can expose stack traces, affecting security.
7. Write Tests for the Controller
<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 loads only the web layer (controller, filter, validation), not the entire context, so tests run fast. @MockBean replaces the real OrderService with a mock, allowing you to test the controller independently from the service logic. Both test cases check both the success path and the error path — a habit you should adopt for every endpoint.
Further verification using AssertJ at the service layer:
@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. Common Mistakes
- Forgetting
@RequestBody: Spring won’t bind JSON to the object, fields will always benullwithout a clear error message. - Returning
nullinstead ofResponseEntity.notFound(): the client receives200 OKwith anullbody, easily causing bugs on the frontend. - Not validating input: the API accepts negative amounts, empty names… causing logic errors in lower layers that are hard to trace.
- Using
@Controllerinstead of@RestController: you have to manually add@ResponseBodyto each method, easy to forget.
Note: If your API serves multiple clients (mobile, web, partners), consider versioning from the start (e.g.,
/api/v1/orders) to avoid breaking changes later.
9. Conclusion
You’ve built a basic REST API with Spring Boot: from model, service, and controller to centralized error handling and testing with MockMvc. The next step is to replace ConcurrentHashMap with Spring Data JPA for real persistence, and add more exception handling in @RestControllerAdvice such as EntityNotFoundException.