Skip to content
Java best practices 7 min read

Java Best Practices

Writing Java that works is one thing — writing Java that lasts is another. Best practices are the hard-won lessons of thousands of engineers distilled into habits that make your code easier to read, debug, extend, and hand off to the next person (who might be you, six months from now).

This section collects the most important principles, patterns, and pitfalls so you can level up whether you are writing your first class or your thousandth.

In This Section

  • Common Mistakes & Pitfalls — The bugs and anti-patterns that trip up Java developers most often, with clear explanations and fixes.
  • Clean Code in Java — Practical techniques for writing readable, well-structured Java that your teammates will actually enjoy maintaining.

Why Best Practices Matter

Bad habits compound. A variable named x is fine in a five-line example but becomes a nightmare in a 500-line class. A missing null check is harmless until it fires in production at 2 a.m. Best practices are not bureaucratic rules — they are short-cuts to code you can be proud of.

Tip: Treat best practices as defaults, not dogma. Every rule has exceptions; knowing why a rule exists tells you when to break it safely.

Naming Things Well

Java is verbose by design, and good names are your best documentation. Follow the naming conventions the language community has agreed on:

  • Classes and interfacesPascalCase: OrderService, Comparable
  • Methods and variablescamelCase: calculateTotal(), isActive
  • ConstantsUPPER_SNAKE_CASE: MAX_RETRY_COUNT
  • Packages — all lowercase, reverse domain: com.example.billing

Names should express intent, not implementation:

// Poor — what does "d" mean?
int d = 86400;

// Good — intent is clear
int SECONDS_IN_A_DAY = 86400;

// Poor — what does this return?
boolean check(User u) { ... }

// Good — reads like a sentence
boolean isEligibleForDiscount(User user) { ... }

Prefer Immutability Where Possible

Immutable objects cannot be modified after construction. They are inherently thread-safe, easy to reason about, and safe to share freely. Use the final keyword on fields that should not change:

public final class Money {
    private final long amountCents;
    private final String currency;

    public Money(long amountCents, String currency) {
        this.amountCents = amountCents;
        this.currency = currency;
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amountCents + other.amountCents, this.currency);
    }
}

For data carriers in modern Java (17+), Records give you immutability for free:

record Point(double x, double y) {}

Favour Interfaces Over Concrete Types

Program to abstractions. Declare variables and parameters using interfaces rather than concrete classes. This keeps your code flexible and easier to test:

// Tightly coupled — hard to swap implementation
ArrayList<String> names = new ArrayList<>();

// Loosely coupled — easy to substitute LinkedList, etc.
List<String> names = new ArrayList<>();

The same principle applies to method return types and parameters — return List, Map, or Set rather than ArrayList, HashMap, or HashSet.

Handle Exceptions Properly

Exception handling is one of the most abused areas of Java. A few firm rules:

Anti-patternWhy it hurtsBetter approach
Empty catch blockSwallows errors silentlyAt minimum, log the exception
Catch Exception everywhereHides programming errorsCatch specific types
Use exceptions for flow controlExpensive and confusingUse if checks instead
Throw new Exception("message")Loses stack contextCreate a custom exception
// Bad — swallows the error
try {
    processOrder(order);
} catch (Exception e) {
    // nothing here
}

// Good — specific type, logged, re-thrown with context
try {
    processOrder(order);
} catch (OrderNotFoundException e) {
    logger.error("Order {} not found during processing", order.getId(), e);
    throw new OrderProcessingException("Could not process order " + order.getId(), e);
}

Warning: Never catch Error or Throwable in general application code. Errors like OutOfMemoryError signal JVM-level problems that you usually cannot recover from.

See try-catch, throws, and exception propagation for the full picture.

Use StringBuilder for String Assembly

Strings in Java are immutable. Concatenating inside a loop creates a new String object on every iteration — that is O(n²) work and significant garbage:

// Bad — O(n²) allocations
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i; // creates a new String each time
}

// Good — O(n) with StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result = sb.toString();

Note: The compiler automatically converts "a" + "b" + "c" to a StringBuilder chain — but only for single-expression concatenations, not for loop bodies.

