Java and Spring Boot · Intermediate

A validated, tested REST service with Spring Boot

4 min read

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 @RestController handles HTTP (paths, status codes, JSON). A @Service holds 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 Location header for a new resource, 404 when it does not exist, 400 for invalid input.

  • Tests. @WebMvcTest starts only the web layer, and MockMvc sends 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:

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:

CreateOrder.java
public record CreateOrder(
        @NotBlank @Email String customerEmail,
        @NotNull @DecimalMin("1.00") BigDecimal amount) {}
Order.java
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.

OrderService.java
@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:

OrderController.java
@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:

shellsession
$ 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}'
400

Worked example: the tests

OrderControllerTest.java
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());
    }
}
shellsession
$ mvn test
...
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

In 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 @Autowired on 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

  1. Add PATCH /orders/{id}/cancel that returns 409 if the order was already delivered, with a test for each case.

  2. Add a @RestControllerAdvice that returns validation errors as JSON with the field names and messages, and assert on it in a test.

Further reading