|$ curl https://forge-ai.dev/api/markdown?path=docs/java/best-practices
$cat docs/java-—-best-practices-&-patterns.md
updated Recently·28 min read·published

Java — Best Practices & Patterns

JavaBest PracticesAdvanced🎯Free Tools
Introduction

Java remains one of the most widely deployed languages in enterprise software, cloud services, Android, and big-data ecosystems. Its longevity comes from a strong type system, mature tooling, and a vast standard library. However, writing Java that is readable, maintainable, and performant at scale requires more than knowing syntax — it requires disciplined patterns and a deep respect for the JVM.

This guide distills production-tested Java best practices across object-oriented design, the Stream API, concurrency, Spring Boot, testing, logging, and JVM tuning. It is written for engineers who already know the basics and want to write code that survives real traffic, real teams, and real debugging sessions at 2 a.m.

info

Treat Java as a language of explicit contracts: every public class, method, and field communicates intent to the next engineer. Favor clarity over cleverness, and let the compiler and tests carry as much of the correctness burden as possible.
Dos and Don'ts

The table below contrasts common Java anti-patterns with the production approaches that replace them. These rules are deliberately conservative: they prioritize correctness, team velocity, and predictable runtime behavior.

AvoidPreferWhy
Returning nullOptional<T> or empty collectionsEliminates NullPointerException churn at call sites
Mutable public fieldsPrivate final fields with accessorsEncapsulation preserves invariants and eases refactoring
String concatenation in loopsStringBuilder or String.joinAvoids quadratic copying and intermediate garbage
Raw typesParameterized generics with boundsType safety and self-documenting APIs
Synchronized on mutable objectsprivate final Object lock = new Object()Prevents external code from acquiring your lock
Creating threads directlyExecutorService with named thread poolsBounded resources, graceful shutdown, observability
Swallowing exceptionsLog, wrap, or rethrow with contextSilent failures hide data corruption and outages
Reflection for routine workInterfaces, records, dependency injectionCompile-time safety, better performance, easier testing
System.out.printlnSLF4J/Logback with structured levelsConfigurable output, log aggregation, production tuning
Massive @Component classesSmall, focused services with single responsibilitiesEasier to test, reason about, and reuse

warning

Do not use Optional as a routine replacement for every nullable reference, and never return Optional.get() without first checking isPresent() — prefer orElseThrow, orElse, or functional mapping. Used poorly, Optional just moves null-handling errors deeper into the call stack.
OOP Design Principles

Java rewards object-oriented design done well. The goal is not dogmatic adherence to inheritance, but code that expresses behavior through small, composable objects with clear responsibilities. Favor composition, immutability, and interfaces over deep class hierarchies.

Money.java
JAVA
1// Good — immutable value object with factory method
2public final class Money {
3 private final BigDecimal amount;
4 private final Currency currency;
5
6 private Money(BigDecimal amount, Currency currency) {
7 this.amount = amount;
8 this.currency = currency;
9 }
10
11 public static Money of(BigDecimal amount, Currency currency) {
12 Objects.requireNonNull(amount, "amount must not be null");
13 Objects.requireNonNull(currency, "currency must not be null");
14 return new Money(amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP), currency);
15 }
16
17 public Money add(Money other) {
18 if (!this.currency.equals(other.currency)) {
19 throw new IllegalArgumentException("Cannot add " + this.currency + " and " + other.currency);
20 }
21 return Money.of(this.amount.add(other.amount), this.currency);
22 }
23
24 public BigDecimal amount() { return amount; }
25 public Currency currency() { return currency; }
26}
27
28// Bad — mutable data bag with no validation
29public class Money {
30 public BigDecimal amount;
31 public String currency;
32}

Design for equals and hashCode whenever objects represent values. Records (Java 16+) automate the boilerplate for immutable data carriers, but you still control validation and behavior through compact constructors.

EmailAddress.java
JAVA
1public record EmailAddress(String value) {
2 public EmailAddress {
3 Objects.requireNonNull(value, "email must not be null");
4 if (!value.contains("@")) {
5 throw new IllegalArgumentException("Invalid email: " + value);
6 }
7 }
8}
9
10// Usage
11EmailAddress email = new EmailAddress("ada@example.com");
12email.value(); // no getter noise

best practice

