Skip to main content

Spring Boot Framework Guidelines

Comprehensive guidelines for building production-ready backend services with Spring Boot 3.x and Java 25.

Overview

Spring Boot is the primary backend framework for building RESTful APIs and microservices. These guidelines cover the entire development lifecycle from project structure to production deployment, emphasizing modern Java features, testability, and operational excellence.

Target Audience: Backend developers building Spring Boot applications using Java 21-25 with Spring Boot 3.x.

Key Technologies:

  • Spring Boot: 3.4+
  • Java: 21-25 (prefer 25)
  • Build Tool: Gradle (preferred over Maven)
  • Database: PostgreSQL with Spring Data JPA
  • Testing: JUnit 5, TestContainers, PITest
  • Security: Spring Security with JWT
  • Observability: Micrometer, Prometheus, OpenTelemetry

Architecture Principles

Spring Boot applications should follow these foundational principles:

1. Dependency Injection via Constructor

Always use constructor injection with final fields. This ensures immutability, simplifies testing, and makes dependencies explicit. Field injection (@Autowired on fields) creates mutable state and makes unit testing more difficult because you cannot easily inject mock dependencies without a Spring context.

// GOOD
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository repository;
private final AuditService auditService;

public Payment createPayment(PaymentRequest request) {
// All dependencies are final and injected via constructor
}
}

// BAD
@Service
public class PaymentService {
@Autowired
private PaymentRepository repository; // Mutable, harder to test
}

Why it matters: Constructor injection with final fields prevents null pointer exceptions, enables compile-time dependency checking, and makes testing straightforward by allowing simple constructor calls with mock objects.

The diagram below illustrates how Spring Boot's dependency injection container manages bean lifecycle and wiring. At application startup, Spring scans for components, creates bean definitions, resolves dependencies, and injects them via constructors. The container maintains singleton instances by default, ensuring a single shared instance of each service throughout the application.

This auto-configuration and dependency resolution is what makes Spring Boot "opinionated." When you add a database driver dependency, Spring Boot automatically configures a DataSource, JPA EntityManager, and transaction manager without explicit XML or Java configuration. The container handles the complex initialization order - DataSource must exist before repositories, repositories before services - freeing developers to focus on business logic rather than wiring infrastructure.

2. Virtual Threads over Reactive Programming

With Java 25, use virtual threads instead of reactive programming (WebFlux) for most service workloads. Virtual threads provide the scalability benefits of reactive programming while maintaining imperative, easy-to-read code with traditional debugging and stack traces.

# application.yml
spring:
threads:
virtual:
enabled: true

Why it matters: Reactive programming (WebFlux) introduces significant complexity with Mono/Flux operators, makes debugging harder with fragmented stack traces, and has a steep learning curve. Virtual threads achieve similar scalability with simpler imperative code that the entire team can understand and maintain.

3. Feature-Based Package Structure

Organize code by business feature (domain) rather than technical layer. This creates cohesive modules that are easier to understand and potentially extract into separate services.

com.bank.payments/
├── payment/
│ ├── Payment.java # Entity
│ ├── PaymentController.java # REST API
│ ├── PaymentService.java # Business logic
│ ├── PaymentRepository.java # Data access
│ └── PaymentMapper.java # DTO mapping
├── customer/
│ └── ...
└── account/
└── ...

Why it matters: Feature-based packaging reduces coupling between features, makes the codebase easier to navigate by business domain, and simplifies extracting features into microservices when needed. Team members can quickly locate all code related to a specific feature in one place.

4. OpenAPI-First API Design

Define API contracts using OpenAPI 3.x specifications before implementation. Use Springdoc to automatically generate and validate these specifications.

Why it matters: API-first development creates a clear contract between frontend and backend teams, enables parallel development, and ensures API documentation is always accurate. The spec becomes the single source of truth for API behavior.

5. Testing Honeycomb Model

Prioritize integration tests (50-60%) over unit tests (25-35%). Use TestContainers with real databases (PostgreSQL) instead of in-memory databases (H2) to ensure tests reflect production behavior.

Why it matters: Integration tests catch real issues like SQL dialect differences, transaction behavior, and actual database constraints that unit tests with mocks miss. The Testing Honeycomb model acknowledges that integration tests provide better confidence in production readiness.


Topic Areas

This Spring Boot section covers the following areas in detail:

General Guidelines

Core Spring Boot patterns including dependency injection, modern Java features (records, pattern matching), configuration management with @ConfigurationProperties, async processing with virtual threads, and MapStruct for DTO mapping. Covers project structure, REST controller design, and entity best practices.

Testing

