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 interfaces —
PascalCase:OrderService,Comparable - Methods and variables —
camelCase:calculateTotal(),isActive - Constants —
UPPER_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-pattern | Why it hurts | Better approach |
|---|---|---|
Empty catch block | Swallows errors silently | At minimum, log the exception |
Catch Exception everywhere | Hides programming errors | Catch specific types |
| Use exceptions for flow control | Expensive and confusing | Use if checks instead |
Throw new Exception("message") | Loses stack context | Create 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
ErrororThrowablein general application code. Errors likeOutOfMemoryErrorsignal 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 aStringBuilderchain — 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 samehashCode(). 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:
| Need | Use |
|---|---|
| Ordered list, frequent reads | ArrayList |
| Frequent insertions/deletions | LinkedList |
| Fast lookup, no duplicates | HashSet |
| Sorted unique elements | TreeSet |
| Key-value pairs, fast access | HashMap |
| Insertion-order map | LinkedHashMap |
| Sorted map | TreeMap |
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.
Related Topics
- Common Mistakes & Pitfalls — learn which habits to avoid before they become bugs in production.
- Clean Code in Java — concrete techniques for writing readable, well-structured code every day.
- Naming Conventions — the community-wide agreements on how to name every Java construct.
- Exception Handling — the full model for handling errors safely and intentionally.
- Collections Framework — choosing and using the right data structure for every situation.
- Java Memory Model — understand the rules that govern visibility and ordering across threads.