Prefer composition over inheritance. Inheritance couples subclasses to superclass implementation details and breaks encapsulation. Use interfaces to define capabilities, and delegate behavior to injected dependencies.
CheckoutService.java
JAVA
1// Interface defines the capability
2public interface PaymentProcessor {
3 PaymentResult process(Payment payment);
4}
5
6// Composed behavior, not inherited
7public class CheckoutService {
8 private final PaymentProcessor processor;
9 private final FraudChecker fraudChecker;
10 private final ReceiptSender receiptSender;
11
12 public CheckoutService(PaymentProcessor processor,
13 FraudChecker fraudChecker,
14 ReceiptSender receiptSender) {
15 this.processor = Objects.requireNonNull(processor);
16 this.fraudChecker = Objects.requireNonNull(fraudChecker);
17 this.receiptSender = Objects.requireNonNull(receiptSender);
18 }
19
20 public OrderResult checkout(Cart cart) {
21 fraudChecker.verify(cart);
22 PaymentResult result = processor.process(cart.toPayment());
23 receiptSender.send(result);
24 return new OrderResult(result.transactionId());
25 }
26}
Effective Stream API

The Stream API is one of Java's most powerful abstractions for collection processing. Used well, it produces declarative, parallel-ready pipelines. Used poorly, it creates subtle bugs, hidden loops, and memory bombs.

stream-patterns.java
JAVA
1// Good — declarative, single pass, no mutation
2List<String> activeNames = users.stream()
3 .filter(User::isActive)
4 .map(User::name)
5 .sorted()
6 .toList(); // Java 16+ immutable list
7
8// Bad — verbose, mutates accumulator, harder to parallelize
9List<String> result = new ArrayList<>();
10for (User user : users) {
11 if (user.isActive()) {
12 result.add(user.name());
13 }
14}
15Collections.sort(result);

Avoid side effects inside stream operations. The pipeline should transform data, not mutate external state. If you find yourself reaching for forEach to do real work, consider whether a collector or reduce operation is cleaner.

stream-collectors.java
JAVA
1// Bad — side effect and race hazard
2AtomicInteger total = new AtomicInteger(0);
3orders.stream().forEach(o -> total.addAndGet(o.amount()));
4
5// Good — reduction expresses intent
6int total = orders.stream()
7 .mapToInt(Order::amount)
8 .sum();
9
10// Grouping with collectors
11Map<String, Long> countByCategory = products.stream()
12 .collect(Collectors.groupingBy(Product::category, Collectors.counting()));
13
14// Downstream collection into immutable structures
15Map<String, List<String>> namesByCity = people.stream()
16 .collect(Collectors.groupingBy(
17 Person::city,
18 Collectors.mapping(Person::name, Collectors.toList())
19 ));

warning

Do not call stream() on a one-element operation just to look functional, and do not chain dozens of operations into an unreadable single expression. Break complex pipelines into named intermediate variables or helper methods when readability suffers.
stream-optional.java
JAVA
1// Use Optional results responsibly
2Optional<Order> firstLarge = orders.stream()
3 .filter(o -> o.amount() > 1_000)
4 .findFirst();
5
6firstLarge.ifPresentOrElse(
7 o -> log.info("Large order: {}", o.id()),
8 () -> log.warn("No large orders found")
9);
10
11// Avoid Optional in fields or method arguments; return it from queries
12public Optional<Customer> findById(UUID id) { ... }
Concurrency & Multithreading

Concurrent Java code is where small mistakes become large outages. The best strategy is to minimize shared mutable state. When state must be shared, use higher-level concurrency utilities from java.util.concurrent instead of raw synchronized and wait/notify.

executor-patterns.java
JAVA
1// Good — bounded thread pool with meaningful names
2ExecutorService executor = Executors.newFixedThreadPool(
3 Runtime.getRuntime().availableProcessors(),
4 r -> {
5 Thread t = new Thread(r, "worker-" + counter.incrementAndGet());
6 t.setUncaughtExceptionHandler((thread, ex) ->
7 log.error("Uncaught in {}", thread.getName(), ex));
8 return t;
9 }
10);
11
12try {
13 List<Future<String>> futures = tasks.stream()
14 .map(task -> executor.submit(task))
15 .toList();
16
17 for (Future<String> future : futures) {
18 process(future.get());
19 }
20} finally {
21 shutdownGracefully(executor, Duration.ofSeconds(30));
22}