See StringBuilder and String vs StringBuffer for a detailed comparison.

Close Resources with try-with-resources

Any object that implements AutoCloseable (files, streams, database connections) must be closed when you are done. The try-with-resources statement introduced in Java 7 handles this automatically, even when exceptions occur:

// Old way — easy to forget the finally block
BufferedReader reader = null;
try {
    reader = new BufferedReader(new FileReader("data.txt"));
    System.out.println(reader.readLine());
} finally {
    if (reader != null) reader.close();
}

// Modern way — clean and safe
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
    System.out.println(reader.readLine());
} // reader.close() is called automatically

This pattern is critical for JDBC connections, sockets, and any I/O work.

Override equals() and hashCode() Together

If you override equals() on a class, you must also override hashCode(). The contract between the two methods is relied on by every hash-based collection (HashMap, HashSet, etc.):

public class Product {
    private final String sku;
    private final String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Product p)) return false;
        return Objects.equals(sku, p.sku);
    }

    @Override
    public int hashCode() {
        return Objects.hash(sku);
    }
}

Warning: If two objects are equals(), they must return the same hashCode(). Violating this contract silently breaks HashMap and HashSet lookups.

Use Optional Instead of Returning null

Returning null to signal “no value” forces every caller to remember to null-check. Java 8+ Optional makes the absence of a value explicit and composable:

// Fragile — caller may forget null check
public User findById(int id) {
    return database.lookup(id); // might be null
}

// Safe — the type itself communicates that absence is possible
public Optional<User> findById(int id) {
    return Optional.ofNullable(database.lookup(id));
}

// Caller uses it safely
findById(42)
    .map(User::getEmail)
    .ifPresent(email -> sendWelcome(email));

Leverage the Collections Framework

Avoid reinventing data structures. The Collections Framework provides highly optimised, well-tested implementations. Pick the right tool:

NeedUse
Ordered list, frequent readsArrayList
Frequent insertions/deletionsLinkedList
Fast lookup, no duplicatesHashSet
Sorted unique elementsTreeSet
Key-value pairs, fast accessHashMap
Insertion-order mapLinkedHashMap
Sorted mapTreeMap

Use the Collections utility class for sorting, searching, and creating unmodifiable views.

Write Small, Focused Methods

A method should do one thing and do it well. A good rule of thumb: if you cannot summarise a method’s job in a single sentence without the word “and”, it probably does too much. Short methods are easier to test, name, and understand.

// Hard to name, hard to test — does two things
public void processAndSave(Order order) {
    double discount = order.getTotal() > 100 ? 0.1 : 0.0;
    order.setFinalPrice(order.getTotal() * (1 - discount));
    database.save(order);
}

// Two focused methods — each is easy to test independently
public double calculateDiscount(Order order) {
    return order.getTotal() > 100 ? 0.1 : 0.0;
}

public void saveOrder(Order order) {
    order.setFinalPrice(order.getTotal() * (1 - calculateDiscount(order)));
    database.save(order);
}

Under the Hood

Understanding what happens below the surface helps you make smarter decisions:

String concatenation in loops. Before Java 9, + inside a loop compiled to repeated StringBuilder create-append-toString chains — a new object per iteration. From Java 9+, invokedynamic + StringConcatFactory can batch some cases, but loops are still not optimised — use StringBuilder manually.

equals() and hashCode() in HashMaps. When you call map.get(key), the JVM computes key.hashCode() to find the bucket, then walks the bucket comparing with equals(). A broken hashCode() (e.g., always returning 0) degrades the map to O(n) performance because everything lands in one bucket.

Final fields and the JMM. The Java Memory Model gives final fields a special safe publication guarantee: once a constructor finishes, any thread that can see the object reference is guaranteed to see the correct values of all final fields — no volatile or synchronisation needed.

Optional is not magic. Optional is a plain wrapper object on the heap. Wrapping a long in Optional<Long> boxes the primitive and allocates the wrapper — in hot paths, null checks can be faster. Use OptionalInt, OptionalLong, OptionalDouble for primitives when performance matters.

Last updated June 13, 2026
Was this helpful?