Java Development with Spring Boot

Mastering Backend Development with Java Spring Boot: A Complete Expert Guide
Build production-grade, scalable, and maintainable backend systems — from first principles to deployment.
1. Introduction: Why Spring Boot?
Spring Boot is the de facto standard for building enterprise-grade backend applications in Java. It removes the boilerplate of configuring a Spring application from scratch through its convention-over-configuration philosophy and powerful auto-configuration engine.
Key advantages at a glance:
- Embedded server (Tomcat/Jetty/Undertow) — no WAR deployment needed
- Production-ready features out of the box (health checks, metrics, externalized config)
- Massive, battle-tested ecosystem (Spring Security, Spring Data, Spring Cloud)
- Native support for modern architecture patterns: REST, microservices, event-driven
2. Core Architecture & Project Structure
Bootstrapping a Project
The fastest way to start is Spring Initializr. Choose:
- Build tool: Maven or Gradle
- Language: Java 21 (LTS recommended)
- Spring Boot version: Latest stable (3.x)
- Dependencies: Spring Web, Spring Data JPA, Spring Security, Validation, Actuator
Recommended Project Structure
src/
├── main/
│ ├── java/com/yourapp/
│ │ ├── config/ # Security, beans, app-level config
│ │ ├── controller/ # REST controllers (presentation layer)
│ │ ├── service/ # Business logic layer
│ │ ├── repository/ # Data access layer
│ │ ├── domain/ # Entities, enums, domain objects
│ │ ├── dto/ # Data Transfer Objects (request/response)
│ │ ├── exception/ # Custom exceptions & global handler
│ │ ├── mapper/ # Entity <-> DTO mappers (MapStruct)
│ │ └── util/ # Shared utilities
│ └── resources/
│ ├── application.yml # Main configuration
│ ├── application-dev.yml
│ ├── application-prod.yml
│ └── db/migration/ # Flyway SQL scripts
└── test/
├── unit/
└── integration/This layered architecture enforces separation of concerns and keeps each layer independently testable.
3. Dependency Injection & IoC Container
Spring's Inversion of Control (IoC) container manages the lifecycle and dependencies of your objects (called beans).
Stereotype Annotations
| Annotation | Purpose |
|---|---|
@Component | Generic Spring-managed bean |
@Service | Business logic layer |
@Repository | Data access layer (also translates DB exceptions) |
@Controller / @RestController | Web layer |
@Configuration | Java-based bean configuration |
Constructor Injection (Recommended)
Always prefer constructor injection — it makes dependencies explicit and enables immutability:
@Service
@RequiredArgsConstructor // Lombok generates constructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
public UserResponse createUser(CreateUserRequest request) {
// business logic
}
}Expert Tip: Avoid field injection (@Autowired on fields). It hides dependencies, makes testing harder, and violates clean code principles.
Bean Scopes
@Bean
@Scope("singleton") // Default — one instance per container
@Scope("prototype") // New instance per injection point
@Scope("request") // One per HTTP request (web apps)
@Scope("session") // One per HTTP session
public SomeBean someBean() { ... }4. Building RESTful APIs
Controller Layer
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
return ResponseEntity.ok(userService.findAll(pageable));
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable UUID id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserResponse response = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.id())
.toUri();
return ResponseEntity.created(location).body(response);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable UUID id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable UUID id) {
userService.delete(id);
}
}Using DTOs with Java Records
Modern Java (16+) records are perfect for immutable DTOs:
// Request DTO
public record CreateUserRequest(
@NotBlank @Size(min = 3, max = 50) String username,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@NotNull Role role
) {}
// Response DTO
public record UserResponse(
UUID id,
String username,
String email,
Role role,
LocalDateTime createdAt
) {}API Versioning Strategy
// URL versioning (most common)
@RequestMapping("/api/v1/users")
@RequestMapping("/api/v2/users")
// Header versioning
@GetMapping(headers = "X-API-VERSION=2")
// Content-type versioning
@GetMapping(produces = "application/vnd.app.v2+json")5. Data Persistence with Spring Data JPA
Entity Design
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_users_email", columnList = "email"),
@Index(name = "idx_users_username", columnList = "username")
})
@Getter @Setter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(unique = true, nullable = false, length = 50)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}Repository Layer
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
boolean existsByEmail(String email);
// JPQL
@Query("SELECT u FROM User u WHERE u.role = :role AND u.createdAt > :since")
List<User> findActiveUsersByRole(@Param("role") Role role,
@Param("since") LocalDateTime since);
// Native query for complex operations
@Query(value = "SELECT * FROM users u WHERE u.email ILIKE %:keyword%",
nativeQuery = true)
Page<User> searchByEmail(@Param("keyword") String keyword, Pageable pageable);
// Projections for lightweight reads
@Query("SELECT u.id as id, u.email as email FROM User u WHERE u.role = :role")
List<UserSummary> findSummariesByRole(@Param("role") Role role);
}Service Layer with Transaction Management
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // Default all methods to read-only
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
public Page<UserResponse> findAll(Pageable pageable) {
return userRepository.findAll(pageable).map(userMapper::toResponse);
}
public UserResponse findById(UUID id) {
return userRepository.findById(id)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
}
@Transactional // Override to allow writes
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("Email already registered");
}
User user = userMapper.toEntity(request);
user.setPassword(passwordEncoder.encode(request.password()));
return userMapper.toResponse(userRepository.save(user));
}
@Transactional
public UserResponse update(UUID id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
userMapper.updateEntityFromRequest(request, user);
return userMapper.toResponse(userRepository.save(user));
}
@Transactional
public void delete(UUID id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
userRepository.delete(user);
}
}MapStruct for Mapping
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface UserMapper {
UserResponse toResponse(User user);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
User toEntity(CreateUserRequest request);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntityFromRequest(UpdateUserRequest request, @MappingTarget User user);
}6. Database Migrations with Flyway
Never change the database schema manually. Use Flyway for version-controlled, repeatable migrations.
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true-- V1__create_users_table.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'USER',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
-- V2__create_posts_table.sql
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published ON posts(published) WHERE published = TRUE;Naming convention: V{version}__{description}.sql — always increment version, never edit existing files.
7. Security with Spring Security & JWT
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(e -> e
.authenticationEntryPoint(new HttpStatusEntryPoint(UNAUTHORIZED))
.accessDeniedHandler(new AccessDeniedHandlerImpl())
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}JWT Token Provider
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration-ms}")
private long jwtExpirationMs;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
if (userDetails instanceof User user) {
claims.put("role", user.getRole().name());
claims.put("userId", user.getId().toString());
}
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
String username = jwtTokenProvider.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenProvider.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}Method-Level Security
@Service
public class PostService {
@PreAuthorize("hasRole('ADMIN') or @postSecurity.isOwner(#id, authentication)")
public void deletePost(UUID id) { ... }
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public PostResponse createPost(CreatePostRequest request) { ... }
@PostFilter("filterObject.published == true or hasRole('ADMIN')")
public List<PostResponse> getAllPosts() { ... }
}8. Exception Handling & Validation
Custom Exception Hierarchy
public class AppException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
public AppException(String message, HttpStatus status, String errorCode) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
}
public class ResourceNotFoundException extends AppException {
public ResourceNotFoundException(String resource, String field, Object value) {
super(String.format("%s not found with %s: '%s'", resource, field, value),
HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND");
}
}
public class DuplicateResourceException extends AppException {
public DuplicateResourceException(String message) {
super(message, HttpStatus.CONFLICT, "DUPLICATE_RESOURCE");
}
}Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(AppException.class)
public ResponseEntity<ErrorResponse> handleAppException(AppException ex,
HttpServletRequest request) {
log.warn("Application exception: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
ex.getStatus().value(),
ex.getErrorCode(),
ex.getMessage(),
request.getRequestURI(),
Instant.now()
);
return ResponseEntity.status(ex.getStatus()).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> fieldErrors.put(e.getField(), e.getDefaultMessage()));
ValidationErrorResponse error = new ValidationErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"VALIDATION_FAILED",
"Input validation failed",
request.getRequestURI(),
Instant.now(),
fieldErrors
);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex,
HttpServletRequest request) {
log.error("Unexpected error occurred", ex);
ErrorResponse error = new ErrorResponse(
500, "INTERNAL_ERROR", "An unexpected error occurred",
request.getRequestURI(), Instant.now()
);
return ResponseEntity.internalServerError().body(error);
}
}Standard Error Response Shape
public record ErrorResponse(
int status,
String errorCode,
String message,
String path,
Instant timestamp
) {}
public record ValidationErrorResponse(
int status,
String errorCode,
String message,
String path,
Instant timestamp,
Map<String, String> fieldErrors
) {}9. Caching Strategies
Enable Caching
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withCacheConfiguration("users", config.entryTtl(Duration.ofMinutes(5)))
.withCacheConfiguration("posts", config.entryTtl(Duration.ofHours(1)))
.build();
}
}Cache Annotations
@Service
public class UserService {
@Cacheable(value = "users", key = "#id", unless = "#result == null")
public UserResponse findById(UUID id) { ... }
@CachePut(value = "users", key = "#result.id()")
@Transactional
public UserResponse update(UUID id, UpdateUserRequest request) { ... }
@CacheEvict(value = "users", key = "#id")
@Transactional
public void delete(UUID id) { ... }
// Evict all user-related caches on bulk changes
@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCaches() {}
}Expert Tip: Only cache data that is read-heavy and expensive to compute. Always define TTLs and have a strategy for cache invalidation.
10. Asynchronous Processing
Enable Async and Configure Thread Pool
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}Async Service Methods
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
@Async("taskExecutor")
public CompletableFuture<Void> sendWelcomeEmail(String to, String username) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(to);
helper.setSubject("Welcome, " + username + "!");
helper.setText(buildWelcomeHtml(username), true);
mailSender.send(message);
return CompletableFuture.completedFuture(null);
} catch (MessagingException e) {
return CompletableFuture.failedFuture(e);
}
}
@Async("taskExecutor")
@EventListener
public void handleUserCreatedEvent(UserCreatedEvent event) {
sendWelcomeEmail(event.email(), event.username());
}
}Spring Events for Decoupling
// Define event
public record UserCreatedEvent(UUID userId, String email, String username) {}
// Publish event
@Service
public class UserService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public UserResponse create(CreateUserRequest request) {
User user = userRepository.save(newUser);
eventPublisher.publishEvent(new UserCreatedEvent(user.getId(), user.getEmail(), user.getUsername()));
return userMapper.toResponse(user);
}
}11. Testing: Unit, Integration & End-to-End
Unit Testing the Service Layer
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private UserMapper userMapper;
@Mock private PasswordEncoder passwordEncoder;
@InjectMocks private UserService userService;
@Test
@DisplayName("Should create user successfully when email is unique")
void createUser_Success() {
// Given
CreateUserRequest request = new CreateUserRequest("john", "[email protected]", "Pass@123", Role.USER);
User savedUser = new User(); // set fields
UserResponse expectedResponse = new UserResponse(UUID.randomUUID(), "john", "[email protected]", Role.USER, LocalDateTime.now());
when(userRepository.existsByEmail(request.email())).thenReturn(false);
when(userMapper.toEntity(request)).thenReturn(savedUser);
when(passwordEncoder.encode(request.password())).thenReturn("hashed");
when(userRepository.save(savedUser)).thenReturn(savedUser);
when(userMapper.toResponse(savedUser)).thenReturn(expectedResponse);
// When
UserResponse result = userService.create(request);
// Then
assertThat(result).isEqualTo(expectedResponse);
verify(userRepository).save(savedUser);
verify(passwordEncoder).encode(request.password());
}
@Test
@DisplayName("Should throw DuplicateResourceException when email already exists")
void createUser_DuplicateEmail_ThrowsException() {
CreateUserRequest request = new CreateUserRequest("john", "[email protected]", "Pass@123", Role.USER);
when(userRepository.existsByEmail(request.email())).thenReturn(true);
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(DuplicateResourceException.class)
.hasMessageContaining("Email already registered");
verify(userRepository, never()).save(any());
}
}Integration Testing with Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class UserControllerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private TestRestTemplate restTemplate;
@Autowired private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void createUser_Returns201WithLocation() {
CreateUserRequest request = new CreateUserRequest("alice", "[email protected]", "SecureP@ss1", Role.USER);
ResponseEntity<UserResponse> response = restTemplate.postForEntity(
"/api/v1/users", request, UserResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getHeaders().getLocation()).isNotNull();
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().email()).isEqualTo("[email protected]");
}
}Slice Testing with @WebMvcTest
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private UserService userService;
@Test
@WithMockUser(roles = "ADMIN")
void getUser_ReturnsUser() throws Exception {
UUID id = UUID.randomUUID();
UserResponse user = new UserResponse(id, "alice", "[email protected]", Role.USER, LocalDateTime.now());
when(userService.findById(id)).thenReturn(user);
mockMvc.perform(get("/api/v1/users/{id}", id)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("alice"))
.andExpect(jsonPath("$.email").value("[email protected]"));
}
}12. Observability: Logging, Metrics & Tracing
Structured Logging with Logback + JSON
<!-- logback-spring.xml -->
<configuration>
<springProfile name="prod">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</springProfile>
</configuration>Spring Boot Actuator + Micrometer
# application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active}Custom Metrics
@Service
@RequiredArgsConstructor
public class UserService {
private final MeterRegistry meterRegistry;
private final Counter userCreationCounter;
@PostConstruct
void initMetrics() {
Counter.builder("users.created.total")
.description("Total users created")
.register(meterRegistry);
}
@Transactional
public UserResponse create(CreateUserRequest request) {
UserResponse response = // ... create user
meterRegistry.counter("users.created.total").increment();
return response;
}
}Distributed Tracing with Micrometer Tracing
@RestController
@RequiredArgsConstructor
public class UserController {
private final Tracer tracer;
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable UUID id) {
Span span = tracer.nextSpan().name("get-user-by-id").start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
span.tag("user.id", id.toString());
return ResponseEntity.ok(userService.findById(id));
} finally {
span.end();
}
}
}13. Dockerizing & Deploying Spring Boot
Multi-Stage Dockerfile
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests -q
# Stage 2: Extract layers
FROM eclipse-temurin:21-jdk-alpine AS layers
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Stage 3: Runtime
FROM eclipse-temurin:21-jre-alpine AS runtime
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
COPY --from=layers /app/dependencies ./
COPY --from=layers /app/spring-boot-loader ./
COPY --from=layers /app/snapshot-dependencies ./
COPY --from=layers /app/application ./
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.launch.JarLauncher"]Docker Compose for Local Development
version: '3.9'
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: apppassword
SPRING_DATA_REDIS_HOST: redis
APP_JWT_SECRET: ${JWT_SECRET}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppassword
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:14. Best Practices & Production Checklist
Configuration Management
# Never hardcode secrets! Use environment variables or a secrets manager
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
data:
redis:
password: ${REDIS_PASSWORD}
app:
jwt:
secret: ${JWT_SECRET} # Min 256-bit for HS256
expiration-ms: 86400000 # 24 hours
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:3000}JVM Tuning for Containers
# Recommended JVM flags for containerized Spring Boot apps
-XX:+UseContainerSupport # Respect container memory limits
-XX:MaxRAMPercentage=75.0 # Use 75% of container RAM for heap
-XX:+UseG1GC # G1 GC for most workloads
-XX:+ExitOnOutOfMemoryError # Fast fail, let orchestrator restart
-Xss512k # Reduce thread stack sizeProduction Readiness Checklist
✅ Security
□ All secrets in environment variables or secrets manager
□ HTTPS enforced (TLS termination at LB or in-app)
□ CORS configured for production domains only
□ Rate limiting implemented (Bucket4j / API Gateway)
□ Input validation on all endpoints
□ SQL injection prevention (parameterized queries via JPA)
✅ Performance
□ Database connection pooling configured (HikariCP)
□ N+1 queries eliminated (use @EntityGraph or JOIN FETCH)
□ Indexes on frequently queried columns
□ Caching for read-heavy endpoints
□ Pagination for list endpoints
□ Async for long-running tasks (email, notifications)
✅ Reliability
□ Health checks configured (/actuator/health)
□ Graceful shutdown enabled
□ Database migration strategy (Flyway)
□ Circuit breakers for external calls (Resilience4j)
□ Retry logic for transient failures
✅ Observability
□ Structured JSON logging in production
□ Metrics exposed to Prometheus/Grafana
□ Distributed tracing enabled
□ Error tracking (Sentry, Datadog)
□ Alerting configured
✅ Testing
□ Unit test coverage > 80%
□ Integration tests with Testcontainers
□ Security tests (authentication, authorization)
□ Load tests before major releasesHikariCP (Connection Pool) Configuration
spring:
datasource:
hikari:
pool-name: HikariPool-Main
maximum-pool-size: 20 # CPU cores * 2 + disk spindles
minimum-idle: 5
idle-timeout: 600000 # 10 minutes
connection-timeout: 20000 # 20 seconds
max-lifetime: 1800000 # 30 minutes
leak-detection-threshold: 60000 # Warn if connection held > 60sConclusion
Building production-grade backends with Spring Boot is a journey from understanding the IoC container, to designing clean layered architectures, securing APIs with JWT, persisting data correctly with JPA, ensuring reliability through testing, and shipping with confidence through Docker and observability tooling.
The patterns covered in this guide aren't just theory — they're the battle-tested practices used in real-world systems serving millions of users. Start by applying one section at a time to your existing project, or use the structure here as the blueprint for your next one.
Happy building. Ship with confidence. 🚀
Published on YourSite.com · Java 21 · Spring Boot 3.x · Last updated: February 2026
Found this helpful? Share it with your team, bookmark it as a reference, and follow for more expert backend development content.