Comprehensive testing strategies following the Testing Honeycomb model. Covers unit testing with JUnit 5 and Mockito, integration testing with TestContainers and real PostgreSQL databases, MockMvc for REST API testing, WireMock for external service mocking, mutation testing with PITest, and contract testing with Pact and OpenAPI validation.

API Design

RESTful API design patterns with OpenAPI/Springdoc integration, Jakarta Bean Validation for request validation, RFC 7807 Problem Details for standardized error responses, pagination strategies, API versioning approaches, rate limiting with Bucket4j, and content negotiation.

Data Access

Spring Data JPA patterns for database access including repository design, query optimization techniques (avoiding N+1 queries, projections, batch fetching), transaction management with explicit boundaries, Flyway for database migrations, optimistic locking for concurrent updates, and JPA auditing for entity change tracking.

Security

Spring Security configuration for JWT-based stateless authentication, role-based access control (RBAC), method-level security with @PreAuthorize, CORS and CSRF protection, password encoding with BCrypt, security audit logging, and sensitive data handling patterns.

Observability

Production observability with structured JSON logging using Logback, correlation IDs for request tracking across services, custom metrics with Micrometer and Prometheus, distributed tracing with OpenTelemetry, health checks for dependencies, and Prometheus alerting rules.

Resilience

Fault tolerance patterns using Resilience4j including circuit breakers for failing services, retry policies with exponential backoff, rate limiting to protect downstream services, bulkheads for failure isolation, timeouts to prevent indefinite waits, graceful degradation strategies, and fallback mechanisms.


Quick Start

For a new Spring Boot project, follow this checklist:

  1. Initialize Project

    • Use Spring Initializr or Gradle init
    • Select Spring Boot 3.4+, Java 25, Gradle
    • Add dependencies: Web, Data JPA, PostgreSQL, Security, Actuator, Validation
  2. Configure Core Settings

    • Enable virtual threads in application.yml
    • Set up PostgreSQL datasource with HikariCP connection pool
    • Configure Flyway for database migrations
    • Add Springdoc for OpenAPI documentation
  3. Establish Project Structure

    • Create feature-based package structure
    • Set up base integration test class with TestContainers
    • Configure security with JWT authentication
    • Add global exception handler with RFC 7807 Problem Details
  4. Implement Observability

    • Configure structured logging with Logstash encoder
    • Add correlation ID filter
    • Set up Micrometer metrics with Prometheus endpoint
    • Configure health checks for database and external services
  5. Testing Setup

    • Configure PITest for mutation testing (80% threshold)
    • Set up TestContainers base class
    • Add contract tests with OpenAPI validation
    • Configure test coverage reporting

See individual topic pages for detailed implementation guidance.


Common Patterns

Service Layer Transaction Boundaries

Always define explicit transaction boundaries at the service layer, defaulting to readOnly=true for read operations:

@Service
@Transactional(readOnly = true) // Default for all methods
@RequiredArgsConstructor
public class PaymentService {

private final PaymentRepository repository;

// Inherits readOnly=true
public Payment getPayment(String id) {
return repository.findById(id)
.orElseThrow(() -> new PaymentNotFoundException(id));
}

@Transactional // Overrides to readOnly=false for writes
public Payment createPayment(PaymentRequest request) {
var payment = buildPayment(request);
return repository.save(payment);
}
}

Why it matters: Read-only transactions enable database optimizations and prevent accidental data modification. Explicit service-level transactions ensure proper rollback on exceptions and maintain data consistency across multiple repository calls.

DTO Mapping with Records and MapStruct

Use Java records for immutable DTOs and MapStruct for type-safe mapping between entities and DTOs:

// Request DTO
public record PaymentRequest(
@NotBlank String customerId,
@NotNull @DecimalMin("0.01") BigDecimal amount,
@Size(min = 3, max = 3) String currency
) {}

// Response DTO
public record PaymentResponse(
String paymentId,
String customerId,
BigDecimal amount,
String currency,
PaymentStatus status,
Instant createdAt
) {}

// MapStruct mapper
@Mapper(componentModel = "spring")
public interface PaymentMapper {
PaymentResponse toResponse(Payment entity);
Payment toEntity(PaymentRequest request);
}

Why it matters: Records provide immutability and reduce boilerplate (no getters/setters/equals/hashCode). MapStruct generates compile-time mapping code that's fast and type-safe, catching mapping errors at compile time rather than runtime.

Exception Handling with Problem Details

