Skip to main content

Java General Guidelines

Comprehensive Java best practices for building modern, maintainable applications using Java 25+ features.

Overview

These guidelines cover Java programming best practices, modern language features, and design principles for building production-ready applications. The focus is on writing maintainable, safe, and performant code using modern Java features introduced in recent LTS releases.

Java has evolved significantly since Java 8, with each release introducing features that reduce boilerplate, improve type safety, and enhance expressiveness. Java 21 (September 2023) and Java 25 (September 2025) are the current Long-Term Support releases, each supported for years with security updates and bug fixes. Using these modern versions provides access to productivity-enhancing features while maintaining enterprise-grade stability.

The modern Java approach emphasizes immutability over mutable state, functional programming alongside object-oriented design, and leveraging the type system to catch errors at compile time rather than runtime. These guidelines demonstrate how to apply these principles practically in production code.

Java Version

Always use Java 25+ to leverage modern features like virtual threads, records, sealed classes, and pattern matching. LTS releases receive extended support and security updates, making them ideal for production applications.


Core Principles

  • Immutability first: Prefer immutable objects and final fields
  • Modern Java: Use Java 25 features (records, sealed classes, pattern matching)
  • SOLID principles: Apply thoughtfully, not dogmatically
  • Fail fast: Detect errors early with validation
  • Explicit over implicit: Clear code over clever code
  • Composition over inheritance: Favor composition for flexibility

Java Version Requirements

Minimum Version

  • Required: Java 25 (LTS)
  • Recommended: Java 25 with latest patch

Key Java 25 Era Features to Use

These features represent significant improvements to Java's expressiveness and developer productivity:

  • Virtual Threads (JEP 444): Lightweight threads that enable writing high-throughput concurrent code in a simple, synchronous style. Unlike platform threads which map 1:1 to OS threads, virtual threads are scheduled by the JVM, allowing millions to run concurrently. This eliminates the need for complex async/reactive programming for I/O-bound operations. See Java Concurrency for detailed usage patterns.

  • Records (JEP 395): Compact syntax for immutable data carriers that automatically generates constructors, accessors, equals(), hashCode(), and toString(). Records communicate intent clearly - they exist to transparently carry data, not to encapsulate behavior. The compiler enforces immutability, preventing entire categories of bugs related to unexpected state changes.

  • Pattern Matching (JEP 441): Extends instanceof checks and switch expressions to destructure objects inline, eliminating explicit casts and reducing boilerplate. Pattern matching makes code more readable by expressing intent directly - you're not just checking types, you're extracting data while checking.

  • Sealed Classes (JEP 409): Restrict which classes can extend or implement a type, enabling the compiler to verify exhaustiveness in switch expressions. This provides algebraic data types similar to those in functional languages, allowing you to model closed type hierarchies where you control all possible subtypes.

  • Text Blocks (JEP 378): Multi-line string literals that preserve formatting, making SQL queries, JSON templates, and HTML snippets readable without concatenation or escape sequences.

  • Sequenced Collections (JEP 431): Adds methods for accessing first/last elements and reversing collection order to List, Set, and Map interfaces. This standardizes operations that previously required different approaches for different collection types.

Stable vs Preview Features

Use stable language/runtime features by default in production code. Preview features are useful for experimentation but increase upgrade risk because APIs and behavior can change between JDK releases.

  • Stable-first rule: production services should compile and run without --enable-preview unless a design decision explicitly approves otherwise.
  • If preview is used: isolate it behind internal adapters and avoid exposing preview-based types in public module/service APIs.
  • Upgrade hygiene: add explicit CI checks for compiler/runtime flags so preview usage cannot be introduced unintentionally.

Modern Java Features

Records for Immutable DTOs

Records provide a concise way to create immutable data carriers. Introduced in Java 16 (JEP 395), records automatically generate constructors, accessors, equals(), hashCode(), and toString() methods. The compact constructor syntax allows validation logic without explicitly declaring constructor parameters.

