Introduction

Spring Boot has more annotations than most languages have keywords. That's not necessarily bad — it means you write less code. But it also means you need to know which annotations actually matter and which are ceremony.

We're building a REST API for a product catalog. Layered architecture, JPA persistence, input validation, global exception handling, a basic security setup. Spring Boot 4.1, Java 17+, jakarta.* namespaces, Jackson 3. The code is production-shaped, not a hello-world throwaway.

You need basic Java and some familiarity with Maven or Gradle. If you've never seen an annotation before, this will be confusing. If you've seen too many, you'll appreciate that we're only using the ones that earn their place.

Project Setup with Spring Initializr

Spring Initializr. Maven, Java 17 or newer, Spring Boot 4.1.x (the current line as of October 2026; 3.x no longer gets open-source support, and 4.0 support ends in December 2026). Dependencies: Spring Web, Spring Data JPA, Validation, Spring Security, H2 Database. Generate, unzip, open.

Coming from Boot 3? Boot 4 splits auto-configuration into smaller modules, renames spring-boot-starter-web to spring-boot-starter-webmvc, adds per-technology test starters, moves test annotations to new packages and switches to Jackson 3 (tools.jackson packages). The code below is written for 4.x.

The pom.xml:

pom.xml
<!-- pom.xml (excerpt): versions come from the Boot parent -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.2</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-data-jpa</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-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-h2console</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Tests: each brings spring-boot-starter-test transitively -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Configure H2 first so there's a working database before any application code:

application.properties
# Database Configuration
spring.datasource.url=jdbc:h2:mem:productdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA Configuration (Hibernate detects the H2 dialect itself)
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# H2 Console (for development)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Spring Boot 4.1.2 startup log: banner, Tomcat initialized on port 8080, a Hibernate create table products statement, the H2 console at /h2-console with jdbc:h2:mem:productdb, and Started ProductApiApplication in 3.214 seconds.
Example output. Startup with this configuration: Hibernate creates the products table in H2 and Tomcat listens on port 8080.

In-memory H2. Zero setup. ddl-auto=update creates and updates the schema from entity classes. No spring.jpa.database-platform line: current Hibernate picks the dialect from the JDBC connection, and setting it explicitly only produces a deprecation warning. The console needs the spring-boot-h2console module from the pom.xml. In production you'd swap to PostgreSQL and use Flyway for migrations, but for development this gets you running in seconds.

Controllers and Request Mapping

ProductController.java
package com.codertronix.productapi.controller;

import com.codertronix.productapi.dto.ProductRequest;
import com.codertronix.productapi.dto.ProductResponse;
import com.codertronix.productapi.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    private final ProductService productService;

    // Constructor injection -- Spring autowires automatically
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts() {
        List<ProductResponse> products = productService.findAll();
        return ResponseEntity.ok(products);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProductById(
            @PathVariable Long id) {
        ProductResponse product = productService.findById(id);
        return ResponseEntity.ok(product);
    }

    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @Valid @RequestBody ProductRequest request) {
        ProductResponse created = productService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody ProductRequest request) {
        ProductResponse updated = productService.update(id, request);
        return ResponseEntity.ok(updated);
    }

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

Constructor injection, not field injection. Tutorials still show @Autowired on fields — don't. Constructor injection makes dependencies explicit, allows final fields, and makes the class trivially testable. One constructor means Spring autowires automatically.

We don't return JPA entities from controllers. ProductRequest handles incoming data, ProductResponse handles outgoing data. More files? Yes. But returning entities directly leads to Jackson serialization loops the moment you add a bidirectional JPA relationship. That bug is worse than the extra files.

Service Layer and Business Logic

Controllers should be thin. Receive request, delegate to service, return response. Business logic lives in the service layer because it shouldn't care whether the request arrived via HTTP, a message queue, or a CLI.

ProductService.java
package com.codertronix.productapi.service;