Always shut down thread pools. Unbounded growth of platform threads exhausts memory and hides resource leaks. Prefer virtual threads (Project Loom, Java 21+) for I/O-bound workloads, but understand their interaction with native monitors and pinning.

ExecutorShutdown.java
JAVA
1public static void shutdownGracefully(ExecutorService executor, Duration timeout) {
2 executor.shutdown();
3 try {
4 if (!executor.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
5 executor.shutdownNow();
6 }
7 } catch (InterruptedException e) {
8 executor.shutdownNow();
9 Thread.currentThread().interrupt();
10 }
11}

best practice

Favor ConcurrentHashMap, CopyOnWriteArrayList, and atomic classes over manual synchronization. If you do synchronize, use a private final lock object rather than the instance itself.
concurrent-cache.java
JAVA
1public class CachedPriceService {
2 private final Map<String, BigDecimal> cache = new ConcurrentHashMap<>();
3
4 public BigDecimal getPrice(String symbol) {
5 return cache.computeIfAbsent(symbol, this::fetchPrice);
6 }
7
8 private BigDecimal fetchPrice(String symbol) {
9 // network call
10 return remoteClient.fetch(symbol);
11 }
12}
13
14// Bad — synchronized on mutable object exposes lock
15public synchronized void update() { ... }
Spring Boot Basics

Spring Boot dominates the Java server landscape because it removes boilerplate and provides production-ready defaults. The key is to let the framework handle wiring and configuration while keeping business logic free of framework annotations.

OrderController.java
JAVA
1// Good — thin controller delegates to a service
2@RestController
3@RequestMapping("/api/orders")
4class OrderController {
5 private final OrderService orderService;
6
7 OrderController(OrderService orderService) {
8 this.orderService = orderService;
9 }
10
11 @GetMapping("/{id}")
12 ResponseEntity<OrderDto> getOrder(@PathVariable UUID id) {
13 return orderService.findById(id)
14 .map(ResponseEntity::ok)
15 .orElse(ResponseEntity.notFound().build());
16 }
17
18 @PostMapping
19 ResponseEntity<OrderDto> createOrder(@Valid @RequestBody OrderRequest request) {
20 OrderDto created = orderService.create(request);
21 URI location = ServletUriComponentsBuilder
22 .fromCurrentRequestUri()
23 .path("/{id}")
24 .buildAndExpand(created.id())
25 .toUri();
26 return ResponseEntity.created(location).body(created);
27 }
28}

Constructor injection is the only injection style you should use in production code. It makes dependencies explicit, enables immutability, and guarantees the object is valid immediately after construction. Avoid field injection in tests or application code.

OrderService.java
JAVA
1@Service
2class OrderService {
3 private final OrderRepository repository;
4 private final EventPublisher publisher;
5 private final PricingClient pricing;
6
7 OrderService(OrderRepository repository,
8 EventPublisher publisher,
9 PricingClient pricing) {
10 this.repository = repository;
11 this.publisher = publisher;
12 this.pricing = pricing;
13 }
14
15 @Transactional
16 public OrderDto create(OrderRequest request) {
17 Order order = Order.from(request, pricing.currentFor(request.sku()));
18 Order saved = repository.save(order);
19 publisher.publish(new OrderCreated(saved.id()));
20 return OrderDto.from(saved);
21 }
22}

info

Keep Spring annotations out of domain classes. Your entities, value objects, and domain services should be plain Java. Framework coupling makes unit testing harder and portability impossible.
application.yml
YAML
1# application.yml — externalize environment-specific config
2server:
3 port: 8080
4
5spring:
6 datasource:
7 url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/app}
8 username: ${DATABASE_USER:app}
9 password: ${DATABASE_PASSWORD:app}
10 hikari:
11 maximum-pool-size: 20
12 minimum-idle: 5
13 connection-timeout: 20000
14
15management:
16 endpoints:
17 web:
18 exposure:
19 include: health,info,metrics,prometheus
20 endpoint:
21 health:
22 show-details: when_authorized
Testing

Java's testing ecosystem is mature. JUnit 5 provides the foundation, AssertJ gives fluent assertions, and Mockito enables isolated unit tests. The goal of tests is not coverage percentage but confidence that refactoring does not break behavior.

