Spring Boot #
Building a Java enterprise application from scratch with pure Spring Framework is an exhausting experience — long XML configuration files, dependencies that must be configured manually one by one, and time wasted on boilerplate before a single line of business logic is written. Spring Boot was born to solve this problem. With the principle of convention over configuration, Spring Boot makes sensible default decisions for you — an embedded Tomcat is ready, auto-configuration activates based on the dependencies on the classpath, and a single @SpringBootApplication annotation is enough to run a production-grade application. The result: you can focus on what matters — business logic. Spring Boot is currently the most widely used Java framework in the industry, and understanding it well is the foundation that will shape how you design and build systems.
How Spring Boot Works #
Before writing code, it’s important to understand the core mechanisms that make Spring Boot work. These two concepts control almost everything that happens inside a Spring Boot application.
Inversion of Control and Dependency Injection #
Spring Boot applies the Inversion of Control (IoC) principle — instead of classes creating their own dependencies, Spring is responsible for creating and injecting the objects needed. Objects managed by Spring are called Beans.
flowchart LR
subgraph WITHOUT[Without IoC — the class controls its own dependencies]
A1[OrderService] -->|new UserRepository| B1[UserRepository]
A1 -->|new EmailService| C1[EmailService]
end
subgraph WITH[With IoC — Spring is in control]
IOC[Spring IoC\nContainer]
IOC -->|inject| A2[OrderService]
IOC -->|create and manage| B2[UserRepository]
IOC -->|create and manage| C2[EmailService]
B2 -->|injected into| A2
C2 -->|injected into| A2
endAuto-Configuration #
Spring Boot inspects your classpath at startup. If it finds spring-boot-starter-web, it automatically configures Tomcat, Jackson, and Spring MVC. If it finds spring-boot-starter-data-jpa, it configures Hibernate and the DataSource. This is called auto-configuration — you don’t need to manually define beans for things that already have sensible defaults.
Application Startup
↓
@SpringBootApplication found
↓
@EnableAutoConfiguration active
↓
Scan the classpath — which dependencies exist?
↓
spring-boot-starter-web found
→ configure Tomcat (port 8080)
→ configure Jackson (JSON serialization)
→ configure Spring MVC (dispatcher servlet)
↓
spring-boot-starter-data-jpa found
→ configure Hibernate
→ configure the DataSource from application.properties
↓
The application is ready to serve requests
Project Setup #
The fastest way to create a new Spring Boot project is through Spring Initializr at start.spring.io. Choose the dependencies you need, download, and open it in your IDE.
For Maven, the basic pom.xml structure:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Spring Boot parent — manages the versions of all dependencies -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>online-store</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>online-store</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Web MVC + embedded Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Bean validation (Jakarta Validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- PostgreSQL database driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- H2 for testing (in-memory database) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The standard Spring Boot directory structure:
src/
├── main/
│ ├── java/
│ │ └── com/example/onlinestore/
│ │ ├── OnlineStoreApplication.java ← entry point
│ │ ├── controller/ ← REST controllers
│ │ ├── service/ ← business logic
│ │ ├── repository/ ← database access
│ │ ├── model/ ← entities and DTOs
│ │ └── config/ ← custom configuration
│ └── resources/
│ ├── application.properties ← main configuration
│ ├── application-dev.properties ← dev configuration
│ └── application-prod.properties ← prod configuration
└── test/
└── java/
└── com/example/onlinestore/ ← test classes
The Entry Point #
package com.example.onlinestore;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// @SpringBootApplication is a combination of:
// @Configuration — this class is a Spring configuration source
// @EnableAutoConfiguration — enable auto-configuration
// @ComponentScan — scan all @Component, @Service, @Repository classes in this package
@SpringBootApplication
public class OnlineStoreApplication {
public static void main(String[] args) {
SpringApplication.run(OnlineStoreApplication.class, args);
}
}
Dependency Injection #
Spring supports three ways to inject dependencies. Choosing the right one matters for testability and code clarity.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
// ✗ ANTI-PATTERN: field injection — can't be tested without the Spring context
@Autowired
private UserRepository userRepository;
// ✗ ANTI-PATTERN: setter injection — dependencies can be null if the setter isn't called
private EmailService emailService;
@Autowired
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
// ✓ CORRECT: constructor injection — dependencies are required, testable without Spring
// Since Spring 4.3, @Autowired is optional when there's only one constructor
private final ProductRepository productRepository;
private final NotificationService notificationService;
public OrderService(ProductRepository productRepository,
NotificationService notificationService) {
this.productRepository = productRepository;
this.notificationService = notificationService;
}
}
Stereotype Annotations #
Spring recognizes a class as a Bean based on stereotype annotations:
// @Component — a generic Bean, no specific role
@org.springframework.stereotype.Component
public class DataConverter { }
// @Service — a Bean for business logic (semantic only, same as @Component)
@Service
public class PaymentService { }
// @Repository — a Bean for data access; Spring handles database exception translation
@org.springframework.stereotype.Repository
public class ProductRepositoryImpl { }
// @Controller — a Bean for HTTP request handlers (returns views)
@org.springframework.stereotype.Controller
public class PageController { }
// @RestController — @Controller + @ResponseBody; all methods return JSON/data directly
@org.springframework.web.bind.annotation.RestController
public class ProductApiController { }
// @Configuration — defines Beans programmatically
@org.springframework.context.annotation.Configuration
public class AppConfig {
// @Bean — explicitly register an object as a Spring Bean
// Useful for third-party libraries that can't be annotated with @Component
@org.springframework.context.annotation.Bean
public com.fasterxml.jackson.databind.ObjectMapper objectMapper() {
return new com.fasterxml.jackson.databind.ObjectMapper()
.findAndRegisterModules();
}
}
Configuration with application.properties #
All Spring Boot application configuration is managed through application.properties or application.yml. Spring Boot provides hundreds of built-in properties, and you can define your own custom properties too.
# ===== Server =====
server.port=8080
server.servlet.context-path=/api
# ===== Database =====
spring.datasource.url=jdbc:postgresql://localhost:5432/onlinestore
spring.datasource.username=postgres
spring.datasource.password=secret
spring.datasource.driver-class-name=org.postgresql.Driver
# Connection pool (HikariCP — Spring Boot default)
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000
# ===== JPA / Hibernate =====
spring.jpa.hibernate.ddl-auto=validate
# none → don't change the schema (production)
# validate → validate that the schema matches the entities (production-safe)
# update → update the schema automatically (development only)
# create → recreate the schema on every startup (testing only)
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# ===== Logging =====
logging.level.root=INFO
logging.level.com.example.onlinestore=DEBUG
logging.level.org.hibernate.SQL=DEBUG
# ===== Actuator (monitoring) =====
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when-authorized
Custom Properties with @ConfigurationProperties #
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
// Reads all properties with the "app" prefix from application.properties
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String name;
private int defaultPageSize = 20;
private Upload upload = new Upload();
// getters and setters are required for binding
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getDefaultPageSize() { return defaultPageSize; }
public void setDefaultPageSize(int pageSize) { this.defaultPageSize = pageSize; }
public Upload getUpload() { return upload; }
public void setUpload(Upload upload) { this.upload = upload; }
public static class Upload {
private String directory = "/tmp/uploads";
private long maxSize = 10 * 1024 * 1024; // 10 MB
public String getDirectory() { return directory; }
public void setDirectory(String directory) { this.directory = directory; }
public long getMaxSize() { return maxSize; }
public void setMaxSize(long maxSize) { this.maxSize = maxSize; }
}
}
# application.properties
app.name=My Online Store
app.default-page-size=25
app.upload.directory=/data/uploads
app.upload.max-size=20971520
Profiles #
Profiles allow different configuration for different environments without changing code:
# application.properties — configuration that applies to all profiles
spring.application.name=online-store
# application-dev.properties — only active when profile=dev
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop
logging.level.com.example=DEBUG
# application-prod.properties — only active when profile=prod
spring.datasource.url=jdbc:postgresql://prod-db:5432/onlinestore
spring.jpa.hibernate.ddl-auto=validate
logging.level.com.example=WARN
Activate a profile when running the application:
# Via an environment variable (the recommended way in production)
export SPRING_PROFILES_ACTIVE=prod
java -jar online-store.jar
# Via a JVM argument
java -jar online-store.jar --spring.profiles.active=prod
# Via application.properties (for development)
spring.profiles.active=dev
// @Profile — a Bean is only created if a specific profile is active
@Service
@org.springframework.context.annotation.Profile("dev")
public class MockEmailService implements EmailService {
@Override
public void send(String to, String subject, String body) {
System.out.println("[DEV] Simulated email to " + to + ": " + subject);
}
}
@Service
@org.springframework.context.annotation.Profile("prod")
public class SmtpEmailService implements EmailService {
@Override
public void send(String to, String subject, String body) {
// real SMTP implementation
}
}
REST APIs with Spring MVC #
Spring MVC provides a complete set of annotations for building REST APIs. Here’s a representative CRUD controller example for a Product entity.
Entity and DTOs #
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
// Entity — a database table representation
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Product name must not be empty")
@Size(min = 3, max = 100, message = "Product name must be 3-100 characters")
@Column(nullable = false, length = 100)
private String name;
@NotNull(message = "Price must not be null")
@DecimalMin(value = "0.01", message = "Minimum price is Rp 0.01")
@Column(nullable = false, precision = 15, scale = 2)
private java.math.BigDecimal price;
@Min(value = 0, message = "Stock must not be negative")
@Column(nullable = false)
private int stock;
@Column(name = "created_at", updatable = false)
@org.springframework.data.annotation.CreatedDate
private java.time.LocalDateTime createdAt;
// getters and setters
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public java.math.BigDecimal getPrice() { return price; }
public void setPrice(java.math.BigDecimal price) { this.price = price; }
public int getStock() { return stock; }
public void setStock(int stock) { this.stock = stock; }
public java.time.LocalDateTime getCreatedAt() { return createdAt; }
}
// DTO — data transfer object, separate from the entity
// Prevents the entity from being exposed directly to clients
public record ProductRequest(
@NotBlank String name,
@NotNull @DecimalMin("0.01") java.math.BigDecimal price,
@Min(0) int stock
) {}
public record ProductResponse(
Long id,
String name,
java.math.BigDecimal price,
int stock,
java.time.LocalDateTime createdAt
) {
public static ProductResponse from(Product product) {
return new ProductResponse(
product.getId(),
product.getName(),
product.getPrice(),
product.getStock(),
product.getCreatedAt()
);
}
}
Repository #
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Spring Data JPA automatically creates queries from method names
List<Product> findByNameContainingIgnoreCase(String keyword);
List<Product> findByPriceBetween(BigDecimal min, BigDecimal max);
List<Product> findByStockGreaterThan(int minStock);
Optional<Product> findByNameIgnoreCase(String name);
// Custom JPQL query
@Query("SELECT p FROM Product p WHERE p.stock = 0")
List<Product> findOutOfStock();
// Native SQL query
@Query(value = "SELECT * FROM products ORDER BY price ASC LIMIT :limit",
nativeQuery = true)
List<Product> findCheapest(@org.springframework.data.repository.query.Param("limit") int limit);
}
Service #
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true) // all methods are read-only by default
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<ProductResponse> getAllProducts() {
return productRepository.findAll().stream()
.map(ProductResponse::from)
.toList();
}
public ProductResponse getProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
return ProductResponse.from(product);
}
@Transactional // override to read-write for write operations
public ProductResponse createProduct(ProductRequest request) {
Product product = new Product();
product.setName(request.name());
product.setPrice(request.price());
product.setStock(request.stock());
Product saved = productRepository.save(product);
return ProductResponse.from(saved);
}
@Transactional
public ProductResponse updateProduct(Long id, ProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(request.name());
product.setPrice(request.price());
product.setStock(request.stock());
return ProductResponse.from(product); // JPA auto-commits when the transaction ends
}
@Transactional
public void deleteProduct(Long id) {
if (!productRepository.existsById(id)) {
throw new ProductNotFoundException(id);
}
productRepository.deleteById(id);
}
}
Controller #
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// GET /products
@GetMapping
public ResponseEntity<List<ProductResponse>> getAllProducts() {
return ResponseEntity.ok(productService.getAllProducts());
}
// GET /products/{id}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
return ResponseEntity.ok(productService.getProduct(id));
}
// POST /products
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
@Valid @RequestBody ProductRequest request) {
ProductResponse created = productService.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// PUT /products/{id}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable Long id,
@Valid @RequestBody ProductRequest request) {
return ResponseEntity.ok(productService.updateProduct(id, request));
}
// DELETE /products/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
// GET /products/search?keyword=laptop
@GetMapping("/search")
public ResponseEntity<List<ProductResponse>> search(
@RequestParam String keyword) {
List<Product> products = productService.searchProductsByName(keyword);
return ResponseEntity.ok(products.stream().map(ProductResponse::from).toList());
}
}
Global Exception Handling #
Handling exceptions per controller is very inefficient. Use @ControllerAdvice for a single point handling all exceptions.
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.stream.Collectors;
// Custom exception
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product with ID " + id + " not found");
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handle resource not found → 404
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleProductNotFound(ProductNotFoundException ex) {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
detail.setTitle("Product Not Found");
detail.setDetail(ex.getMessage());
detail.setProperty("timestamp", Instant.now());
return detail;
}
// Handle validation failure → 400
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationFailed(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
org.springframework.validation.FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid"
));
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
detail.setTitle("Validation Failed");
detail.setDetail("One or more fields are invalid");
detail.setProperty("errors", errors);
detail.setProperty("timestamp", Instant.now());
return detail;
}
// Handle all unhandled exceptions → 500
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneral(Exception ex) {
// Don't expose error details to clients in production
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
detail.setTitle("An Error Occurred");
detail.setDetail("Please try again or contact support");
detail.setProperty("timestamp", Instant.now());
return detail;
}
}
The error response format uses RFC 9457 Problem Details:
{
"type": "about:blank",
"title": "Validation Failed",
"status": 400,
"detail": "One or more fields are invalid",
"errors": {
"name": "Product name must not be empty",
"price": "Minimum price is Rp 0.01"
},
"timestamp": "2024-05-10T08:30:00Z"
}
Bean Lifecycle and the ApplicationContext #
Sometimes you need to run certain code when the application has just become ready or when it’s about to shut down.
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.event.EventListener;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
// Run code when the application is ready (after all beans are created)
@org.springframework.stereotype.Component
public class StartupRunner implements ApplicationRunner {
private final ProductService productService;
public StartupRunner(ProductService productService) {
this.productService = productService;
}
@Override
public void run(org.springframework.boot.ApplicationArguments args) throws Exception {
System.out.println("Application ready. Initializing data...");
// Example: seed initial data if the database is empty
}
}
// @PostConstruct and @PreDestroy — lifecycle at the Bean level
@org.springframework.stereotype.Service
public class ConnectionService {
@PostConstruct
public void initialize() {
// Called after the bean is created and all dependencies are injected
System.out.println("ConnectionService: opening connection...");
}
@PreDestroy
public void cleanup() {
// Called before the bean is destroyed (when the application shuts down)
System.out.println("ConnectionService: closing connection...");
}
}
// @EventListener — listen to Spring events
@org.springframework.stereotype.Component
public class ApplicationListener {
@EventListener(ApplicationReadyEvent.class)
public void whenReady() {
System.out.println("The application is fully ready to serve requests.");
}
}
Testing with Spring Boot Test #
Spring Boot provides very complete testing support — from simple unit tests to integration tests running a real server.
Service Unit Tests #
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // doesn't need a Spring context — pure unit test
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@InjectMocks
private ProductService productService;
@Test
void getProduct_whenFound_returnResponse() {
// Arrange
Product product = new Product();
product.setName("Laptop");
product.setPrice(new BigDecimal("15000000"));
product.setStock(10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// Act
ProductResponse response = productService.getProduct(1L);
// Assert
assertThat(response.name()).isEqualTo("Laptop");
assertThat(response.price()).isEqualByComparingTo("15000000");
verify(productRepository, times(1)).findById(1L);
}
@Test
void getProduct_whenIdDoesNotExist_throwException() {
when(productRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> productService.getProduct(99L))
.isInstanceOf(ProductNotFoundException.class)
.hasMessageContaining("99");
}
}
Controller Integration Tests #
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// @WebMvcTest — only loads the web layer, mocks all other layers
// Faster than @SpringBootTest because it doesn't load the full context
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void getAllProducts_returnJsonList() throws Exception {
ProductResponse laptop = new ProductResponse(1L, "Laptop", new BigDecimal("15000000"), 5, null);
ProductResponse mouse = new ProductResponse(2L, "Mouse", new BigDecimal("250000"), 20, null);
when(productService.getAllProducts()).thenReturn(List.of(laptop, mouse));
mockMvc.perform(get("/products"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].name").value("Laptop"))
.andExpect(jsonPath("$[1].price").value(250000));
}
@Test
void createProduct_withInvalidBody_return400() throws Exception {
String invalidBody = """
{
"name": "",
"price": -100,
"stock": -5
}
""";
mockMvc.perform(post("/products")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidBody))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Validation Failed"));
}
}
End-to-End Integration Test #
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
// @SpringBootTest — run a real server on a random port
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test") // use application-test.properties (in-memory H2)
class ProductIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void createAndGetProduct_endToEnd() {
// Create a product
ProductRequest request = new ProductRequest("Keyboard", new java.math.BigDecimal("500000"), 15);
ResponseEntity<ProductResponse> createResponse =
restTemplate.postForEntity("/products", request, ProductResponse.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = createResponse.getBody().id();
// Get the newly created product
ResponseEntity<ProductResponse> getResponse =
restTemplate.getForEntity("/products/" + id, ProductResponse.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().name()).isEqualTo("Keyboard");
}
}
When to Use Spring Boot and When Not To #
USE SPRING BOOT WHEN:
✓ Building REST APIs or enterprise backend services
✓ You need database integration with an ORM (JPA/Hibernate)
✓ The team is already familiar with the Spring ecosystem
✓ You need a complete ecosystem: security, batch, messaging, etc.
✓ You need mature testing infrastructure
✓ Long-term applications that need high maintainability
CONSIDER ALTERNATIVES WHEN:
✗ The application is very simple — the Spring overhead isn't worth it
✗ You need very fast startup times → Quarkus or Micronaut
✗ Memory footprint is critical (embedded/IoT) → lighter frameworks
✗ The team is more familiar with another framework → choose what you master
✗ You need native compilation → Quarkus with GraalVM is more mature
Summary #
- Spring Boot = Spring Framework + auto-configuration + embedded server — you don’t need to manually configure Tomcat, Jackson, or HikariCP; everything already has sensible defaults.
- Always use constructor injection, not field injection. Constructor injection makes unit testing easier without a Spring context and forces dependencies to always be available.
@SpringBootApplicationis a combination of@Configuration,@EnableAutoConfiguration, and@ComponentScan— one annotation to enable all of Spring Boot’s mechanisms.- Separate Controller, Service, and Repository — controllers only handle routing and request validation, services for business logic, repositories for data access. This keeps the code testable and separated.
- Use DTOs (
records) instead of entities directly in API responses — prevents sensitive data from being exposed and separates the database model from the API contract.@ControllerAdvice+@ExceptionHandleris the centralized way to handle all exceptions — cleaner than try-catch in every controller.- Use Spring Profiles to separate per-environment configuration —
application-dev.propertiesfor development,application-prod.propertiesfor production.@WebMvcTestfor testing the web layer without loading the full context (fast),@SpringBootTestfor end-to-end integration tests that need all components.spring.jpa.hibernate.ddl-auto=validatein production — never usecreateorupdatein production because they can change the database schema uncontrollably.