// GOOD: Use records for DTOs
public record PaymentRequest(
String customerId,
BigDecimal amount,
String currency,
String description
) {
// Compact constructor for validation - parameters are implicit
public PaymentRequest {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (currency == null || currency.length() != 3) {
throw new IllegalArgumentException("Invalid currency code");
}
}

// Additional methods allowed - records can have instance methods
public boolean isLargePayment() {
return amount.compareTo(new BigDecimal("10000")) > 0;
}
}

// BAD: Traditional mutable class
public class PaymentRequest {
private String customerId;
private BigDecimal amount;
private String currency;
private String description;

// Constructor, getters, setters, equals, hashCode, toString...
}

Sealed Classes for Type Hierarchies

Sealed classes (Java 17, JEP 409) restrict which other classes can extend or implement them. This provides compile-time exhaustiveness checking when pattern matching. The compiler knows all possible subtypes, eliminating the need for a default case and catching errors when new subtypes are added without updating all switch expressions.

// GOOD: Sealed class hierarchy
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 exhaustive switch - compiler ensures all cases covered
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;
}; // No default needed - compiler verifies exhaustiveness
}

Pattern Matching

Pattern matching for instanceof (Java 16, JEP 394) eliminates redundant casts by binding the matched variable directly. The pattern variable is automatically in scope only when the pattern matches, preventing errors from using the wrong type.

// GOOD: Pattern matching with instanceof - eliminates explicit cast
public BigDecimal calculateFee(Payment payment) {
if (payment instanceof DomesticPayment p) { // p is automatically cast
return p.amount().multiply(new BigDecimal("0.01"));
} else if (payment instanceof InternationalPayment p) {
return p.amount().multiply(new BigDecimal("0.03"))
.add(p.conversionFee());
} else {
throw new IllegalArgumentException("Unknown payment type");
}
}

// GOOD: Pattern matching in switch
public String getPaymentDescription(Object obj) {
return switch (obj) {
case Payment p when p.amount().compareTo(BigDecimal.ZERO) > 0 ->
"Payment: " + p.amount();
case String s -> "Description: " + s;
case null -> "No payment info";
default -> "Unknown";
};
}

Virtual Threads

Virtual threads (Java 21, JEP 444) are lightweight threads managed by the JVM rather than the operating system. Unlike platform threads which map 1:1 to OS threads, millions of virtual threads can run concurrently with minimal overhead. They're ideal for I/O-bound workloads (database queries, HTTP calls) where threads spend most time waiting. See Java Concurrency for comprehensive coverage.

// GOOD: Using virtual threads for concurrent I/O operations
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> callPaymentGateway());
var future2 = executor.submit(() -> callFraudService());
var future3 = executor.submit(() -> callNotificationService());

// Blocks are cheap with virtual threads - won't block platform threads
var gatewayResult = future1.get();
var fraudCheck = future2.get();
var notification = future3.get();
}

// GOOD: Virtual thread for each request (Spring Boot 3.5+)
// Configure in application.yml:
// spring.threads.virtual.enabled: true

Text Blocks

Text blocks (Java 15, JEP 378) solve the long-standing problem of embedding multi-line strings in Java code. Before text blocks, developers had to concatenate strings with + operators or escape special characters, making the content difficult to read and maintain. Text blocks preserve the formatting exactly as written, including line breaks and indentation.

The opening delimiter """ must be followed by a line break, and the closing delimiter determines the indentation level - the compiler strips common leading whitespace based on the position of the closing """. This allows you to indent text blocks naturally within your code while producing properly formatted output.

// GOOD: Multi-line strings with text blocks - formatted() for substitution
var json = """
{
"paymentId": "%s",
"amount": %.2f,
"status": "SUCCESS"
}
""".formatted(paymentId, amount);

