Java — Best Practices & Patterns
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
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.
| Avoid | Prefer | Why |
|---|---|---|
| Returning null | Optional<T> or empty collections | Eliminates NullPointerException churn at call sites |
| Mutable public fields | Private final fields with accessors | Encapsulation preserves invariants and eases refactoring |
| String concatenation in loops | StringBuilder or String.join | Avoids quadratic copying and intermediate garbage |
| Raw types | Parameterized generics with bounds | Type safety and self-documenting APIs |
| Synchronized on mutable objects | private final Object lock = new Object() | Prevents external code from acquiring your lock |
| Creating threads directly | ExecutorService with named thread pools | Bounded resources, graceful shutdown, observability |
| Swallowing exceptions | Log, wrap, or rethrow with context | Silent failures hide data corruption and outages |
| Reflection for routine work | Interfaces, records, dependency injection | Compile-time safety, better performance, easier testing |
| System.out.println | SLF4J/Logback with structured levels | Configurable output, log aggregation, production tuning |
| Massive @Component classes | Small, focused services with single responsibilities | Easier to test, reason about, and reuse |
warning
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.
| 1 | // Good — immutable value object with factory method |
| 2 | public 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 |
| 29 | public 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.
| 1 | public 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 |
| 11 | EmailAddress email = new EmailAddress("ada@example.com"); |
| 12 | email.value(); // no getter noise |
best practice
| 1 | // Interface defines the capability |
| 2 | public interface PaymentProcessor { |
| 3 | PaymentResult process(Payment payment); |
| 4 | } |
| 5 | |
| 6 | // Composed behavior, not inherited |
| 7 | public 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 | } |
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.
| 1 | // Good — declarative, single pass, no mutation |
| 2 | List<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 |
| 9 | List<String> result = new ArrayList<>(); |
| 10 | for (User user : users) { |
| 11 | if (user.isActive()) { |
| 12 | result.add(user.name()); |
| 13 | } |
| 14 | } |
| 15 | Collections.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.
| 1 | // Bad — side effect and race hazard |
| 2 | AtomicInteger total = new AtomicInteger(0); |
| 3 | orders.stream().forEach(o -> total.addAndGet(o.amount())); |
| 4 | |
| 5 | // Good — reduction expresses intent |
| 6 | int total = orders.stream() |
| 7 | .mapToInt(Order::amount) |
| 8 | .sum(); |
| 9 | |
| 10 | // Grouping with collectors |
| 11 | Map<String, Long> countByCategory = products.stream() |
| 12 | .collect(Collectors.groupingBy(Product::category, Collectors.counting())); |
| 13 | |
| 14 | // Downstream collection into immutable structures |
| 15 | Map<String, List<String>> namesByCity = people.stream() |
| 16 | .collect(Collectors.groupingBy( |
| 17 | Person::city, |
| 18 | Collectors.mapping(Person::name, Collectors.toList()) |
| 19 | )); |
warning
| 1 | // Use Optional results responsibly |
| 2 | Optional<Order> firstLarge = orders.stream() |
| 3 | .filter(o -> o.amount() > 1_000) |
| 4 | .findFirst(); |
| 5 | |
| 6 | firstLarge.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 |
| 12 | public Optional<Customer> findById(UUID id) { ... } |
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.
| 1 | // Good — bounded thread pool with meaningful names |
| 2 | ExecutorService 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 | |
| 12 | try { |
| 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.
| 1 | public 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
| 1 | public 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 |
| 15 | public synchronized void update() { ... } |
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.
| 1 | // Good — thin controller delegates to a service |
| 2 | @RestController |
| 3 | @RequestMapping("/api/orders") |
| 4 | class 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.
| 1 | @Service |
| 2 | class 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
| 1 | # application.yml — externalize environment-specific config |
| 2 | server: |
| 3 | port: 8080 |
| 4 | |
| 5 | spring: |
| 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 | |
| 15 | management: |
| 16 | endpoints: |
| 17 | web: |
| 18 | exposure: |
| 19 | include: health,info,metrics,prometheus |
| 20 | endpoint: |
| 21 | health: |
| 22 | show-details: when_authorized |
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.
| 1 | @ExtendWith(MockitoExtension.class) |
| 2 | class 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.
| 1 | // Good — descriptive name, single concept |
| 2 | @Test |
| 3 | void shouldRejectOrderWhenInventoryIsInsufficient() { ... } |
| 4 | |
| 5 | // Bad — vague name, multiple concepts |
| 6 | @Test |
| 7 | void testOrder() { ... } |
| 8 | |
| 9 | // Integration test slice |
| 10 | @DataJpaTest |
| 11 | class 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
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.
| 1 | import org.slf4j.Logger; |
| 2 | import org.slf4j.LoggerFactory; |
| 3 | |
| 4 | @Service |
| 5 | public 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.
| 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
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.
| 1 | # Sensible starting point for a containerized Spring Boot service |
| 2 | java -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.
| 1 | # Generational ZGC for low-latency services (Java 21+) |
| 2 | java -XX:+UseZGC -XX:+ZGenerational -XX:MaxRAMPercentage=75.0 -jar app.jar |
info
| Goal | Flags to Consider | Notes |
|---|---|---|
| Container awareness | -XX:+UseContainerSupport -XX:MaxRAMPercentage | Honors cgroup limits in Docker/Kubernetes |
| Low latency | -XX:+UseZGC -XX:MaxGCPauseMillis | Sub-millisecond pauses on large heaps |
| Throughput | -XX:+UseParallelGC | Best for batch jobs, not interactive services |
| Observability | -Xlog:gc* -XX:+HeapDumpOnOutOfMemoryError | Essential for production debugging |
| Startup time | -XX:+TieredCompilation -XX:TieredStopAtLevel=1 | Trade peak throughput for faster startup |
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.
| 1 | // Pitfall 1: double for money |
| 2 | double total = 0.1 + 0.2; // 0.30000000000000004 |
| 3 | // Use BigDecimal instead |
| 4 | BigDecimal total = new BigDecimal("0.1").add(new BigDecimal("0.2")); |
| 5 | |
| 6 | // Pitfall 2: BigDecimal with double constructor |
| 7 | new BigDecimal(0.1); // imprecise |
| 8 | new BigDecimal("0.1"); // exact |
| 9 | |
| 10 | // Pitfall 3: comparing strings with == |
| 11 | String a = new String("hi"); |
| 12 | String b = new String("hi"); |
| 13 | if (a == b) { ... } // false; use a.equals(b) |
| 14 | |
| 15 | // Pitfall 4: leaking resources |
| 16 | try (InputStream in = Files.newInputStream(path)) { |
| 17 | // auto-closed |
| 18 | } |
| 19 | |
| 20 | // Pitfall 5: modifying a collection while iterating |
| 21 | for (User u : users) { |
| 22 | if (!u.isActive()) users.remove(u); // ConcurrentModificationException |
| 23 | } |
| 24 | users.removeIf(u -> !u.isActive()); // safe |
danger
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.
| Check | Automated? | Details |
|---|---|---|
| Null safety | SpotBugs / NullAway | Optional, requireNonNull, validated records |
| Immutability | Review | final fields, unmodifiable collections, records |
| Concurrency | Review + jcstress | Private locks, atomic usage, executor shutdown |
| Resource handling | Review | try-with-resources, AutoCloseable, connection pools |
| Exception hygiene | Review | No swallowed exceptions, specific catch blocks |
| Test coverage | JaCoCo | Unit, integration, and edge-case tests |
| Logging | Review | Appropriate levels, no PII, parameterized messages |
| Dependencies | OWASP dependency-check | No known CVEs, minimal scope |
| Performance | JMH + profiling | No accidental O(n²), sensible collection choices |
best practice
These references back the recommendations in this guide and remain useful throughout a Java engineering career.
| Resource | Topic |
|---|---|
| Effective Java | Joshua Bloch — canonical Java best practices |
| Java Concurrency in Practice | Goetz et al. — threading and concurrent design |
| OpenJDK Documentation | openjdk.org — release notes, GC guides, migration |
| Spring Guides | spring.io/guides — practical Spring Boot tutorials |
| JUnit 5 User Guide | junit.org/junit5 — tests, extensions, parameterized tests |
| SLF4J Manual | slf4j.org — logging facades and migration |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.