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 3, Java 17+, Jakarta EE 9+ namespaces. 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, Spring Boot 3.2.x. Dependencies: Spring Web, Spring Data JPA, Starter Validation, Spring Security, H2 Database (dev), Starter Test. Generate, unzip, open.
The pom.xml:
<!-- Key dependencies in pom.xml --><dependencies><dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency></dependencies>Configure H2 first so there's a working database before any application code:
# 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
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
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-consoleIn-memory H2. Zero setup. ddl-auto=update creates and updates the schema from entity classes. 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
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 classProductController {
private final ProductService productService;
// Constructor injection -- Spring autowires automaticallypublicProductController(ProductService productService) {
this.productService = productService;
}
@GetMappingpublic 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);
}
@PostMappingpublic 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.
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@Transactionalpublic classProductService {
private final ProductRepository productRepository;
publicProductService(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(() ->newResourceNotFoundException(
"Product not found with id: " + id));
returnmapToResponse(product);
}
public ProductResponse create(ProductRequest request) {
Product product = newProduct();
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);
returnmapToResponse(saved);
}
public ProductResponse update(Long id, ProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() ->newResourceNotFoundException(
"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);
returnmapToResponse(updated);
}
public voiddelete(Long id) {
if (!productRepository.existsById(id)) {
throw newResourceNotFoundException(
"Product not found with id: " + id);
}
productRepository.deleteById(id);
}
private ProductResponse mapToResponse(Product product) {
return newProductResponse(
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:
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 classProduct {
@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)publicProduct() {}
// Getters and setterspublic Long getId() { return id; }
public voidsetId(Long id) { this.id = id; }
public String getName() { return name; }
public voidsetName(String name) { this.name = name; }
public String getDescription() { return description; }
public voidsetDescription(String desc) { this.description = desc; }
public BigDecimal getPrice() { return price; }
public voidsetPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public voidsetCategory(String cat) { this.category = cat; }
public booleanisInStock() { return inStock; }
public voidsetInStock(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:
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;
@Repositorypublic interfaceProductRepositoryextends 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, the derived queries handle 80% of what you need and the method names serve as self-documenting query descriptions.
Validation and Error Handling
package com.codertronix.productapi.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public classProductRequest {
@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 setterspublic String getName() { return name; }
public voidsetName(String name) { this.name = name; }
public String getDescription() { return description; }
public voidsetDescription(String desc) { this.description = desc; }
public BigDecimal getPrice() { return price; }
public voidsetPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public voidsetCategory(String cat) { this.category = cat; }
public booleanisInStock() { return inStock; }
public voidsetInStock(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:
package com.codertronix.productapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvicepublic classGlobalExceptionHandler {
// Handle validation errors@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
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);
}
// Catch-all for unexpected errors@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneral(
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);
}
}ResourceNotFoundException extends RuntimeException. Nothing special.
With this in place, empty product name returns {"errors": {"name": "Product name is required"}, "status": 400}. Nonexistent product returns 404 with a clear message. 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:
package com.codertronix.productapi.config;
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.*;
import org.springframework.security.config.annotation.web.configuration.*;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.*;
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@EnableWebSecuritypublic classSecurityConfig {
@Beanpublic 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
.requestMatchers("/h2-console/**").permitAll()
// Protected: write operations need auth
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.headers(headers ->
headers.frameOptions(f -> f.sameOrigin()));
return http.build();
}
@Beanpublic UserDetailsService userDetailsService() {
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("secret123"))
.roles("ADMIN")
.build();
return newInMemoryUserDetailsManager(admin);
}
@Beanpublic PasswordEncoder passwordEncoder() {
return newBCryptPasswordEncoder();
}
}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.
The frameOptions().sameOrigin() line exists for the H2 console's iframes. Remove it in production along with H2 console access.
Testing with MockMvc
MockMvc simulates HTTP requests without a real web server. Fast. Reliable.
package com.codertronix.productapi.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.codertronix.productapi.dto.ProductRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest@AutoConfigureMockMvcclassProductControllerTest {
@Autowiredprivate MockMvc mockMvc;
@Autowiredprivate ObjectMapper objectMapper;
@TestvoidgetAllProducts_ReturnsOk() throws Exception {
mockMvc.perform(get("/api/v1/products"))
.andExpect(status().isOk())
.andExpect(content().contentType(
MediaType.APPLICATION_JSON));
}
@Test@WithMockUser(roles = "ADMIN")
voidcreateProduct_WithValidData_ReturnsCreated()
throws Exception {
ProductRequest request = newProductRequest();
request.setName("Mechanical Keyboard");
request.setDescription("Cherry MX Brown switches");
request.setPrice(newBigDecimal("89.99"));
request.setCategory("Electronics");
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name")
.value("Mechanical Keyboard"))
.andExpect(jsonPath("$.price")
.value(89.99));
}
@Test@WithMockUser(roles = "ADMIN")
voidcreateProduct_WithInvalidData_ReturnsBadRequest()
throws Exception {
ProductRequest request = newProductRequest();
// Missing required fields -- should fail validation
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.name").exists())
.andExpect(jsonPath("$.errors.price").exists());
}
@TestvoidcreateProduct_WithoutAuth_ReturnsUnauthorized()
throws Exception {
ProductRequest request = newProductRequest();
request.setName("Test Product");
request.setPrice(newBigDecimal("19.99"));
request.setCategory("Test");
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}@SpringBootTest boots the full context. @AutoConfigureMockMvc wires up MockMvc. @WithMockUser simulates auth without real login.
Test names: methodName_scenario_expectedResult. When CI breaks, the name tells you what failed without reading the test body. Four scenarios here: read without auth (should succeed), create with valid data (201), create with invalid data (400 with field errors), 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.
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 handles more load than most people think. I've seen single Spring Boot instances handle thousands of requests per second without any reactive magic, and the debugging experience is dramatically better when your stack traces aren't full of Mono and Flux.