Spring Boot - From Zero to Production
03 Feb 2025Spring Boot has revolutionized Java application development by simplifying the bootstrap and development process. In this post, I’ll cover the journey from initial setup to production deployment, highlighting key practices I’ve implemented across multiple projects.
Why Spring Boot?
Spring Boot eliminates much of the boilerplate configuration required in traditional Spring applications:
- Opinionated defaults: Works out of the box with sensible configurations
- Standalone: Run as a self-contained application with embedded servers
- Production-ready: Built-in metrics, health checks, and externalized configuration
- No code generation: No XML configuration required
Setting Up a Spring Boot Project
The quickest way to bootstrap a Spring Boot application is using Spring Initializr:
curl https://start.spring.io/starter.tgz \
-d dependencies=web,data-jpa,postgresql,actuator,validation \
-d type=gradle-project \
-d bootVersion=3.2.0 \
-d groupId=com.example \
-d artifactId=demo-service \
-d packageName=com.example.demo \
-d javaVersion=17 | tar -xzvf -
Core Application Components
Application Configuration
Spring Boot’s externalized configuration is powerful for managing different environments:
@Configuration
@ConfigurationProperties(prefix = "app")
public class ApplicationProperties {
private String apiKey;
private int cacheTimeToLiveSeconds;
private Retry retry = new Retry();
// Getters and setters
public static class Retry {
private int maxAttempts = 3;
private long backoffMillis = 1000;
// Getters and setters
}
}
In application.yml
:
app:
api-key: ${API_KEY:default-key}
cache-time-to-live-seconds: 300
retry:
max-attempts: 5
backoff-millis: 2000
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:app}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
jdbc:
batch_size: 50
cache:
type: caffeine
caffeine:
spec: maximumSize=500,expireAfterAccess=600s
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
show-details: when_authorized
Entity and Repository Layer
For domain entities and repositories, I prefer a clean, focused approach:
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private BigDecimal price;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ProductCategory category;
@Column(length = 2000)
private String description;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
// Getters, setters, equals, hashCode, toString
}
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategoryOrderByName(ProductCategory category);
@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);
}
Service Layer
The service layer implements business logic and transaction boundaries:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
private final ProductMapper productMapper;
private final ApplicationProperties appProperties;
@Cacheable("products")
public List<ProductDTO> getProductsByCategory(ProductCategory category) {
return productRepository.findByCategoryOrderByName(category).stream()
.map(productMapper::toDto)
.collect(Collectors.toList());
}
@Transactional
public ProductDTO createProduct(CreateProductRequest request) {
Product product = productMapper.toEntity(request);
Product saved = productRepository.save(product);
return productMapper.toDto(saved);
}
@Transactional
@CacheEvict(value = "products", allEntries = true)
public void updateProductPrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
product.setPrice(newPrice);
productRepository.save(product);
}
}
Controller Layer
Controllers should be thin, delegating business logic to services:
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public ResponseEntity<Page<ProductDTO>> getProducts(
@RequestParam(required = false) ProductCategory category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<ProductDTO> products;
if (category != null) {
products = productService.getProductsByCategory(category, PageRequest.of(page, size));
} else {
products = productService.getAllProducts(PageRequest.of(page, size));
}
return ResponseEntity.ok(products);
}
@PostMapping
public ResponseEntity<ProductDTO> createProduct(
@Valid @RequestBody CreateProductRequest request) {
ProductDTO created = productService.createProduct(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}/price")
public ResponseEntity<Void> updatePrice(
@PathVariable Long id,
@Valid @RequestBody UpdatePriceRequest request) {
productService.updateProductPrice(id, request.getPrice());
return ResponseEntity.noContent().build();
}
}
Production-Ready Features
Exception Handling
A global exception handler provides consistent API responses:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
ApiError error = new ApiError(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));
ApiError apiError = new ApiError(
HttpStatus.BAD_REQUEST.value(),
"Validation error",
errors,
LocalDateTime.now()
);
return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAllExceptions(Exception ex) {
ApiError error = new ApiError(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"An unexpected error occurred",
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Request Logging
For debugging and audit purposes, request logging is essential:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestLoggingFilter implements Filter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
log.info("Request: {} {} [{}]",
httpRequest.getMethod(),
httpRequest.getRequestURI(),
httpRequest.getRemoteAddr());
long startTime = System.currentTimeMillis();
try {
chain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - startTime;
log.info("Response: {} {} ({}ms)",
httpResponse.getStatus(),
httpRequest.getRequestURI(),
duration);
MDC.remove("requestId");
}
}
}
Actuator Endpoints
Spring Boot Actuator provides production-ready features:
@Component
public class CustomHealthIndicator implements HealthIndicator {
private final ExternalServiceClient client;
public CustomHealthIndicator(ExternalServiceClient client) {
this.client = client;
}
@Override
public Health health() {
try {
boolean isAvailable = client.isServiceAvailable();
if (isAvailable) {
return Health.up()
.withDetail("externalService", "available")
.build();
} else {
return Health.down()
.withDetail("externalService", "unavailable")
.build();
}
} catch (Exception e) {
return Health.down(e).build();
}
}
}
Database Migration
Flyway or Liquibase manage database schema changes:
-- V1__create_products_table.sql
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(19, 2) NOT NULL,
category VARCHAR(50) NOT NULL,
description VARCHAR(2000),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
CREATE INDEX idx_products_category ON products(category);
Testing Strategies
Unit Testing
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@Mock
private ProductMapper productMapper;
@InjectMocks
private ProductService productService;
@Test
void createProduct_ShouldSaveAndReturnMappedDTO() {
// Arrange
CreateProductRequest request = new CreateProductRequest(
"Test Product",
BigDecimal.valueOf(99.99),
ProductCategory.ELECTRONICS,
"Test description"
);
Product product = new Product();
product.setName("Test Product");
product.setPrice(BigDecimal.valueOf(99.99));
Product savedProduct = new Product();
savedProduct.setId(1L);
savedProduct.setName("Test Product");
savedProduct.setPrice(BigDecimal.valueOf(99.99));
ProductDTO expectedDto = new ProductDTO(
1L,
"Test Product",
BigDecimal.valueOf(99.99),
ProductCategory.ELECTRONICS,
"Test description"
);
when(productMapper.toEntity(request)).thenReturn(product);
when(productRepository.save(product)).thenReturn(savedProduct);
when(productMapper.toDto(savedProduct)).thenReturn(expectedDto);
// Act
ProductDTO result = productService.createProduct(request);
// Assert
assertEquals(expectedDto, result);
verify(productRepository).save(product);
verify(productMapper).toEntity(request);
verify(productMapper).toDto(savedProduct);
}
}
Integration Testing
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(locations = "classpath:application-test.yml")
@ActiveProfiles("test")
class ProductControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ProductService productService;
@Test
void createProduct_ShouldReturnCreatedProduct() throws Exception {
// Arrange
CreateProductRequest request = new CreateProductRequest(
"New Product",
BigDecimal.valueOf(29.99),
ProductCategory.BOOKS,
"New book"
);
ProductDTO createdDto = new ProductDTO(
1L,
"New Product",
BigDecimal.valueOf(29.99),
ProductCategory.BOOKS,
"New book"
);
when(productService.createProduct(any())).thenReturn(createdDto);
// Act & Assert
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("New Product"))
.andExpect(jsonPath("$.price").value(29.99));
}
}
Containerization and Deployment
Dockerfile
FROM eclipse-temurin:17-jre-alpine as builder
WORKDIR /app
COPY build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
ENV JAVA_OPTS="-Xms512m -Xmx1024m"
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -q --spider http://localhost:8080/actuator/health || exit 1
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]
Application Performance Monitoring
For production monitoring, Spring Boot works seamlessly with APM tools like Datadog, New Relic, or Prometheus/Grafana.
Lessons Learned
After working with Spring Boot across various projects, here are key observations:
- Minimize boilerplate: Use Lombok, Spring Data projections, and MapStruct for cleaner code
- Embrace reactive programming: Consider WebFlux for high-throughput, low-latency requirements
- Don’t overuse annotations: They can hide complexity and make debugging harder
- Design for failure: Circuit breakers, retry policies, and graceful degradation are essential
- Profile and optimize early: Use Spring Boot’s metrics to identify bottlenecks
Conclusion
Spring Boot provides an excellent foundation for building modern Java applications. By following the practices outlined in this post, you can create robust, maintainable, and production-ready services.
In future posts, I’ll dive deeper into specific Spring Boot topics such as security, reactive programming, and event-driven architectures.
What Spring Boot features or best practices have you found most valuable? Share your experiences in the comments.