// GOOD: SQL queries are readable without concatenation
var sql = """
SELECT p.id, p.amount, p.currency, p.status
FROM payments p
WHERE p.customer_id = ?
AND p.created_at >= ?
ORDER BY p.created_at DESC
""";

// BAD: Old concatenation style - hard to read and maintain
var oldStyleSql = "SELECT p.id, p.amount, p.currency, p.status\n" +
"FROM payments p\n" +
"WHERE p.customer_id = ?\n" +
" AND p.created_at >= ?\n" +
"ORDER BY p.created_at DESC";

Optional for Nullable Values

Optional<T> (Java 8) is a container that may or may not contain a non-null value. It forces callers to explicitly handle the absence of a value, eliminating null pointer exceptions. Use Optional for return types where absence is valid, but avoid using it as a method parameter (adds overhead without benefit - nullable parameters are clearer).

// GOOD: Use Optional for nullable return values
public Optional<Payment> findPayment(String id) {
return paymentRepository.findById(id);
}

// Usage - forces caller to handle absence
var payment = findPayment(id)
.orElseThrow(() -> new PaymentNotFoundException(id));

// BAD: Returning null - caller may forget null check
public Payment findPayment(String id) {
return paymentRepository.findById(id); // May return null - risky!
}

// BAD: Using Optional as parameter - adds overhead without value
public void processPayment(Optional<Payment> payment) { // Don't do this
// Just use nullable parameter or method overload instead
}

Immutability

Immutable objects cannot change state after construction. This provides thread safety without synchronization, easier reasoning about code behavior, and safer sharing between components. Immutability prevents entire categories of bugs related to unexpected state changes.

Prefer Immutable Objects

// GOOD: Immutable class with defensive copying
public final class PaymentSummary {
private final String id;
private final BigDecimal total;
private final List<Payment> payments;

public PaymentSummary(String id, BigDecimal total, List<Payment> payments) {
this.id = id;
this.total = total;
// List.copyOf creates unmodifiable copy - prevents external mutation
this.payments = List.copyOf(payments);
}

public String id() { return id; }
public BigDecimal total() { return total; }
public List<Payment> payments() { return payments; } // Already unmodifiable
}

// BAD: Mutable class
public class PaymentSummary {
private String id;
private BigDecimal total;
private List<Payment> payments;

// Setters allow mutation...
}

Final Fields

// GOOD: Final fields prevent reassignment
public class PaymentService {
private final PaymentRepository repository;
private final AuditService auditService;

public PaymentService(PaymentRepository repository, AuditService auditService) {
this.repository = repository;
this.auditService = auditService;
}
}

// BAD: Mutable fields
public class PaymentService {
private PaymentRepository repository; // Can be reassigned!
private AuditService auditService;
}

Enums and Constants

Use Enums Not Strings

Enums provide type safety and enable compile-time checking that string constants cannot. When you use a string constant, any typo or invalid value becomes a runtime error. With enums, the compiler catches these errors immediately. Additionally, enums can carry behavior and data, making them far more powerful than simple constants.

Enums in Java are full classes - they can have fields, constructors, and methods. Each enum constant is a singleton instance, and you can override methods per-constant to implement state-specific behavior. This enables patterns like state machines where each state knows its valid transitions.

// GOOD: Enum with behavior - each constant can override methods
public enum PaymentStatus {
PENDING("Payment is pending") {
@Override
public boolean canTransitionTo(PaymentStatus newStatus) {
return newStatus == PROCESSING || newStatus == CANCELLED;
}
},
PROCESSING("Payment is being processed") {
@Override
public boolean canTransitionTo(PaymentStatus newStatus) {
return newStatus == SUCCESS || newStatus == FAILED;
}
},
SUCCESS("Payment completed successfully") {
@Override
public boolean canTransitionTo(PaymentStatus newStatus) {
return false; // Terminal state
}
},
FAILED("Payment failed") {
@Override
public boolean canTransitionTo(PaymentStatus newStatus) {
return false; // Terminal state
}
},
CANCELLED("Payment was cancelled") {
@Override
public boolean canTransitionTo(PaymentStatus newStatus) {
return false; // Terminal state
}
};

private final String description;

PaymentStatus(String description) {
this.description = description;
}

public String getDescription() {
return description;
}

public abstract boolean canTransitionTo(PaymentStatus newStatus);
}

