Java best practices 5 min read
Best Practices
Opinionated, battle-tested guidance for writing Java that’s easy to read, hard to break, and pleasant to maintain. These are the conventions experienced teams converge on — each with a short rationale so you know why, not just what. Targets modern Java (17+).
Rules of thumb, not laws. The goal is clear, correct, maintainable code — when a guideline fights that goal, the goal wins.
Code Style & Naming
- Name for intent, not type.
customerCountbeatsintCust;activeUsersbeatslist1. Code is read far more than it’s written. - Classes are nouns, methods are verbs.
OrderValidator,calculateTotal(). Booleans read as questions:isEmpty(),hasNext(). - Keep methods short and single-purpose. If you need “and” to describe what a method does, split it.
- Avoid abbreviations and Hungarian notation. Modern IDEs make long names cheap; clarity is worth the keystrokes.
- Make fields
finalby default. Immutability removes a whole class of bugs; loosen it only when you need mutation.
✅ Good
boolean isEligibleForDiscount(Customer customer) {
return customer.loyaltyYears() >= 5;
}
❌ Bad
boolean check(Customer c) { // check what? returns what?
return c.ly() >= 5; // cryptic accessor
}
Object Design
- Prefer immutability. Immutable objects are inherently thread-safe and easy to reason about. Use
recordfor data carriers. - Validate in the constructor. An object should never exist in an invalid state. Throw early if arguments violate invariants.
- Favor composition over inheritance. Inheritance couples you to a base class’s implementation; composition stays flexible.
- Program to interfaces. Declare variables and return types as the interface (
List, notArrayList) to keep callers decoupled. - Keep constructors cheap. No I/O or heavy work in a constructor; use a factory method or builder when construction is complex.
✅ Good
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.signum() < 0) {
throw new IllegalArgumentException("amount must be non-negative");
}
}
}
❌ Bad
public class Money {
public BigDecimal amount; // public mutable field, no validation
public String currency; // any value, anytime, from anywhere
}
Collections
- Choose the right structure.
ArrayListfor indexed access,HashMapfor lookups,ArrayDequefor stacks/queues,EnumMapfor enum keys. - Size collections when you know the count.
new ArrayList<>(expectedSize)avoids repeated resize-and-copy. - Return empty, never
null.Collections.emptyList()or an empty collection saves every caller a null check. - Expose unmodifiable views. Wrap internal collections with
List.copyOforCollections.unmodifiableListbefore returning them. - Use the right map accessor.
getOrDefault,computeIfAbsent, andmergereplace verbose check-then-put idioms.
✅ Good
List<String> getTags() {
return List.copyOf(tags); // defensive, immutable view
}
❌ Bad
List<String> getTags() {
return tags; // caller can mutate your internal state
}
Exceptions
- Throw specific exceptions; catch specific exceptions. Never
catch (Exception e)to silence everything. - Never swallow exceptions. An empty
catchblock hides bugs. At minimum, log with context. - Use try-with-resources for anything closeable. It guarantees cleanup even on exceptions and replaces manual
finallyblocks. - Don’t use exceptions for control flow. They’re for exceptional conditions, not for breaking out of loops.
- Add context when rethrowing. Wrap with a message and preserve the cause:
throw new ServiceException("loading user " + id, e).
✅ Good
try (var reader = Files.newBufferedReader(path)) {
return reader.readLine();
} // reader closed automatically, even on exception
❌ Bad
try {
var reader = Files.newBufferedReader(path);
return reader.readLine();
} catch (IOException e) {
// swallowed: leaks the reader and hides the failure
return null;
}
Concurrency
- Prefer high-level utilities. Reach for
ExecutorService,ConcurrentHashMap, and atomics before raw threads andsynchronized. - Minimize shared mutable state. The easiest concurrency bug to fix is the one you designed away with immutability.
- Hold locks briefly and consistently. Acquire multiple locks in a fixed global order to avoid deadlock.
- Always shut down executors. Call
shutdown()andawaitTermination()so the JVM can exit and tasks can drain. - Use
ConcurrentHashMap, notsynchronizedMap. It scales under contention with lock striping instead of one global lock.
✅ Good
var pool = Executors.newFixedThreadPool(4);
try {
futures.forEach(pool::submit);
} finally {
pool.shutdown(); // always reclaim threads
}
❌ Bad
for (Runnable task : tasks) {
new Thread(task).start(); // unbounded threads, no lifecycle control
}
Performance
- Measure before optimizing. Profile with async-profiler or JFR; intuition about hotspots is usually wrong.
- Use
StringBuilderin loops.String +=in a loop is O(n²) due to repeated allocation. - Avoid premature object allocation. Reuse buffers in hot paths and prefer primitive streams (
IntStream) over boxed ones. - Let the JVM warm up before benchmarking. Use JMH; a cold-start
System.nanoTime()loop measures the interpreter, not steady state. - Cache expensive, idempotent results — but bound the cache so it can’t leak memory.
Testing
- Write small, fast, isolated unit tests. One logical assertion per test; the test name should describe the behavior.
- Use AAA structure. Arrange the inputs, Act on the unit, Assert the outcome — in that visual order.
- Test behavior, not implementation. Asserting on public outcomes lets you refactor internals freely.
- Cover edge cases. Empty inputs, nulls, boundaries, and the unhappy path catch the bugs happy-path tests miss.
- Prefer real integration tests over heavy mocking for I/O. Testcontainers spins up a real database in seconds.
✅ Good
@Test
void transfer_failsWhenBalanceInsufficient() {
var account = new Account(BigDecimal.TEN);
assertThrows(InsufficientFundsException.class,
() -> account.withdraw(BigDecimal.valueOf(50)));
}
A descriptive name plus a focused assertion tells the next engineer exactly what broke when this test goes red.
The short version
| Area | Default choice |
|---|---|
| Object design | Immutable record, validate in constructor |
| Collections | List/Map interface types, return empty not null |
| Exceptions | Specific types, try-with-resources, never swallow |
| Concurrency | ExecutorService + ConcurrentHashMap, avoid shared state |
| Performance | Measure first, StringBuilder, JMH benchmarks |
| Testing | Fast unit tests, AAA, test behavior |
When in doubt, optimize for the next person to read this code — often a future version of you.