import com.codertronix.productapi.dto.ProductRequest;
import com.codertronix.productapi.dto.ProductResponse;
import com.codertronix.productapi.entity.Product;
import com.codertronix.productapi.exception.ResourceNotFoundException;
import com.codertronix.productapi.repository.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional(readOnly = true)
    public List<ProductResponse> findAll() {
        return productRepository.findAll()
                .stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "Product not found with id: " + id));
        return mapToResponse(product);
    }

    public ProductResponse create(ProductRequest request) {
        Product product = new Product();
        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setCategory(request.getCategory());
        product.setInStock(request.isInStock());

        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    public ProductResponse update(Long id, ProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "Product not found with id: " + id));

        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setCategory(request.getCategory());
        product.setInStock(request.isInStock());

        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    public void delete(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ResourceNotFoundException(
                    "Product not found with id: " + id);
        }
        productRepository.deleteById(id);
    }

    private ProductResponse mapToResponse(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getCategory(),
                product.isInStock(),
                product.getCreatedAt(),
                product.getUpdatedAt()
        );
    }
}

@Transactional at the class level wraps every public method in a transaction. Anything throws, changes roll back. readOnly = true on read methods tells Hibernate to skip dirty checking — minor optimization, but free.

The mapToResponse helper is fine here. Bigger project? Extract it or use MapStruct. Point is: entities never escape the service layer.

JPA Entities and Repositories

Spring Data JPA generates database access code at runtime from an interface you define. You write findByCategory(String category) as a method signature — no implementation, no SQL — and Spring parses the method name, generates the query, and wires it up. Sounds like magic. Works reliably in practice. Here's the entity it operates on:

Product.java
package com.codertronix.productapi.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(length = 1000)
    private String description;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(nullable = false, length = 100)
    private String category;

    @Column(name = "in_stock")
    private boolean inStock = true;

    @CreationTimestamp
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    // Default constructor (required by JPA)
    public Product() {}

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getDescription() { return description; }
    public void setDescription(String desc) { this.description = desc; }

    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }

    public String getCategory() { return category; }
    public void setCategory(String cat) { this.category = cat; }

    public boolean isInStock() { return inStock; }
    public void setInStock(boolean inStock) { this.inStock = inStock; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

Price is BigDecimal, not double. Floating-point arithmetic introduces rounding errors that compound. Customers getting charged $19.990000000000002 is a real bug that ships in production with double. Use BigDecimal for money. Always.

@CreationTimestamp and @UpdateTimestamp are Hibernate-specific. They auto-set timestamps on create and update. Convenient enough to justify the coupling.

And the repository:

ProductRepository.java
package com.codertronix.productapi.repository;

import com.codertronix.productapi.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Spring Data derives the query from the method name
    List<Product> findByCategory(String category);

    List<Product> findByInStockTrue();

    List<Product> findByNameContainingIgnoreCase(String keyword);

    // Custom JPQL query for more complex needs
    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
    List<Product> findByPriceRange(java.math.BigDecimal min,
                                   java.math.BigDecimal max);
}

No SQL. No implementation class. JpaRepository gives you save(), findById(), findAll(), deleteById() — all generated at runtime from that interface definition.

The custom finders parse method names into SQL. findBy starts the query, then property names and operators: Containing, IgnoreCase, True, Between. When names get unwieldy, drop to JPQL with @Query. But for most CRUD applications, derived queries cover the bulk of what you need and the method names serve as self-documenting query descriptions.

Validation and Error Handling

ProductRequest.java
package com.codertronix.productapi.dto;

import jakarta.validation.constraints.*;

import java.math.BigDecimal;

public class ProductRequest {

    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 200,
          message = "Name must be between 2 and 200 characters")
    private String name;

    @Size(max = 1000,
          message = "Description cannot exceed 1000 characters")
    private String description;

    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01",
                message = "Price must be at least 0.01")
    @DecimalMax(value = "999999.99",
                message = "Price cannot exceed 999999.99")
    private BigDecimal price;

    @NotBlank(message = "Category is required")
    private String category;

    private boolean inStock = true;

    // Getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getDescription() { return description; }
    public void setDescription(String desc) { this.description = desc; }

    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }

    public String getCategory() { return category; }
    public void setCategory(String cat) { this.category = cat; }

    public boolean isInStock() { return inStock; }
    public void setInStock(boolean inStock) { this.inStock = inStock; }
}