// BAD: String constants
public class PaymentStatus {
public static final String PENDING = "PENDING";
public static final String PROCESSING = "PROCESSING";
public static final String SUCCESS = "SUCCESS";
}

Constants Location

Constants should be defined close to where they're used, not in a centralized Constants class. A global constants file becomes a dumping ground that couples unrelated code together. When constants are defined near their usage, the code is more cohesive and easier to understand in context. Changes to constants are less likely to have unexpected ripple effects.

If a constant is truly used across multiple unrelated modules, consider whether it represents a domain concept that should have its own class or enum. For example, currency codes are better represented as an enum or validated through a Currency domain object than as string constants.

// GOOD: Constants close to usage - encapsulated within relevant class
public class PaymentValidator {
private static final BigDecimal MIN_AMOUNT = new BigDecimal("0.01");
private static final BigDecimal MAX_AMOUNT = new BigDecimal("100000.00");

public void validate(Payment payment) {
if (payment.amount().compareTo(MIN_AMOUNT) < 0) {
throw new ValidationException("Amount too small");
}
if (payment.amount().compareTo(MAX_AMOUNT) > 0) {
throw new ValidationException("Amount too large");
}
}
}

// BAD: Global constants file
public class Constants {
public static final BigDecimal PAYMENT_MIN_AMOUNT = new BigDecimal("0.01");
public static final BigDecimal PAYMENT_MAX_AMOUNT = new BigDecimal("100000.00");
public static final String ORDER_STATUS_PENDING = "PENDING";
public static final String CUSTOMER_TYPE_INDIVIDUAL = "INDIVIDUAL";
// 100+ unrelated constants...
}

SOLID Principles

SOLID principles provide guidelines for object-oriented design that improve maintainability and testability. For comprehensive coverage of these principles and their relationship to Clean Architecture patterns, see Architecture Overview. The following are key Java-specific applications of these principles.

Single Responsibility Principle

A class should have one, and only one, reason to change. This means each class should focus on a single concern or business capability. When classes have multiple responsibilities, changes to one responsibility risk breaking the others.

// GOOD: Each class has one responsibility
public class PaymentProcessor {
public PaymentResult process(Payment payment) {
// Only processes payments
}
}

public class PaymentValidator {
public void validate(Payment payment) {
// Only validates payments
}
}

public class PaymentNotifier {
public void sendNotification(Payment payment) {
// Only sends notifications
}
}

// BAD: God class doing everything
public class PaymentManager {
public PaymentResult processPayment(Payment payment) {
validate(payment);
var result = callGateway(payment);
saveToDatabase(result);
sendNotification(result);
logAudit(result);
updateCache(result);
return result;
}
}

Dependency Inversion Principle

High-level modules should not depend on low-level modules - both should depend on abstractions. This decouples components, making them easier to test (mock the interface) and swap implementations without changing dependent code.

// GOOD: Depend on abstractions not concrete classes
public interface PaymentGateway {
PaymentResult process(Payment payment);
}

public class StripePaymentGateway implements PaymentGateway {
@Override
public PaymentResult process(Payment payment) {
// Stripe-specific implementation
}
}

public class PaymentService {
private final PaymentGateway gateway; // Depends on interface

public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
}

// BAD: Depend on concrete class
public class PaymentService {
private final StripePaymentGateway gateway; // Tightly coupled

public PaymentService(StripePaymentGateway gateway) {
this.gateway = gateway;
}
}

Exception Handling