Use Spring 6's ProblemDetail (RFC 7807) for standardized error responses:

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(PaymentNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(PaymentNotFoundException ex) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.getMessage()
);
problem.setTitle("Payment Not Found");
problem.setType(URI.create("https://docs.bank.com/errors/payment-not-found"));
problem.setProperty("paymentId", ex.getPaymentId());
return problem;
}
}

Why it matters: RFC 7807 provides a standardized, machine-readable error format that clients can reliably parse. Including a type URI allows clients to link to documentation, and custom properties provide additional context for debugging.

Query Optimization with JOIN FETCH

Avoid N+1 query problems by explicitly fetching related entities:

@Repository
public interface PaymentRepository extends JpaRepository<Payment, String> {

// BAD: Causes N+1 queries when accessing payment.customer
List<Payment> findByStatus(PaymentStatus status);

// GOOD: Fetches payments and customers in one query
@Query("""
SELECT p FROM Payment p
JOIN FETCH p.customer
WHERE p.status = :status
""")
List<Payment> findByStatusWithCustomer(@Param("status") PaymentStatus status);

// GOOD: Alternative using @EntityGraph
@EntityGraph(attributePaths = {"customer"})
List<Payment> findByStatusEager(PaymentStatus status);
}

Why it matters: N+1 queries cause severe performance issues. Loading 100 payments without JOIN FETCH triggers 101 database queries (1 for payments + 100 for customers). With JOIN FETCH, this becomes a single query.


Technology Decisions

Why Spring Boot Over Alternatives?

Spring Boot is chosen for:

  • Ecosystem maturity: Comprehensive libraries for security, data access, messaging, caching
  • Convention over configuration: Sensible defaults with auto-configuration
  • Production readiness: Built-in health checks, metrics, and externalized configuration
  • Team familiarity: Widely adopted with extensive community support and documentation
  • Enterprise support: Long-term support releases and commercial backing

Why Virtual Threads Over WebFlux?

Virtual threads (Java 25) are preferred because they provide comparable scalability to WebFlux while maintaining:

  • Simpler code: Imperative programming model everyone understands
  • Better debugging: Traditional stack traces and breakpoints work normally
  • Lower learning curve: No need to learn reactive operators (map, flatMap, zip, etc.)
  • Easier integration: Works with blocking JDBC, traditional libraries

WebFlux should only be considered for streaming use cases (SSE, WebSockets) or when you have proven scalability requirements that virtual threads cannot meet.

Why PostgreSQL Over Other Databases?

PostgreSQL is the standard relational database because:

  • ACID compliance: Strong transactional guarantees
  • Rich data types: JSON, arrays, UUIDs, geometric types
  • Advanced features: CTEs, window functions, full-text search
  • Performance: Efficient indexing, query optimization, parallel queries
  • Open source: No licensing costs, active development

Anti-Patterns to Avoid

1. Using Field Injection

Field injection with @Autowired creates mutable state and complicates testing. Always use constructor injection.

2. Exposing Entities in REST APIs

Never return JPA entities directly from controllers. Entities contain implementation details (lazy loading proxies, bidirectional relationships) that cause serialization issues. Always use DTOs.

3. Using H2 for Integration Tests

H2 has SQL dialect differences from PostgreSQL that cause tests to pass in test environments but fail in production. Always use TestContainers with real PostgreSQL.

4. Transactions at Repository Layer

Never put @Transactional on repositories. Transaction boundaries belong at the service layer where you control business logic flow and can ensure multiple repository calls are atomic.

5. Ignoring Open-in-View

The Open Session in View pattern (enabled by default in Spring Boot) keeps the Hibernate session open during view rendering, causing lazy loading exceptions and performance issues. Always disable it:

spring:
jpa:
open-in-view: false

6. Using @Value for Structured Configuration

For complex configuration, use @ConfigurationProperties with record or POJO classes instead of individual @Value annotations. This provides type safety, validation, and better organization.


Further Reading

External Resources:


Summary

Essential Principles:

  1. Constructor injection only: Use final fields with @RequiredArgsConstructor
  2. Virtual threads: Enable for Java 25, avoid WebFlux complexity
  3. Feature-based packages: Organize by domain, not by layer
  4. OpenAPI first: Define contracts before implementation
  5. TestContainers: Use real PostgreSQL in integration tests
  6. Records for DTOs: Immutable request/response objects
  7. Transaction boundaries: Explicit @Transactional at service layer
  8. Never expose entities: Always use DTOs in REST APIs
  9. Structured logging: JSON format with correlation IDs
  10. Resilience patterns: Circuit breakers, retries, timeouts for fault tolerance

Spring Boot provides a robust foundation for building scalable, maintainable backend services. Follow these guidelines to ensure your applications are production-ready, testable, and aligned with modern Java best practices.