Jakarta Bean Validation. @Valid on a controller parameter triggers validation before the method runs. Fail? Spring throws MethodArgumentNotValidException. But the default error response is generic and useless for API clients.

Standard pattern. Copy this:

GlobalExceptionHandler.java
package com.codertronix.productapi.exception;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

// Extending ResponseEntityExceptionHandler keeps Spring MVC's own 4xx handling
// (malformed JSON, /products/abc type mismatches, unknown URLs, wrong method...)
// as RFC 9457 ProblemDetail responses instead of turning them into 500s.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // Customize the validation response: field -> message
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                fieldErrors.put(
                        error.getField(),
                        error.getDefaultMessage()
                )
        );

        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now());
        body.put("status", 400);
        body.put("error", "Validation Failed");
        body.put("errors", fieldErrors);

        return ResponseEntity.badRequest().body(body);
    }

    // Handle resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(
            ResourceNotFoundException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now());
        body.put("status", 404);
        body.put("error", "Not Found");
        body.put("message", ex.getMessage());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    // Last resort for genuinely unexpected errors. The more specific
    // handlers above (and the inherited ones) win for their exception types.
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneral(
            Exception ex) {
        logger.error("Unhandled exception", ex);
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now());
        body.put("status", 500);
        body.put("error", "Internal Server Error");
        body.put("message", "Something went wrong");

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(body);
    }
}
Terminal: posting an empty JSON body returns status 400 with Validation Failed and messages for price, name and category; fetching product 999 returns status 404 with Product not found with id: 999.
Example output. The exception handler turns validation failures and missing products into consistent JSON error bodies.

A plain @ExceptionHandler(Exception.class) on its own would also catch the exceptions Spring MVC normally maps to 4xx responses, such as unreadable JSON (HttpMessageNotReadableException) or /products/abc for a Long id (MethodArgumentTypeMismatchException), and report them as 500s. Extending ResponseEntityExceptionHandler keeps those as proper 400/404/405 responses.

The two remaining classes referenced so far. ResourceNotFoundException extends RuntimeException, nothing special:

ResourceNotFoundException.java
package com.codertronix.productapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

ProductResponse is a record whose components match the mapToResponse call in the service:

ProductResponse.java
package com.codertronix.productapi.dto;

import java.math.BigDecimal;
import java.time.LocalDateTime;

// Immutable response DTO; Jackson serializes record components as JSON fields
public record ProductResponse(
        Long id,
        String name,
        String description,
        BigDecimal price,
        String category,
        boolean inStock,
        LocalDateTime createdAt,
        LocalDateTime updatedAt
) {}

With this in place, empty product name returns {"errors": {"name": "Product name is required"}, "status": 400}. Nonexistent product returns 404 with a clear message. Malformed JSON or a non-numeric id returns 400 as a ProblemDetail body. No stack traces leaking to clients. @RestControllerAdvice applies globally — write it once, covers every controller.

Spring Security Basics

Add the dependency and everything locks down. Every endpoint requires auth. Random password in the startup logs. Default deny. The right starting point, but you need to open things back up selectively:

SecurityConfig.java
package com.codertronix.productapi.config;

