Java and Spring Boot · Intermediate
A validated, tested REST service with Spring Boot
Why it matters
Spring Boot appeared in 38% of the Software Engineer job descriptions we read, and in 62% of the junior ones. Most Java backend work is building and changing services like the one in this lesson: an endpoint, validation, business logic in a service, and tests that prove it.
The concept
Layers. A
@RestControllerhandles HTTP (paths, status codes, JSON). A@Serviceholds business logic. A repository talks to the database. Keeping them separate makes each easy to test.Dependency injection. Spring creates one instance of each component (a bean) and passes it to the constructors that need it. You write
OrderController(OrderService service); Spring supplies the service. Constructor injection makes dependencies explicit and easy to replace in tests.Validation. Annotate request fields with Jakarta Bean Validation constraints (
@NotBlank,@Email,@DecimalMin) and mark the parameter@Valid. Invalid requests get a 400 before your code runs.Status codes. 201 Created with a
Locationheader for a new resource, 404 when it does not exist, 400 for invalid input.Tests.
@WebMvcTeststarts only the web layer, andMockMvcsends requests without a real server, so tests run in about a second.
Worked example: the orders service
The project uses Spring Boot 4.1.1 and Java 21. The dependencies in pom.xml:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</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-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>The request and the stored order are records:
public record CreateOrder(
@NotBlank @Email String customerEmail,
@NotNull @DecimalMin("1.00") BigDecimal amount) {}public record Order(long id, String customerEmail, BigDecimal amount, String status) {}The service keeps orders in memory for now. In a real service this is where a repository and a transaction would go.
@Service
public class OrderService {
private final Map<Long, Order> orders = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
public Order create(CreateOrder request) {
long id = nextId.getAndIncrement();
Order order = new Order(id, request.customerEmail(), request.amount(), "PLACED");
orders.put(id, order);
return order;
}
public Optional<Order> find(long id) {
return Optional.ofNullable(orders.get(id));
}
}The controller only translates between HTTP and the service:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<Order> create(@Valid @RequestBody CreateOrder request) {
Order order = service.create(request);
return ResponseEntity.created(URI.create("/orders/" + order.id())).body(order);
}
@GetMapping("/{id}")
public ResponseEntity<Order> get(@PathVariable long id) {
return service.find(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
}Imports are left out above for space; your IDE adds them. Running it with mvn spring-boot:run or java -jar target/orders-0.1.0.jar:
$ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' \
-d '{"customerEmail":"asha@example.com","amount":1250.00}'
HTTP/1.1 201
Location: /orders/1
{"id":1,"customerEmail":"asha@example.com","amount":1250.00,"status":"PLACED"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/orders \
-H 'Content-Type: application/json' -d '{"customerEmail":"x","amount":0}'
400Worked example: the tests
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(OrderController.class)
@Import(OrderService.class)
class OrderControllerTest {
@Autowired
MockMvc mvc;
@Test
void createsAnOrder() throws Exception {
mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON)
.content("{\"customerEmail\":\"asha@example.com\",\"amount\":1250.00}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/orders/1"))
.andExpect(jsonPath("$.status").value("PLACED"));
}
@Test
void rejectsInvalidInput() throws Exception {
mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON)
.content("{\"customerEmail\":\"not-an-email\",\"amount\":0}"))
.andExpect(status().isBadRequest());
}
@Test
void returns404ForUnknownOrder() throws Exception {
mvc.perform(get("/orders/999")).andExpect(status().isNotFound());
}
}$ mvn test
...
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0In Spring Boot 4 the test slices moved into per-technology modules, which is why WebMvcTest is imported from org.springframework.boot.webmvc.test.autoconfigure. Older tutorials for Boot 3 show a different package.
Common mistakes
Business logic in controllers. It becomes impossible to reuse or test without HTTP. Move it to a service.
Field injection with
@Autowiredon private fields in application code. Prefer constructors; field injection hides dependencies.Returning 200 for everything. Use 201, 204, 400, 404 and 409 so clients can react correctly.
Returning entities with sensitive fields. Map to response records that expose only what clients need.
Interview angle
Expect "what is dependency injection and why use it?", "what does @SpringBootApplication do?" (configuration, auto-configuration and component scanning), "how do you handle validation errors?", "difference between @Controller and @RestController", and "how would you test this endpoint?". Being able to write the controller and a MockMvc test live is a strong signal.
Practice task
Add
PATCH /orders/{id}/cancelthat returns 409 if the order was already delivered, with a test for each case.Add a
@RestControllerAdvicethat returns validation errors as JSON with the field names and messages, and assert on it in a test.