Well-designed exception handling improves debugging by providing context and enables appropriate error recovery. Use checked exceptions for recoverable conditions and unchecked exceptions for programming errors. Custom exceptions should carry domain-specific information to aid diagnosis.

Custom Exceptions

// GOOD: Domain-specific exceptions with context
public class PaymentException extends RuntimeException {
private final String paymentId;
private final String errorCode;

public PaymentException(String message, String paymentId, String errorCode) {
super(message);
this.paymentId = paymentId;
this.errorCode = errorCode;
}

public String getPaymentId() { return paymentId; }
public String getErrorCode() { return errorCode; }
}

public class InsufficientFundsException extends PaymentException {
private final BigDecimal availableBalance;
private final BigDecimal requestedAmount;

public InsufficientFundsException(String paymentId,
BigDecimal availableBalance,
BigDecimal requestedAmount) {
super("Insufficient funds", paymentId, "INSUFFICIENT_FUNDS");
this.availableBalance = availableBalance;
this.requestedAmount = requestedAmount;
}
}

Proper Exception Handling

// GOOD: Specific exception handling
public Payment processPayment(PaymentRequest request) {
try {
validatePayment(request);
return gateway.process(request);
} catch (InsufficientFundsException ex) {
log.warn("Insufficient funds for payment: {}", request.customerId());
return Payment.failed("INSUFFICIENT_FUNDS");
} catch (PaymentGatewayException ex) {
log.error("Gateway error processing payment", ex);
throw new PaymentProcessingException("Gateway unavailable", ex);
}
}

// BAD: Catching generic Exception
public Payment processPayment(PaymentRequest request) {
try {
return gateway.process(request);
} catch (Exception ex) { // Too broad!
log.error("Error", ex);
return null; // Swallowing exception
}
}

// BAD: Empty catch block
try {
riskyOperation();
} catch (Exception ex) {
// Ignored - very dangerous!
}

Collections

Java's collection factory methods (Java 9+) create immutable collections with less boilerplate than traditional approaches. Immutable collections are thread-safe, prevent accidental modification, and communicate intent clearly.

Prefer List.of(), Set.of(), Map.of()

// GOOD: Immutable collections with factory methods (Java 9+)
var currencies = List.of("USD", "EUR", "GBP");
var allowedStatuses = Set.of(PaymentStatus.PENDING, PaymentStatus.PROCESSING);
var exchangeRates = Map.of(
"USD", 1.0,
"EUR", 0.85,
"GBP", 0.73
);

// BAD: Mutable collections
var currencies = new ArrayList<>();
currencies.add("USD");
currencies.add("EUR");
currencies.add("GBP");

Stream API

The Stream API (Java 8+) provides functional-style operations on collections. Streams are lazily evaluated - intermediate operations (filter, map) don't execute until a terminal operation (collect, reduce) is called. This enables efficient processing and clearer expression of data transformations.

// GOOD: Functional stream operations
var totalAmount = payments.stream()
.filter(p -> p.status() == PaymentStatus.SUCCESS) // Lazy - doesn't execute yet
.map(Payment::amount) // Lazy
.reduce(BigDecimal.ZERO, BigDecimal::add); // Terminal - triggers execution

var paymentsByCustomer = payments.stream()
.collect(Collectors.groupingBy(Payment::customerId));

// GOOD: Use method references for readability
var customerIds = payments.stream()
.map(Payment::customerId) // Method reference instead of lambda
.distinct()
.toList();

// BAD: Imperative loop for simple operations
var totalAmount = BigDecimal.ZERO;
for (Payment payment : payments) {
if (payment.status() == PaymentStatus.SUCCESS) {
totalAmount = totalAmount.add(payment.amount());
}
}

Lombok Usage

