Java Best Practices

August 19, 20265 min readUpdated 8/20/2026

These are the habits that separate code a team can maintain from code they quietly rewrite. None of them is clever. All of them compound.

Name things properly

// Unreadable
class P {
    int d;
    List<String> l;
    void proc() { }
}

// Readable
class Payment {
    int daysOverdue;
    List<String> failureReasons;
    void retryFailedCharges() { }
}

You write a name once and read it for years. The conventions are fixed and universal: PascalCase for types, camelCase for methods and variables, UPPER_SNAKE_CASE for constants, nouns for classes, verbs for methods, and questions for booleans (isActive, hasExpired).

Avoid abbreviations nobody else shares. calc, tmp and mgr save four characters and cost a guess every time.

Keep methods small and single-purpose

A method should do one thing. The test is whether you can name it without using "and". If validateAndSaveAndNotify is honest, it is three methods.

Use guard clauses so the happy path stays unindented — the example is in Conditional Statements. Deep nesting is the most reliable signal that a method is doing too much.

Prefer immutability

// Mutable: anyone can change it at any time, from anywhere
class MutableOrder {
    private String id;
    public void setId(String id) { this.id = id; }
}

// Immutable: valid on construction, and stays that way
record Order(String id, int quantity) {
    Order {
        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
    }
}

Make fields final by default and remove the keyword only where you have a reason. Immutable objects are automatically thread-safe, cannot be corrupted by a caller, and are safe as map keys. Do not write a setter reflexively — ask whether the value should ever change.

Remember that final freezes the reference, not the object (static and final). For collections, use List.of or copy defensively.

Fail fast, and never hand back null

class Booking {
    void reserve(String seatId, int count) {
        // Check at the boundary, before any work happens
        Objects.requireNonNull(seatId, "seatId");
        if (count <= 0) {
            throw new IllegalArgumentException("count must be positive, got " + count);
        }
        // ...the method body can now trust its inputs
    }
}

An invalid argument should fail at the point it arrives, not three layers down where the message will be meaningless. And put the offending value in the message — "count must be positive, got -3" ends the investigation; "invalid count" starts one.

Do not return null

class Repo {
    // Forces a null check nobody is reminded to write
    String findNameOld(String id) { return null; }

    // Says so in the signature
    Optional<String> findName(String id) { return Optional.empty(); }

    // For collections, return an empty one — never null
    List<String> findAll() { return List.of(); }
}

An empty collection is the single easiest win here: every caller can iterate it without a check, and a returned null list is a NullPointerException waiting for the one code path nobody tested. Use Optional for a single value that may be absent.

Handle exceptions honestly

class Loader {
    void bad(Path path) {
        try {
            Files.readString(path);
        } catch (IOException e) {
            // silence — the failure happened and the evidence is gone
        }
    }

    void good(Path path) {
        try {
            Files.readString(path);
        } catch (IOException e) {
            throw new IllegalStateException("could not read config at " + path, e);   // cause kept
        }
    }
}

Never swallow an exception. Never catch Exception to be safe. Always pass the cause when wrapping. Catch only where you can do something about it — the full argument is in Exception Handling.

Program to interfaces

class Service {
    // Ties every caller to ArrayList forever
    ArrayList<String> namesBad() { return new ArrayList<>(); }

    // Free to change the implementation later
    List<String> names() { return new ArrayList<>(); }
}

Declare variables, parameters and return types as the interface. It costs nothing today and buys you the ability to change your mind.

Write comments that explain why

class Pricing {
    private static final double RATE = 0.0825;

    double total(double subtotal) {
        // i++ increments i                       <- worthless
        // Rate is hardcoded because the tax service has no sandbox and
        // this figure is fixed by statute until 2027. See TICKET-4412.
        return subtotal * (1 + RATE);
    }
}

The code already says what it does. A comment earns its place by explaining why — the constraint, the ticket, the thing that looks wrong and is not. If you need a comment to explain what a block does, extracting it into a well-named method is usually better.

Use the modern language

A quick list of replacements, each covered in its own post:

  • Date, Calendar, SimpleDateFormatjava.time.
  • Data classes with sixty lines of boilerplate → records.
  • Fall-through switch with breakswitch expressions.
  • String concatenation in a loop → StringBuilder or String.join.
  • Multi-line strings with \n escapes → text blocks.
  • Anonymous classes for one method → lambdas.
  • Raw List and casting → generics.

The rule underneath all of them

All of it in one file

From the console bank app this site uses for examples — small, but it applies most of this post at once:

public record User(long id, String email, String password, String fullName, LocalDateTime createdAt) {

    public User {
        if (email == null || email.isBlank()) {
            throw new IllegalArgumentException("A user must have an email");
        }
        email = email.trim().toLowerCase(); // Normalise once, here, so sign-in never worries about it.
    }
}

Immutable by construction. Fails fast, with a message that says what was wrong. Normalises its input once so no later code has to. Names everything in full. And the comment explains why — "so sign-in never worries about it" — rather than restating the line above it.

Test what can break

Tests are how you change code you did not write without being afraid. Write them for the logic that has rules — pricing, validation, state transitions, edge cases — and do not write them for getters to chase a coverage number. A high coverage figure over trivial tests is worse than no figure, because it is a false sense of safety.

Write for the person who reads this next. That person has no context, is under time pressure, and is quite likely you in six months. Every practice above is a way of leaving less for them to reconstruct.

Clever code is a liability. If a line takes a moment to work out, the boring version is better.

Next

The last post in the track is a reference page — the lookups you will make repeatedly, in one place.