Spring Boot is the most widely deployed Java framework in the world. It powers banking systems, healthcare platforms, e-commerce giants, and the overwhelming majority of enterprise microservices. If you're building anything serious in Java, Spring Boot is your starting point. not a consideration.
This guide walks you through building a production-grade REST API from scratch: project structure, dependency injection, data persistence, security, validation, error handling, and testing. No shortcuts, no toy examples.
Why Spring Boot in 2026?
Spring Boot has held its position as the dominant Java framework for over a decade, and for good reason. The alternative Java frameworks. Quarkus, Micronaut, Helidon. each solve specific problems (faster cold starts, smaller container images), but Spring Boot's ecosystem depth, community size, and tooling maturity remain unmatched.
Key reasons teams still choose Spring Boot:
- Auto-configuration: sensible defaults out of the box; you override only what you need.
- Spring Data JPA: reduces data-layer boilerplate to near zero.
- Spring Security: battle-tested authentication and authorization.
- Actuator: production observability (health checks, metrics, tracing) built in.
- Massive ecosystem: every cloud vendor, every database, every message broker has a Spring integration.
Project Loom (virtual threads, fully available since Java 21) gave Spring Boot a substantial throughput boost without requiring code changes. we cover that in a companion deep-dive.
Prerequisites
- Java 21 (LTS) or later
- Maven 3.9+ or Gradle 8+
- A working IDE (IntelliJ IDEA recommended)
- Basic understanding of HTTP and REST
Step 1: Bootstrap the Project
The fastest path is Spring Initializr at start.spring.io. Select:
- Project: Maven
- Language: Java
- Spring Boot: 3.3.x (latest stable)
- Java: 21
- Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Spring Security, Spring Boot Actuator, Validation
Download, unzip, open in your IDE.
Your pom.xml core dependencies will look like this:
<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.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</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-actuator</artifactId>
</dependency>
</dependencies>
Step 2: Project Structure
Spring Boot doesn't enforce structure, but this layout scales well:
src/main/java/com/yourcompany/app/
├── AppApplication.java # entry point
├── config/ # Security, CORS, bean configs
├── controller/ # REST controllers (HTTP layer only)
├── dto/ # Request/response objects
├── entity/ # JPA entities
├── exception/ # Custom exceptions + global handler
├── repository/ # Spring Data JPA interfaces
├── service/ # Business logic
└── util/ # Shared helpers
The golden rule: controllers call services, services call repositories. Never skip layers.
Step 3: Configure the Database
In src/main/resources/application.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # use Flyway/Liquibase for migrations
show-sql: false
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
Never use ddl-auto: create or update in production. Use a proper migration tool like Flyway:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
Migration files go in src/main/resources/db/migration/V1__init.sql.
Step 4: Define the Entity
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false)
private BigDecimal price;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
private Instant createdAt;
// getters / setters or use Lombok @Data
}
Step 5: Repository Layer
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByNameContainingIgnoreCase(String name);
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
Page<Product> findByPriceRange(
@Param("min") BigDecimal min,
@Param("max") BigDecimal max,
Pageable pageable);
}
Spring Data generates the implementation at runtime. No boilerplate SQL.
Step 6: DTOs and Validation
Never expose entities directly through the API. Use DTOs:
public record CreateProductRequest(
@NotBlank(message = "Name is required")
@Size(max = 120, message = "Name must be 120 characters or fewer")
String name,
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be positive")
BigDecimal price
) {}
public record ProductResponse(
Long id,
String name,
BigDecimal price,
Instant createdAt
) {
public static ProductResponse from(Product p) {
return new ProductResponse(p.getId(), p.getName(), p.getPrice(), p.getCreatedAt());
}
}
Step 7: Service Layer
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
public Page<ProductResponse> list(Pageable pageable) {
return repo.findAll(pageable).map(ProductResponse::from);
}
@Transactional
public ProductResponse create(CreateProductRequest req) {
var product = new Product();
product.setName(req.name());
product.setPrice(req.price());
return ProductResponse.from(repo.save(product));
}
public ProductResponse get(Long id) {
return repo.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
}
}
Mark the class @Transactional(readOnly = true) and override with @Transactional on write methods. this is a significant performance optimisation that many teams miss.
Step 8: Controller Layer
@RestController
@RequestMapping("/api/v1/products")
@Validated
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public Page<ProductResponse> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return service.list(PageRequest.of(page, size, Sort.by("createdAt").descending()));
}
@GetMapping("/{id}")
public ProductResponse get(@PathVariable Long id) {
return service.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse create(@Valid @RequestBody CreateProductRequest req) {
return service.create(req);
}
}
Step 9: Global Exception Handling
Centralise error responses with @RestControllerAdvice:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
var detail = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
detail.setTitle("Validation failed");
detail.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(), "message", e.getDefaultMessage()))
.toList());
return detail;
}
}
ProblemDetail is RFC 7807. the standard format for HTTP API errors. Spring Boot 3 supports it natively.
Step 10: Testing
Spring Boot's test slices let you test layers in isolation:
// Unit test the service
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock ProductRepository repo;
@InjectMocks ProductService service;
@Test
void get_throwsWhenNotFound() {
when(repo.findById(99L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> service.get(99L));
}
}
// Integration test the controller
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired MockMvc mvc;
@MockBean ProductService service;
@Test
void list_returns200() throws Exception {
when(service.list(any())).thenReturn(Page.empty());
mvc.perform(get("/api/v1/products"))
.andExpect(status().isOk());
}
}
Common Pitfalls
1. Circular dependency injection: usually a sign the service layer is too large. Split responsibilities.
2. N+1 queries: when fetching a list of entities that each trigger a lazy-load. Use @EntityGraph or JPQL JOIN FETCH to fix.
3. @Transactional on private methods: Spring AOP proxies won't intercept private methods. Transactions are silently ignored.
4. Using entities as API responses: entities change schema over time; DTOs insulate your API contract from those changes.
5. Ignoring the Actuator: /actuator/health, /actuator/metrics, and /actuator/prometheus are free observability. Always enable them.
When to Use Spring Boot vs Alternatives
| Scenario | Recommendation |
|---|---|
| Enterprise team, complex domain | Spring Boot. ecosystem depth wins |
| Fast cold starts (serverless/Lambda) | Quarkus or Micronaut |
| Simplest possible REST service | Spring Boot with virtual threads is fine |
| Kotlin codebase | Spring Boot + Kotlin or Ktor |
| Go/Rust team | Wrong question. use the right language for the team |
FAQ
Q: Should I use Spring MVC or Spring WebFlux? For most teams: Spring MVC with virtual threads (Java 21+). WebFlux is reactive and has a steeper learning curve. the throughput gains are rarely worth it unless you're handling massive concurrency with blocking I/O.
Q: How do I handle database migrations? Flyway for simplicity; Liquibase if you need rollback scripts or XML-based change sets. Both integrate with Spring Boot via auto-configuration.
Q: What's the difference between @Component, @Service, @Repository, @Controller?
They're all @Component specialisations. The semantic difference matters for clarity and for Spring Data's exception translation (which applies to @Repository-annotated classes).
Q: Do I still need Lombok? Java records handle immutable DTOs cleanly. Lombok remains useful for mutable entities with many fields. The community is gradually moving toward records.
Q: How do I secure my API? Start with Spring Security's JWT support for stateless APIs. The companion article on Spring Security covers this in detail.
Conclusion
Spring Boot remains the gold standard for Java API development. and for good reason. Its auto-configuration, rich ecosystem, and excellent testing support mean you can ship production-quality code fast without reinventing infrastructure.
The patterns in this guide. layered architecture, DTOs, global exception handling, ProblemDetail responses, and proper transaction management. apply regardless of the domain you're building for.
Next: Virtual Threads with Spring Boot. how Java 21 Project Loom transforms throughput without touching your business logic.