Lombok is a code generation library that reduces boilerplate through annotations. While it significantly reduces verbosity for entity classes and DTOs, use it judiciously. Over-reliance on Lombok can make code harder to debug (generated code isn't visible in your IDE) and creates a compile-time dependency. For simple immutable data carriers, prefer records over Lombok.

Use Lombok primarily for JPA entities and mutable DTOs where records aren't suitable. Avoid the @Data annotation as it combines too many features implicitly - prefer explicit annotations that clearly communicate intent.

Accepted Annotations

// GOOD: Use Lombok for boilerplate on mutable classes
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@EqualsAndHashCode
public class Payment {
private String id;
private String customerId;
private BigDecimal amount;
private PaymentStatus status;
}

// GOOD: @RequiredArgsConstructor for dependency injection
@Service
@RequiredArgsConstructor // Generates constructor with all final fields
public class PaymentService {
private final PaymentRepository repository;
private final AuditService auditService;

// Constructor generated by Lombok - enables constructor injection
// Equivalent to:
// public PaymentService(PaymentRepository repository, AuditService auditService) {
// this.repository = repository;
// this.auditService = auditService;
// }
}

// GOOD: BETTER: Use records for immutable DTOs instead of Lombok
public record PaymentRequest(
String customerId,
BigDecimal amount,
String currency
) {
// No Lombok needed - records are built into modern Java
}

// BAD: Avoid @Data (combines too many annotations implicitly)
@Data // Includes @ToString, @EqualsAndHashCode, @Getter, @Setter, @RequiredArgsConstructor
public class Payment {
// Problem: @ToString might log sensitive data
// Problem: @EqualsAndHashCode might use wrong fields
// Problem: @Setter makes everything mutable
// Too much magic - be explicit instead
}

Build Tools

Gradle is the standard build tool for Java projects. Its incremental build engine, build cache, and Kotlin DSL support offer faster build times and more flexibility than Maven. All new projects should use Gradle.

For Spring Boot applications, use the Spring Boot Gradle plugin which provides dependency management, executable JAR packaging, and integration with Spring's ecosystem. The plugin automatically configures sensible defaults while allowing customization when needed.

Gradle Configuration

This configuration demonstrates a modern Gradle setup for a Spring Boot application with Java 25, including code quality tools (Spotless for formatting) and mutation testing (PITest). The dependency management plugin ensures compatible versions of all Spring dependencies.

build.gradle:

plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.6'
id 'com.diffplug.spotless' version '6.25.0'
id 'info.solidsoft.pitest' version '1.15.0'
}

java {
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:postgresql'
}

tasks.named('test') {
useJUnitPlatform()
}

Linting and Static Analysis

Automated code quality checks catch bugs, enforce standards, and improve maintainability before code reaches production. Java provides a rich ecosystem of static analysis tools including Spotless for formatting, Checkstyle for coding standards, SpotBugs for bug detection, PMD for code quality analysis, and SonarQube for continuous inspection.

For comprehensive coverage of linting tools, configuration, CI/CD integration, and best practices, see our dedicated Java Linting and Static Analysis guide.

Quick reference:

  • Spotless - Automated code formatting with Google Java Format
  • Checkstyle - Enforce naming conventions, complexity limits, documentation
  • SpotBugs - Bytecode analysis for bugs and security issues
  • PMD - Source code analysis for anti-patterns and code smells
  • SonarQube - Centralized quality tracking with quality gates
  • Compiler warnings - Treat all warnings as errors with -Werror

Further Reading

External Resources:


Summary

Key Takeaways:

  1. Java 25+: Use modern Java features (records, sealed classes, virtual threads)
  2. Immutability: Prefer immutable objects with final fields
  3. Records: Use records for DTOs and simple data carriers
  4. Sealed classes: Restrict class hierarchies with sealed interfaces
  5. Pattern matching: Leverage pattern matching for cleaner code
  6. Enums: Use enums instead of string constants
  7. Optional: Use Optional for nullable return values, not parameters
  8. SOLID principles: Apply thoughtfully for maintainable code
  9. Lombok: Use for boilerplate, but keep it simple
  10. Code quality: Zero compiler warnings, use Spotless for formatting