Spring Boot General Guidelines
Modern Spring Boot best practices for building production-ready backend services using Java 25 and Spring Boot 3.x.
Overview
These guidelines cover Spring Boot application architecture, dependency injection, configuration management, and integration with modern Java features like virtual threads and records.
These guidelines assume Spring Boot 3.4+ with Java 25. Leverage modern Java features (records, sealed classes, pattern matching, virtual threads) for cleaner, more maintainable code.
Core Principles
- Constructor injection: Always prefer constructor injection over field injection
- Virtual threads: Use virtual threads instead of reactive programming (WebFlux)
- Immutability: Leverage Java records and immutable objects
- Configuration classes: Use
@ConfigurationPropertiesinstead of@Value - Feature-based packaging: Group by feature, not by layer
Project Structure
Feature-Based Package Organization
Group classes by feature (domain context), not by technical layer.
src/main/java/com/bank/payments/
│
├── PaymentsApplication.java # Application entry point
│
├── config/ # Cross-cutting configuration
│ ├── SecurityConfig.java
│ ├── OpenApiConfig.java
│ └── ObservabilityConfig.java
│
├── payment/ # Payment feature
│ ├── Payment.java # Domain entity
│ ├── PaymentStatus.java # Enum
│ ├── PaymentController.java # REST API
│ ├── PaymentService.java # Business logic
│ ├── PaymentRepository.java # Data access
│ ├── PaymentRequest.java # DTO (record)
│ ├── PaymentResponse.java # DTO (record)
│ └── PaymentMapper.java # MapStruct mapper
│
├── customer/ # Customer feature
│ ├── Customer.java
│ ├── CustomerController.java
│ ├── CustomerService.java
│ └── CustomerRepository.java
│
├── account/ # Account feature
│ └── ...
│
└── common/ # Shared utilities
├── exception/
│ ├── GlobalExceptionHandler.java
│ ├── ResourceNotFoundException.java
│ └── ValidationException.java
└── util/
└── DateUtils.java
Why Feature-Based Over Layer-Based?
Traditional layered architecture groups code by technical function (all controllers together, all services together, all repositories together). This creates several problems:
- High coupling across features: Changes to a single feature require touching files in multiple packages
- Difficult navigation: Finding all code for a feature requires searching through multiple directories
- Harder to extract: Converting a feature to a microservice requires gathering files from many locations
- Team cognitive load: Developers must hold the entire codebase structure in their head
Feature-based packaging inverts this by grouping related code together. Each feature package contains everything needed for that business capability - from the API controller down to the database repository. This aligns the code structure with how you think about the business domain.
Benefits:
- Self-contained features: All code for a feature lives in one place
- Reduced coupling: Features interact through well-defined interfaces
- Easier microservice extraction: Each feature is already isolated
- Domain-driven navigation: New team members understand the business by exploring feature packages
- Parallel development: Teams can work on different features with minimal conflicts
Dependency Injection
Constructor Injection (Required)
Always use constructor injection with final fields.
// GOOD: Constructor injection with final fields
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final AuditService auditService;
private final PaymentGateway paymentGateway;
public PaymentService(
PaymentRepository paymentRepository,
AuditService auditService,
PaymentGateway paymentGateway) {
this.paymentRepository = paymentRepository;
this.auditService = auditService;
this.paymentGateway = paymentGateway;
}
public Payment processPayment(PaymentRequest request) {
// Business logic
}
}
// BAD: Field injection (harder to test, mutable)
@Service
public class PaymentService {
@Autowired
private PaymentRepository paymentRepository; // Not final, mutable
@Autowired
private AuditService auditService;
}
Why Constructor Injection?
- Immutability: Fields can be
final, preventing modification - Testability: Easy to mock dependencies in tests without Spring context
- Null safety: Dependencies required at construction time
- Explicitness: Clear what dependencies a class needs
Lombok @RequiredArgsConstructor
Use Lombok to reduce boilerplate:
@Service
@RequiredArgsConstructor // Generates constructor for all final fields
public class PaymentService {
private final PaymentRepository paymentRepository;
private final AuditService auditService;
private final PaymentGateway paymentGateway;
public Payment processPayment(PaymentRequest request) {
// Business logic
}
}
Virtual Threads vs Reactive (WebFlux)
Request Flow Architecture
Key Insight: Virtual threads allow you to write traditional blocking code without the scalability penalty. When a virtual thread blocks on I/O (database query, HTTP call), the platform thread is released to handle other requests. When the I/O completes, the virtual thread resumes on any available platform thread.
Use Virtual Threads (Recommended)
With Java 25, use virtual threads instead of reactive programming (WebFlux).
// GOOD: Enable virtual threads in Spring Boot 3.4+
@SpringBootApplication
public class PaymentsApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentsApplication.class, args);
}
}
application.yml:
spring:
threads:
virtual:
enabled: true # Enable virtual threads
Why Virtual Threads Over WebFlux?
Virtual threads fundamentally change the economics of thread-per-request models:
- Simpler code: Imperative style that reads top-to-bottom. No need for reactive operators like
flatMap,zip,switchIfEmpty - Better stack traces: Full, readable stack traces from entry point to error. No reactive chain fragmentation
- No learning curve: Standard Java programming model. New team members productive immediately
- Better IDE support: Debugging, breakpoints, and step-through work as expected
- Blocking is cheap: Virtual threads are lightweight (kilobytes vs megabytes). Create millions without performance penalty
- Simplified error handling: Use traditional try-catch blocks instead of reactive error handling operators
- Thread-local context: MDC (logging context), security context, and correlation IDs work naturally
WebFlux was necessary when platform threads were the only option and blocking them was expensive. Virtual threads eliminate this constraint while keeping code simple.
// GOOD: Imperative code with virtual threads
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentGateway gateway;
private final AuditService auditService;
public PaymentResult processPayment(PaymentRequest request) {
// Blocking calls are fine with virtual threads
var validation = gateway.validatePayment(request); // Blocks
var result = gateway.processPayment(request); // Blocks
auditService.logPayment(result); // Blocks
return result;
}
}
// BAD: WebFlux reactive complexity
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentGateway gateway;
private final AuditService auditService;
public Mono<PaymentResult> processPayment(PaymentRequest request) {
return gateway.validatePayment(request)
.flatMap(validation -> gateway.processPayment(request))
.flatMap(result -> auditService.logPayment(result)
.thenReturn(result))
.onErrorResume(error -> Mono.error(new PaymentException(error)));
}
}
Only use WebFlux if:
- You have proven performance requirements that virtual threads can't meet
- You're building a streaming API (Server-Sent Events, WebSockets)
- Your entire team is experienced with reactive programming
For 99% of use cases, virtual threads are simpler and sufficient.
Modern Java Features
Records for DTOs
Use Java records for immutable DTOs:
// GOOD: Records for DTOs
public record PaymentRequest(
String customerId,
@NotNull BigDecimal amount,
@NotBlank String currency,
String description
) {}
public record PaymentResponse(
String paymentId,
PaymentStatus status,
BigDecimal amount,
String currency,
Instant createdAt
) {}
// BAD: Mutable DTO class with boilerplate
public class PaymentRequest {
private String customerId;
private BigDecimal amount;
private String currency;
private String description;
// Getters, setters, equals, hashCode, toString...
}
Pattern Matching with Sealed Classes
Use sealed classes and pattern matching for type-safe state machines:
public sealed interface PaymentResult
permits PaymentSuccess, PaymentFailure, PaymentPending {}
public record PaymentSuccess(String transactionId, BigDecimal amount)
implements PaymentResult {}
public record PaymentFailure(String errorCode, String errorMessage)
implements PaymentResult {}
public record PaymentPending(String pendingId, Instant retryAt)
implements PaymentResult {}
// Pattern matching with switch expression
public String formatResult(PaymentResult result) {
return switch (result) {
case PaymentSuccess(var txId, var amount) ->
"Payment successful: " + txId + " for " + amount;
case PaymentFailure(var code, var msg) ->
"Payment failed: " + code + " - " + msg;
case PaymentPending(var id, var retryAt) ->
"Payment pending: " + id + ", retry at " + retryAt;
};
}
Optional for Nullable Values
Use Optional for nullable return values:
// GOOD: Optional for nullable repository results
public interface PaymentRepository extends JpaRepository<Payment, String> {
Optional<Payment> findByTransactionId(String transactionId);
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository repository;
public Payment getPayment(String transactionId) {
return repository.findByTransactionId(transactionId)
.orElseThrow(() -> new PaymentNotFoundException(transactionId));
}
}
Configuration Management
@ConfigurationProperties (Preferred)
Use @ConfigurationProperties for structured configuration instead of scattered @Value annotations.
Why @ConfigurationProperties over @Value?
@Value annotations scattered throughout service classes create several problems:
- No type safety: Property values are strings that may fail at runtime with ClassCastException
- No validation: Invalid values (negative timeouts, malformed URLs) aren't caught until the code runs
- Difficult to test: Must provide all properties in test configuration or use
@TestPropertySource - No IDE support: No autocomplete for property names, easy to introduce typos
- Configuration scattered: Related properties spread across multiple classes
@ConfigurationProperties solves all of these by grouping related configuration into a single, validated, type-safe object that can be injected as a dependency.
Use @ConfigurationProperties for structured configuration:
// GOOD: Type-safe configuration with validation
@ConfigurationProperties(prefix = "payment.gateway")
public record PaymentGatewayConfig(
@NotBlank String baseUrl,
@NotBlank String apiKey,
@Min(1000) @Max(30000) int timeout,
@Min(1) @Max(5) int retryAttempts,
boolean enabled
) {}
// Enable configuration properties
@SpringBootApplication
@EnableConfigurationProperties(PaymentGatewayConfig.class)
public class PaymentsApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentsApplication.class, args);
}
}
// Use in service
@Service
@RequiredArgsConstructor
public class PaymentGatewayClient {
private final PaymentGatewayConfig config;
private final RestClient restClient;
public PaymentResult processPayment(PaymentRequest request) {
return restClient.post()
.uri(config.baseUrl() + "/payments")
.header("X-API-Key", config.apiKey())
.body(request)
.retrieve()
.body(PaymentResult.class);
}
}
application.yml:
payment:
gateway:
base-url: https://gateway.payments.bank.com
api-key: ${PAYMENT_GATEWAY_API_KEY}
timeout: 5000
retry-attempts: 3
enabled: true
// BAD: @Value for complex configuration
@Service
public class PaymentGatewayClient {
@Value("${payment.gateway.base-url}")
private String baseUrl;
@Value("${payment.gateway.api-key}")
private String apiKey;
@Value("${payment.gateway.timeout}")
private int timeout;
// No validation, no type safety, harder to test
}
Profile-Based Configuration
Use Spring profiles for environment-specific configuration:
# application.yml (common configuration)
spring:
application:
name: payment-service
threads:
virtual:
enabled: true
payment:
gateway:
timeout: 5000
retry-attempts: 3
# application-dev.yml
payment:
gateway:
base-url: https://dev-gateway.payments.bank.com
enabled: true
logging:
level:
com.bank.payments: DEBUG
# application-prod.yml
payment:
gateway:
base-url: https://gateway.payments.bank.com
enabled: true
logging:
level:
com.bank.payments: INFO
REST Controllers
Best Practices
// GOOD: Well-structured REST controller
@RestController
@RequestMapping("/api/v1/payments")
@RequiredArgsConstructor
@Validated
public class PaymentController {
private final PaymentService paymentService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PaymentResponse createPayment(
@Valid @RequestBody PaymentRequest request) {
return paymentService.createPayment(request);
}
@GetMapping("/{id}")
public PaymentResponse getPayment(@PathVariable String id) {
return paymentService.getPayment(id);
}
@GetMapping
public Page<PaymentResponse> listPayments(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return paymentService.listPayments(PageRequest.of(page, size));
}
}
Key Points:
- Use
@RestController(not@Controller) - Use
@RequestMappingfor base path - Use
@Validfor request validation - Return DTOs (records), never entities
- Use appropriate HTTP status codes
Global Exception Handling
Use @RestControllerAdvice for centralized error handling:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(PaymentNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handlePaymentNotFound(PaymentNotFoundException ex) {
log.warn("Payment not found: {}", ex.getMessage());
return ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.getMessage()
);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ProblemDetail handleValidationError(
MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.toList();
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Validation failed"
);
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ProblemDetail handleGenericError(Exception ex) {
log.error("Unexpected error", ex);
// Don't expose internal details in production
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred"
);
}
}
DTO Mapping with MapStruct
Use MapStruct for type-safe DTO mapping:
Gradle:
dependencies {
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}
Mapper Interface:
@Mapper(componentModel = "spring")
public interface PaymentMapper {
PaymentResponse toResponse(Payment payment);
List<PaymentResponse> toResponseList(List<Payment> payments);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
Payment toEntity(PaymentRequest request);
}
Usage:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository repository;
private final PaymentMapper mapper;
public PaymentResponse createPayment(PaymentRequest request) {
var payment = mapper.toEntity(request);
payment = repository.save(payment);
return mapper.toResponse(payment);
}
}
Entity Best Practices
JPA Entities
@Entity
@Table(name = "payments")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // For JPA
@AllArgsConstructor
@Getter
@Setter
public class Payment {
@Id
private String id;
@Column(nullable = false)
private String customerId;
@Column(nullable = false, precision = 19, scale = 4)
private BigDecimal amount;
@Column(nullable = false, length = 3)
private String currency;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentStatus status;
private String description;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
if (id == null) {
id = UUID.randomUUID().toString();
}
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}
Key Points:
- Use
@NoArgsConstructor(access = AccessLevel.PROTECTED)for JPA - Use
@PrePersistand@PreUpdatefor timestamps - Use
@Enumerated(EnumType.STRING)for enums (not ORDINAL) - Specify
precisionandscaleforBigDecimal - Never expose entities directly in REST APIs
Async Processing
@Async with Virtual Threads
For background tasks, use @Async:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// Use virtual thread executor
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
@Async
public CompletableFuture<Void> sendPaymentNotification(Payment payment) {
// Runs asynchronously on virtual thread
log.info("Sending notification for payment: {}", payment.getId());
// Send email/SMS/push notification
return CompletableFuture.completedFuture(null);
}
}
Logging
Structured Logging with SLF4J
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {
private final PaymentRepository repository;
public PaymentResponse createPayment(PaymentRequest request) {
log.info("Creating payment for customer: {}, amount: {}",
request.customerId(), request.amount());
try {
var payment = processPayment(request);
log.info("Payment created successfully: {}", payment.getId());
return mapper.toResponse(payment);
} catch (Exception ex) {
log.error("Failed to create payment for customer: {}",
request.customerId(), ex);
throw new PaymentException("Payment creation failed", ex);
}
}
}
Logging Levels:
ERROR: Errors that need immediate attentionWARN: Potentially harmful situationsINFO: Important business events (payment created, user logged in)DEBUG: Detailed diagnostic information (dev/test only)TRACE: Very detailed information (rarely used)
Never log:
- Credit card numbers
- CVV codes
- Passwords
- PII (personal identifiable information) unless masked
- API keys or secrets
Use masking for sensitive fields: log.info("Card: {}****{}", first4, last4)
Further Reading
Framework-Specific Guidelines:
- Spring Boot Testing - Comprehensive testing strategies with TestContainers
- Spring Boot API Design - REST API best practices and OpenAPI
- Spring Boot Data Access - JPA, Hibernate, and database patterns
- Spring Boot Security - Security practices and input validation
- Spring Boot Observability - Structured logging, metrics, and tracing
- Spring Boot Resilience - Circuit breakers, retries, and fault tolerance
Cross-Cutting Concerns:
- Java General Guidelines - Modern Java features and best practices
- API Design - RESTful API principles across frameworks
- Data Access Patterns - Database design and access patterns
- Observability Strategy - Observability pillars and practices
- Security Principles - Security fundamentals
- Dependency Management - Managing dependencies effectively
External Resources:
Summary
Key Takeaways:
- Constructor injection: Always use constructor injection with final fields
- Virtual threads: Prefer virtual threads over WebFlux for simpler code
- Records: Use Java records for immutable DTOs
- @ConfigurationProperties: Type-safe configuration over @Value
- Feature-based packaging: Group by feature, not by layer
- MapStruct: Type-safe DTO mapping
- Never expose entities: Always use DTOs in REST APIs
- Structured logging: Use SLF4J with meaningful log messages
- Global exception handling: Centralize error handling with @RestControllerAdvice
- Modern Java: Leverage Java 25 features (records, sealed classes, pattern matching)