PricingServiceTest.java
JAVA
1@ExtendWith(MockitoExtension.class)
2class PricingServiceTest {
3
4 @Mock DiscountRepository discountRepository;
5 @InjectMocks PricingService pricingService;
6
7 @Test
8 void appliesHighestApplicableDiscount() {
9 when(discountRepository.activeFor("SKU-123"))
10 .thenReturn(List.of(
11 new Discount("TEN", 0.10),
12 new Discount("TWENTY", 0.20)
13 ));
14
15 Money result = pricingService.price("SKU-123", Money.of(new BigDecimal("100.00"), USD));
16
17 assertThat(result.amount())
18 .isEqualByComparingTo(new BigDecimal("80.00"));
19 }
20
21 @Test
22 void rejectsNegativeBasePrice() {
23 assertThatThrownBy(() ->
24 pricingService.price("SKU-123", Money.of(new BigDecimal("-10.00"), USD)))
25 .isInstanceOf(IllegalArgumentException.class)
26 .hasMessageContaining("base price must be positive");
27 }
28}

Structure tests using the Arrange-Act-Assert pattern and name them after the behavior they verify, not the method they call. Avoid multiple unrelated assertions in one test; a single failing assertion should point directly to the broken behavior.

testing-patterns.java
JAVA
1// Good — descriptive name, single concept
2@Test
3void shouldRejectOrderWhenInventoryIsInsufficient() { ... }
4
5// Bad — vague name, multiple concepts
6@Test
7void testOrder() { ... }
8
9// Integration test slice
10@DataJpaTest
11class OrderRepositoryTest {
12
13 @Autowired OrderRepository repository;
14
15 @Test
16 void findsOrdersByStatusAndCreatedAfter() {
17 Order order = repository.save(new Order(PENDING, Instant.now().minusSeconds(60)));
18
19 List<Order> found = repository.findByStatusAndCreatedAfter(
20 PENDING, Instant.now().minusSeconds(120));
21
22 assertThat(found).containsExactly(order);
23 }
24}

best practice

Use @WebMvcTest, @DataJpaTest, and @SpringBootTest at the right granularity. Loading the full application context for every test is slow and obscures the real failure surface.
Logging

Logs are the primary observability signal in production Java applications. Use SLF4J as the facade and a capable backend such as Logback or Log4j2. The right log at the right level saves hours during incidents.

PaymentService.java
JAVA
1import org.slf4j.Logger;
2import org.slf4j.LoggerFactory;
3
4@Service
5public class PaymentService {
6 private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
7
8 public PaymentResult charge(Payment payment) {
9 log.info("Processing payment orderId={} amount={} {}",
10 payment.orderId(), payment.amount(), payment.currency());
11
12 try {
13 PaymentResult result = gateway.charge(payment);
14 log.info("Payment succeeded orderId={} transactionId={}",
15 payment.orderId(), result.transactionId());
16 return result;
17 } catch (GatewayTimeoutException ex) {
18 log.warn("Payment gateway timeout orderId={}", payment.orderId(), ex);
19 throw new PaymentException("Gateway timeout", ex);
20 } catch (Exception ex) {
21 log.error("Payment failed orderId={}", payment.orderId(), ex);
22 throw new PaymentException("Charge failed", ex);
23 }
24 }
25}

Use structured logging — key=value pairs or JSON — so log aggregation systems can filter, group, and alert. Never log sensitive data such as passwords, tokens, or full credit card numbers. Avoid string concatenation in log statements; use parameterized placeholders so the message is only formatted if the level is enabled.

logback-spring.xml
XML
1<!-- logback-spring.xml — environment-aware configuration -->
2<configuration>
3 <springProfile name="local">
4 <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
5 <encoder>
6 <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
7 </encoder>
8 </appender>
9 <root level="INFO">
10 <appender-ref ref="CONSOLE" />
11 </root>
12 </springProfile>
13
14 <springProfile name="prod">
15 <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
16 <encoder class="net.logstash.logback.encoder.LogstashEncoder" />
17 </appender>
18 <root level="WARN">
19 <appender-ref ref="JSON" />
20 </root>
21 <logger name="com.forgelearn" level="INFO" />
22 </springProfile>
23</configuration>

warning

Logging the stack trace at INFO or below pollutes dashboards and increases costs. Reserve exception objects for WARN and ERROR, and always include enough context to identify the affected entity without leaking private data.
JVM Tuning

The JVM is a sophisticated runtime, and most applications run fine with defaults. Tuning becomes necessary as heap size, latency requirements, or throughput demands grow. Measure before tuning; changing flags without evidence is a common source of instability.

