Java Coding Best Practices
This page lists good habits for writing Java code. These habits make code easier to read, test, change, and run safely. They apply to everyone: SDET, SDE, and SRE.
Each practice has:
- Do โ the good way.
- Why โ the reason in simple words.
- An example, where it helps.
Read it once after you finish Part 2 of the Java cheat sheet, then use the checklist at the end before every commit. Pick two or three habits to practise each week rather than all at once.
Contentsโ
- Naming
- Methods
- Classes and Design
- Null Handling
- Exceptions
- Collections and Streams
- Resources: Files, Connections, Streams
- Logging
- Concurrency
- Testing
- Security
- Performance
- Project and Build
- Tools That Help
- Short Checklist Before You Commit
1. Namingโ
In short: a good name makes a comment unnecessary.
| Thing | Style | Example |
|---|---|---|
| Class, record, interface, enum | PascalCase, a noun | OrderService, PaymentStatus |
| Method | camelCase, a verb | calculateTotal(), isExpired() |
| Variable, parameter | camelCase, a noun | retryCount, customerId |
Constant (static final) | UPPER_SNAKE_CASE | MAX_RETRIES |
| Package | all lower case, reversed domain | com.example.orders |
| Test method | the behaviour it checks | rejectsExpiredCard() |
- Do name booleans as questions:
isActive,hasAccess,canRetry. Why:if (user.isActive())reads like a sentence. - Do avoid abbreviations:
customer, notcust;count, notcnt. Why: code is read far more often than it's typed.
2. Methodsโ
In short: small methods that do one thing, with few inputs.
- Do keep methods short โ if you need a comment to separate "steps", each step is probably its own method. Why: short methods are easy to name, test, and reuse.
- Do keep parameters to three or fewer; group more into a record.
Why:
createUser(name, email, age, role, active)is easy to call with arguments in the wrong order.
// Harder to use correctly
static void createUser(String name, String email, int age, String role, boolean active) { }
// Easier: one clear input
record NewUser(String name, String email, int age, String role, boolean active) { }
static void createUser(NewUser user) { }
- Do return early to avoid deep nesting ("guard clauses").
static double discount(String customerType, double total) {
if (total <= 0) return 0; // guard: nothing to discount
if (!"VIP".equals(customerType)) return 0;
return total * 0.1; // the main rule, not nested
}
- Do avoid
booleanflag parameters likesave(order, true). Why: nobody knows whattruemeans at the call site. Write two methods (save,saveAndNotify) or pass an enum.
3. Classes and Designโ
In short: small classes, private data, and depend on interfaces.
- Do make fields
privateandfinalwherever you can. Why: a field that can't change can't be changed by mistake. - Do use a
recordfor plain data (DTOs โ Data Transfer Objects โ API responses, test data). Why: you get constructor, getters,equals,hashCode,toStringfor free, and the object can't change. - Do pass dependencies in through the constructor ("dependency injection").
interface PaymentGateway { boolean charge(String card, long cents); }
class CheckoutService {
private final PaymentGateway gateway; // depends on the interface
CheckoutService(PaymentGateway gateway) { // given from outside
this.gateway = gateway;
}
boolean pay(String card, long cents) {
return gateway.charge(card, cents);
}
}
Why: in a test you can pass a fake gateway; in production, the real one. No code change needed.
- Do prefer composition ("has a") over inheritance ("is a"). Why: deep class trees are hard to change โ a fix in the parent can break every child.
- Do always override
equalsandhashCodetogether (or use a record). Why: a class with onlyequalsmisbehaves inHashSetandHashMap.
4. Null Handlingโ
In short: make "no value" visible instead of letting null travel.
- Do return an empty collection, never
null, from methods that return lists.
List<String> findTags(String id) {
return List.of(); // not null โ callers can loop safely
}
- Do return
Optional<T>when a single result may be missing. - Do check inputs at the boundary with
Objects.requireNonNull.
import java.util.Objects;
class Order {
private final String id;
Order(String id) {
this.id = Objects.requireNonNull(id, "id must not be null"); // fail fast, clear message
}
}
- Do put the known value first when comparing:
"VIP".equals(type). Why: it can't throwNullPointerExceptioneven iftypeisnull. - Don't use
Optionalfor fields, parameters, or collections. Why: it was designed for return values; elsewhere it just adds noise.
5. Exceptionsโ
In short: fail loudly with a clear message, and never hide a failure.
- Do catch the most specific exception you can handle.
- Don't swallow exceptions.
// Bad โ the failure disappears
try { riskyCall(); } catch (Exception e) { }
// Good โ handle it or pass it on, keeping the original as the "cause"
try {
riskyCall();
} catch (java.io.IOException e) {
throw new IllegalStateException("could not load settings", e);
}
static void riskyCall() throws java.io.IOException { }
- Do use unchecked exceptions (
extends RuntimeException) for your own business errors, and give them a clear name. Why:OrderNotFoundExceptionsays more thanRuntimeException("oops"), and callers aren't forced to write emptycatchblocks. - Do include the useful values in the message:
"order 42 not found", not"not found". - Don't use exceptions for normal control flow (e.g. to leave a loop). Why: it's slow and hides what the code is doing.
6. Collections and Streamsโ
In short: declare the interface, pick the right implementation, keep streams simple.
- Do declare variables by interface:
List<String> names = new ArrayList<>();Why: you can switch the implementation later without touching callers. - Do use
List.of,Set.of,Map.offor fixed data, andList.copyOfwhen handing out a list you own. Why: callers can't change your data behind your back. - Do use a
SetorMapfor "is X in here?" checks, not aList. Why:HashSet.containsstays fast at any size;List.containschecks every item. - Do keep stream chains short and readable; use a loop when it's clearer.
record User(String name, int age, boolean active) { }
List<User> users = List.of(new User("Ada", 36, true), new User("Bo", 15, true));
// Clear: each step does one thing
List<String> activeAdults = users.stream()
.filter(User::active)
.filter(u -> u.age() >= 18)
.map(User::name)
.toList();
- Don't change outside variables from inside a stream (
forEach(x -> list.add(x))). Why: it breaks with parallel streams and hides the result. UsetoList()or a collector.
7. Resources: Files, Connections, Streamsโ
In short: anything you open, close โ use try-with-resources.
import java.nio.file.*;
import java.io.*;
static long countLines(Path file) throws IOException {
try (var lines = Files.lines(file)) { // closed even if an exception is thrown
return lines.count();
}
}
Why: leaked file handles and database connections make long-running
services fail hours later, far from the real bug. This applies to anything
that implements AutoCloseable โ files, JDBC (Java Database Connectivity)
connections, HTTP clients, ExecutorService (Java 19+).
8. Loggingโ
In short: use a logger, not System.out.println, and log facts, not noise.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
void pay(String orderId, long cents) {
log.info("Charging order {} for {} cents", orderId, cents); // {} placeholders
try {
charge();
} catch (RuntimeException e) {
log.error("Charge failed for order {}", orderId, e); // exception last โ full stack trace
throw e;
}
}
void charge() { }
}
- Do use
{}placeholders instead of+. Why: the message is only built if that level is switched on. - Do pick the right level:
ERRORneeds a human,WARNis unusual but handled,INFOis a business event,DEBUGis for developers. - Don't log passwords, tokens, card numbers, or personal data.
SLF4J (Simple Logging Facade for Java) is the common logging API; Logback or Log4j 2 does the actual writing.
9. Concurrencyโ
In short: share less, and use the ready-made thread-safe tools.
- Do use an
ExecutorServicerather thannew Thread(...). - Do prefer immutable objects (records,
List.copyOf) for data shared between threads. - Do use
ConcurrentHashMapandAtomicIntegerrather thansynchronizedblocks around aHashMaporint. - Do always set timeouts on waits:
future.get(5, TimeUnit.SECONDS). Why: a call that never returns will eventually freeze every thread. - Don't use
Thread.sleepto "wait until it's ready" in real code or tests; wait for the actual condition (aCountDownLatch, a polling loop with a deadline, or Awaitility in tests).
10. Testingโ
In short: fast, independent tests that each check one behaviour.
- Do follow Arrange โ Act โ Assert, with a blank line between them.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
static double discount(String type, double total) {
return "VIP".equals(type) ? total * 0.1 : 0;
}
@Test
void vipGetsTenPercent() {
double total = 200; // Arrange
double result = discount("VIP", total); // Act
assertEquals(20, result, 0.001); // Assert
}
}
- Do make tests independent: no shared mutable state, no required order.
- Do use
@ParameterizedTestfor the same check with many inputs. - Do mock only what you don't own or what is slow (HTTP, database, clock). Why: over-mocked tests pass while the real code is broken.
- Do test the edge cases: empty,
null, zero, negative, very large, duplicates.
11. Securityโ
In short: never trust input, never store secrets in code.
- Do use
PreparedStatementparameters for SQL, never string joining.
import java.sql.*;
static ResultSet findUser(Connection db, String name) throws SQLException {
// BAD: "SELECT * FROM users WHERE name = '" + name + "'" โ SQL injection
PreparedStatement ps = db.prepareStatement("SELECT * FROM users WHERE name = ?");
ps.setString(1, name); // treated purely as a value
return ps.executeQuery();
}
- Do read secrets from environment variables or a secrets manager:
System.getenv("DB_PASSWORD"). - Do keep dependencies up to date and scan them (OWASP Dependency-Check, Dependabot). Why: most real-world Java breaches come from old libraries, not your code.
- Don't deserialize untrusted data with
ObjectInputStream; use JSON with a fixed target class instead.
12. Performanceโ
In short: make it correct and clear first, then measure, then optimise the slow part only.
- Do use
StringBuilderwhen building text in a loop. - Do pick the right collection (see the collection speed table).
- Do size collections when you know the size:
new ArrayList<>(10_000). - Do measure with a profiler (Java Flight Recorder, async-profiler) or JMH
(Java Microbenchmark Harness) โ not with guesses or one-off
System.nanoTimeruns. - Don't create expensive objects in hot loops: compile a
Patternonce, reuse oneObjectMapper, reuse oneHttpClient.
13. Project and Buildโ
In short: standard layout, pinned versions, one command to build.
my-service/
โโโ pom.xml (or build.gradle.kts)
โโโ src/main/java/com/example/orders/ โ code, one package per feature
โโโ src/main/resources/ โ config files
โโโ src/test/java/com/example/orders/ โ tests mirror the code packages
- Do pin dependency versions and commit the Gradle/Maven wrapper
(
gradlew,mvnw). Why: everyone, including CI (Continuous Integration), builds with exactly the same versions. - Do target an LTS (Long-Term Support) Java version: 17, 21, or 25.
- Do package by feature (
orders,payments), not by layer (controllers,services). Why: everything about one feature sits together.
14. Tools That Helpโ
| Tool | What it does |
|---|---|
| IntelliJ IDEA inspections | Highlights bugs and simplifications as you type |
| Checkstyle / Spotless | Enforces one formatting style |
| SpotBugs / Error Prone | Finds likely bugs (null risks, bad equals) at build time |
| SonarQube / SonarLint | Code-quality and security findings |
| JaCoCo | Shows which lines your tests cover |
| OWASP Dependency-Check | Flags libraries with known vulnerabilities |
15. Short Checklist Before You Commitโ
- Names say what things are and do; no unexplained abbreviations.
- Methods are short, with three or fewer parameters.
- Fields are
private finalwhere possible; data classes are records. - No method returns
nullfor a collection; single "maybe" results useOptional. - No empty
catchblocks; exceptions keep their cause. - Every file, connection, and stream is opened in
try-with-resources. - Logging uses
{}placeholders and contains no secrets. - SQL uses
PreparedStatementparameters. - New behaviour has tests, including edge cases, and
mvn test/./gradlew testpasses. - The build shows no new compiler or linter warnings.