Skip to content
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. customerCount beats intCust; activeUsers beats list1. 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 final by 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 record for 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, not ArrayList) 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. ArrayList for indexed access, HashMap for lookups, ArrayDeque for stacks/queues, EnumMap for 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.copyOf or Collections.unmodifiableList before returning them.
  • Use the right map accessor. getOrDefault, computeIfAbsent, and merge replace 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 catch block hides bugs. At minimum, log with context.
  • Use try-with-resources for anything closeable. It guarantees cleanup even on exceptions and replaces manual finally blocks.
  • 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 and synchronized.
  • 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() and awaitTermination() so the JVM can exit and tasks can drain.
  • Use ConcurrentHashMap, not synchronizedMap. 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 StringBuilder in 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

AreaDefault choice
Object designImmutable record, validate in constructor
CollectionsList/Map interface types, return empty not null
ExceptionsSpecific types, try-with-resources, never swallow
ConcurrencyExecutorService + ConcurrentHashMap, avoid shared state
PerformanceMeasure first, StringBuilder, JMH benchmarks
TestingFast unit tests, AAA, test behavior

When in doubt, optimize for the next person to read this code — often a future version of you.

Last updated June 1, 2026
Was this helpful?