jvm-flags.sh
Bash
1# Sensible starting point for a containerized Spring Boot service
2java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags:filecount=5,filesize=10m -jar app.jar

For modern Java 21+ deployments, evaluate the Generational Z Garbage Collector for workloads that need low-latency, large heaps. For throughput-oriented batch jobs, G1GC or ParallelGC remain solid choices.

zgc-flags.sh
Bash
1# Generational ZGC for low-latency services (Java 21+)
2java -XX:+UseZGC -XX:+ZGenerational -XX:MaxRAMPercentage=75.0 -jar app.jar

info

Always set -XX:+HeapDumpOnOutOfMemoryError in production. A heap dump is the fastest way to distinguish a memory leak from legitimate growth, and it is free until an OutOfMemoryError actually occurs.
GoalFlags to ConsiderNotes
Container awareness-XX:+UseContainerSupport -XX:MaxRAMPercentageHonors cgroup limits in Docker/Kubernetes
Low latency-XX:+UseZGC -XX:MaxGCPauseMillisSub-millisecond pauses on large heaps
Throughput-XX:+UseParallelGCBest for batch jobs, not interactive services
Observability-Xlog:gc* -XX:+HeapDumpOnOutOfMemoryErrorEssential for production debugging
Startup time-XX:+TieredCompilation -XX:TieredStopAtLevel=1Trade peak throughput for faster startup
Common Pitfalls

Even experienced Java engineers trip over the same hazards: floating-point money, broken equals contracts, resource leaks, and mutable shared state. Knowing them by name is the first step toward avoiding them.

common-pitfalls.java
JAVA
1// Pitfall 1: double for money
2double total = 0.1 + 0.2; // 0.30000000000000004
3// Use BigDecimal instead
4BigDecimal total = new BigDecimal("0.1").add(new BigDecimal("0.2"));
5
6// Pitfall 2: BigDecimal with double constructor
7new BigDecimal(0.1); // imprecise
8new BigDecimal("0.1"); // exact
9
10// Pitfall 3: comparing strings with ==
11String a = new String("hi");
12String b = new String("hi");
13if (a == b) { ... } // false; use a.equals(b)
14
15// Pitfall 4: leaking resources
16try (InputStream in = Files.newInputStream(path)) {
17 // auto-closed
18}
19
20// Pitfall 5: modifying a collection while iterating
21for (User u : users) {
22 if (!u.isActive()) users.remove(u); // ConcurrentModificationException
23}
24users.removeIf(u -> !u.isActive()); // safe

danger

Never use double or float for monetary calculations. The inexact binary representation of decimal values causes silent rounding errors that compound over time. Always use BigDecimal initialized from strings.
Code Review Checklist

Use this checklist for every Java pull request. Automate the mechanical checks with static analysis tools and reserve human review for design, correctness, and security judgment.

CheckAutomated?Details
Null safetySpotBugs / NullAwayOptional, requireNonNull, validated records
ImmutabilityReviewfinal fields, unmodifiable collections, records
ConcurrencyReview + jcstressPrivate locks, atomic usage, executor shutdown
Resource handlingReviewtry-with-resources, AutoCloseable, connection pools
Exception hygieneReviewNo swallowed exceptions, specific catch blocks
Test coverageJaCoCoUnit, integration, and edge-case tests
LoggingReviewAppropriate levels, no PII, parameterized messages
DependenciesOWASP dependency-checkNo known CVEs, minimal scope
PerformanceJMH + profilingNo accidental O(n²), sensible collection choices

best practice

Pair the checklist with a CI gate: require mvn verify or ./gradlew check to pass compilation, tests, static analysis, and dependency vulnerability scanning before any merge.
Resources

These references back the recommendations in this guide and remain useful throughout a Java engineering career.

ResourceTopic
Effective JavaJoshua Bloch — canonical Java best practices
Java Concurrency in PracticeGoetz et al. — threading and concurrent design
OpenJDK Documentationopenjdk.org — release notes, GC guides, migration
Spring Guidesspring.io/guides — practical Spring Boot tutorials
JUnit 5 User Guidejunit.org/junit5 — tests, extensions, parameterized tests
SLF4J Manualslf4j.org — logging facades and migration
$Blueprint — Engineering Documentation·Section ID: JAVA-BP·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.