import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http)
            throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(
                    SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // Public: anyone can read products
                .requestMatchers(HttpMethod.GET,
                    "/api/v1/products/**").permitAll()
                // Public: H2 console for development
                // (matches spring.h2.console.path, even if customized)
                .requestMatchers(PathRequest.toH2Console()).permitAll()
                // Protected: write operations need auth
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .headers(headers ->
                headers.frameOptions(f -> f.sameOrigin()));

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails admin = User.builder()
                .username("admin")
                .password(passwordEncoder().encode("secret123"))
                .roles("ADMIN")
                .build();

        return new InMemoryUserDetailsManager(admin);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
Terminal: a POST without credentials returns 401, a POST with -u admin:secret123 returns the created Mechanical Keyboard product with id 1 and status 201, and an unauthenticated GET lists it.
Example output. Anonymous writes get 401, the admin user can create a product, and anyone can read the list.

CSRF disabled — stateless API, no cookies. GETs are public. Writes require auth. In production, swap the in-memory user store for a database-backed one and replace HTTP Basic with JWT. This config is a starting point.

PathRequest.toH2Console() is Boot's matcher for the console path. It replaces a hand-written "/h2-console/**" pattern, which on Boot 3.1/3.2 could fail at startup because the H2 servlet made the pattern ambiguous. The frameOptions().sameOrigin() line exists for the H2 console's iframes. Remove both in production along with H2 console access, and never ship the hard-coded demo password.

Testing with MockMvc

MockMvc simulates HTTP requests without a real web server. Fast. Reliable.

ProductControllerTest.java
package com.codertronix.productapi.controller;

import com.codertronix.productapi.dto.ProductRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.json.JsonMapper;

import java.math.BigDecimal;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JsonMapper jsonMapper; // Jackson 3 mapper auto-configured by Boot 4

    @Test
    void getAllProducts_ReturnsOk() throws Exception {
        mockMvc.perform(get("/api/v1/products"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(
                        MediaType.APPLICATION_JSON));
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void createProduct_WithValidData_ReturnsCreated()
            throws Exception {
        ProductRequest request = new ProductRequest();
        request.setName("Mechanical Keyboard");
        request.setDescription("Cherry MX Brown switches");
        request.setPrice(new BigDecimal("89.99"));
        request.setCategory("Electronics");

        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name")
                        .value("Mechanical Keyboard"))
                .andExpect(jsonPath("$.price")
                        .value(89.99));
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void createProduct_WithInvalidData_ReturnsBadRequest()
            throws Exception {
        ProductRequest request = new ProductRequest();
        // Missing required fields -- should fail validation

        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors.name").exists())
                .andExpect(jsonPath("$.errors.price").exists());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void createProduct_WithMalformedJson_ReturnsBadRequest()
            throws Exception {
        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{ not json"))
                .andExpect(status().isBadRequest());
    }

    @Test
    void createProduct_WithoutAuth_ReturnsUnauthorized()
            throws Exception {
        ProductRequest request = new ProductRequest();
        request.setName("Test Product");
        request.setPrice(new BigDecimal("19.99"));
        request.setCategory("Test");

        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonMapper.writeValueAsString(request)))
                .andExpect(status().isUnauthorized());
    }
}
Maven test output: Running ProductControllerTest, Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, then BUILD SUCCESS in 9.482 seconds.
Example output. mvn test starts the context against in-memory H2 and all five controller tests pass.

@SpringBootTest boots the full context. @AutoConfigureMockMvc wires up MockMvc. @WithMockUser simulates auth without real login. In Boot 4, @AutoConfigureMockMvc lives in org.springframework.boot.webmvc.test.autoconfigure and is required, since @SpringBootTest alone no longer sets up MockMvc. @WithMockUser needs spring-boot-starter-security-test, and the JSON mapper is Jackson 3's JsonMapper.

Test names: methodName_scenario_expectedResult. When CI breaks, the name tells you what failed without reading the test body. Five scenarios here: read without auth (should succeed), create with valid data (201), create with invalid data (400 with field errors), malformed JSON (400, not 500), create without auth (401). Happy path plus the common failure modes. Run with mvn test — Spring Boot spins up embedded H2, runs everything, tears it down.

What I Would Change for Production

Replace H2 with PostgreSQL and set up Flyway migrations on day one. ddl-auto=update will eventually do something unexpected to a table with real data, and by then it's a painful fix. JWT instead of HTTP Basic. Pagination via Pageable.

Spring Boot Actuator — not until production. Spring HATEOAS — probably never. WebFlux — only if you've proven you need reactive, and "it sounds cool" doesn't count as proof. The annotation-based MVC model is enough for most CRUD APIs, especially with virtual threads available on Java 21+ (spring.threads.virtual.enabled=true), and the debugging experience is much simpler when your stack traces aren't full of Mono and Flux. Load-test your own workload before deciding you need reactive.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.