Lesson 24 of 25

Java Best Practices

Write for the Person Who Reads It Next

Code is read far more often than it is written, and the reader is usually you, six months later, with no memory of what you were thinking. Every practice in this lesson comes back to that. The goal is not clever code; it is code whose intent is obvious.

Naming carries most of the weight. A variable called d forces the reader to work out what it holds; daysUntilExam tells them. Length is not the enemy — ambiguity is. Java's conventions are worth following exactly, because every codebase and every interviewer expects them, and deviating creates friction for no benefit.

The second big win is eliminating magic numbers. A bare 0.18 buried in a calculation means nothing; GST_RATE means something, appears once, and can be changed in one place. The same applies to repeated string literals.

Third, keep methods short and single-purpose. If you cannot describe what a method does in one sentence without saying "and", it is doing two things and wants to be two methods. Short methods are easier to name, easier to test, and easier to reuse — and the act of naming the extracted piece often reveals a bug.

Example
// ---- Hard to read ----
public double calc(double a, int b) {
    double t = a * 0.18;
    double d = 0;
    if (b > 5) d = a * 0.1;
    return a + t - d;
}

// ---- Same logic, readable ----
public class Billing {
    private static final double GST_RATE = 0.18;
    private static final double LOYALTY_DISCOUNT_RATE = 0.10;
    private static final int LOYALTY_YEARS_REQUIRED = 5;

    public double finalAmount(double baseAmount, int customerYears) {
        double gst = baseAmount * GST_RATE;
        double discount = qualifiesForLoyalty(customerYears)
                          ? baseAmount * LOYALTY_DISCOUNT_RATE
                          : 0;
        return baseAmount + gst - discount;
    }

    private boolean qualifiesForLoyalty(int customerYears) {
        return customerYears > LOYALTY_YEARS_REQUIRED;
    }
}
  • camelCase for variables and methods, PascalCase for classes, UPPER_SNAKE_CASE for constants
  • Methods are verbs (calculateTotal), classes are nouns (Invoice), booleans are questions (isActive, hasPaid)
  • Never abbreviate beyond what everyone knows — id and url are fine, cstAmt is not
  • Replace every literal number and repeated string with a named static final constant
  • Aim for methods that fit on one screen; extract anything longer
  • Write comments that explain why, not what — the code already says what it does
  • Use final on variables and parameters that should not be reassigned; it costs one word and prevents a class of bug
Notes
  • A comment that repeats the code is worse than no comment, because it will drift out of date and then actively mislead. // add 1 to i is noise. // the university rounds 0.5 upward, unlike Math.round is valuable.

Handling null, and Using Optional Properly

NullPointerException is the most common runtime failure in Java, and most of them are avoidable by deciding, deliberately, where null is allowed.

Three habits remove most of them. First, never return null from a method that returns a collection — return an empty list. Every caller then loops safely with no check, and forgetting the check becomes impossible. Second, validate arguments at the boundary with Objects.requireNonNull, which fails immediately with a clear message rather than several layers deeper where the cause is unclear. Third, put the constant first in comparisons: "PASS".equals(status) can never throw, while status.equals("PASS") can.

Optional<T> is Java's way of saying "there might not be a value here" in the type system itself. A method returning Optional<Student> forces the caller to acknowledge the empty case, because they cannot use the value without unwrapping it.

It is very commonly misused. Writing if (opt.isPresent()) { opt.get(); } reproduces exactly the null check you were trying to escape, with extra typing. Use the methods designed for it: orElse for a fallback value, orElseGet when computing the fallback is expensive, orElseThrow when absence really is an error, map to transform, and ifPresent to act only when there is something.

Use Optional as a return type, which is what it was designed for. Do not use it for fields, and do not use it for method parameters — an overloaded method or a plain null check reads better there.

Example
import java.util.*;

// ---- Never return null for a collection ----
public List<Student> findByCity(String city) {
    if (city == null) return List.of();      // empty, not null
    // ...
    return results;
}
// The caller can now write this with no null check at all:
// for (Student s : findByCity("Pune")) { ... }

// ---- Fail fast at the boundary ----
public void enrol(Student student, String course) {
    Objects.requireNonNull(student, "student must not be null");
    Objects.requireNonNull(course, "course must not be null");
    // ...
}

// ---- Constant first, so it can never throw ----
String status = null;
// status.equals("PASS")            // NullPointerException
System.out.println("PASS".equals(status));            // false, safe
System.out.println(Objects.equals(status, "PASS"));   // false, safe

// ---- Optional as a return type ----
public Optional<Student> findByRoll(int rollNo) {
    Student found = database.get(rollNo);              // may be null
    return Optional.ofNullable(found);
}

// The anti-pattern: this is just a null check again
// Optional<Student> opt = findByRoll(101);
// if (opt.isPresent()) { System.out.println(opt.get().name()); }

// Use the methods it provides
String name = findByRoll(101)
        .map(Student::name)
        .orElse("Not enrolled");

findByRoll(101).ifPresent(s -> System.out.println("Found " + s.name()));

Student required = findByRoll(101)
        .orElseThrow(() -> new NoSuchElementException("No student with roll 101"));
Notes
  • optional.get() without checking first throws NoSuchElementException — the same crash as a null dereference, with a different name. If you are reaching for get(), one of orElse, orElseGet or orElseThrow is almost certainly what you want.

Design Habits That Age Well

Everything below has appeared in earlier lessons; collected together, these are the decisions that decide whether a project is still pleasant to work on after six months.

The single most valuable one is declaring variables by the interface type. List<String> names = new ArrayList<>(); and methods that accept List rather than ArrayList let the implementation change without touching the callers. It costs nothing to do and is expensive to retrofit.

The second is immutability by default. Make fields final, avoid setters, and return copies of mutable internals. An object that cannot change cannot change incorrectly, cannot be corrupted by a caller, and is automatically safe to share between threads.

The third is keeping classes focused. A class whose name contains "Manager", "Helper", "Util" or "Data" is often several classes that have not been separated yet. When you cannot describe a class's job in a sentence, it has more than one.

  • Declare by the interface: List, Map, Set — not ArrayList, HashMap, HashSet
  • Prefer composition over inheritance; use extends only when "is a" is genuinely true
  • Make fields private and final unless there is a reason not to
  • Never leak a mutable internal collection from a getter — return List.copyOf(...) or a copy
  • Validate in constructors so an invalid object can never exist
  • Always override hashCode() when you override equals()
  • Use record for pure data carriers and enum for fixed sets of values
  • Always use try-with-resources for anything that must be closed
  • Never write a raw type such as List without a type argument
Example
import java.util.*;

// enum instead of loose String constants: the compiler now checks every use
public enum PaymentStatus {
    PENDING, PAID, REFUNDED, FAILED
}

// A switch over an enum needs no default when every case is covered
public String message(PaymentStatus status) {
    return switch (status) {
        case PENDING  -> "Awaiting payment";
        case PAID     -> "Payment received";
        case REFUNDED -> "Amount refunded";
        case FAILED   -> "Payment failed";
    };
}
// Add a new constant later and the compiler flags every switch that is now
// incomplete — a String constant would have failed silently at run time.

// Interface types in declarations and signatures
public double average(List<Integer> marks) {         // List, not ArrayList
    return marks.stream().mapToInt(Integer::intValue).average().orElse(0);
}

// A record for data, immutable by construction
public record Invoice(String number, long amountPaise, PaymentStatus status) { }
Notes
  • An enum is worth reaching for far earlier than most beginners do. Any time a field can only hold one of a small fixed set of strings — status, category, day of week, user role — an enum makes the invalid values unwriteable rather than merely discouraged.

Performance: What Matters and What Does Not

Most performance advice repeated in tutorials is either wrong or irrelevant at the scale you are working. A few things genuinely matter, and they are all about algorithms and data structures rather than micro-optimisation.

The largest single win in beginner code is replacing a list lookup inside a loop with a set or map. Calling list.contains() for each of n items scans the list every time, so the work grows with the square of the size. Swap in a HashSet and each check is effectively instant. On a few thousand records this is the difference between a program that finishes and one that appears to hang.

The second is string building in loops. Each result += x copies the entire string built so far, so a loop of ten thousand iterations copies an enormous amount of data. StringBuilder makes it linear.

The third is not creating work you do not need: reading a whole file into memory when you could stream it line by line, or querying inside a loop when one query would do.

Everything else is almost always premature. Choosing i++ over ++i, avoiding a method call, or replacing a for loop with a stream will not change your program's speed measurably. Write the clear version first; measure with a profiler if it turns out to be slow; optimise the specific thing the measurement points at.

Example
import java.util.*;

// ---- The one that actually matters ----
List<String> registered = new ArrayList<>();   // 10,000 roll numbers
List<String> attending  = new ArrayList<>();   // 10,000 roll numbers

// SLOW: 10,000 x 10,000 comparisons
// for (String roll : attending) {
//     if (registered.contains(roll)) { /* ... */ }
// }

// FAST: build a set once, then every check is instant
Set<String> registeredSet = new HashSet<>(registered);
for (String roll : attending) {
    if (registeredSet.contains(roll)) { /* ... */ }
}

// ---- The second one ----
// String report = "";
// for (String line : lines) report += line + "\n";     // quadratic

StringBuilder report = new StringBuilder();
for (String line : List.of("a", "b", "c")) {
    report.append(line).append('\n');
}

// ---- Pre-size when the count is known ----
List<Integer> marks = new ArrayList<>(10_000);

// ---- Not worth thinking about ----
// i++ vs ++i, one extra method call, a stream versus a loop:
// write whichever reads better.
  • Replace list.contains() inside a loop with a HashSet — the biggest easy win
  • Use StringBuilder when building a string across iterations
  • Pre-size an ArrayList or HashMap when you know roughly how many entries are coming
  • Stream large files line by line instead of loading them whole
  • Use primitives (int) rather than wrappers (Integer) in hot loops to avoid boxing
  • Measure before optimising — assumptions about what is slow are usually wrong
  • Correct and readable first; fast second, and only where a measurement says so
Notes
  • For placement interviews you will be asked about time complexity constantly. Know these by heart: ArrayList.get is O(1) and contains is O(n); HashMap.get and put are O(1) on average; TreeMap operations are O(log n); sorting is O(n log n).

Tools, Testing and What to Learn Next

Two things separate a course project from professional work, and neither is a language feature.

The first is automated tests. JUnit lets you write small methods that check your code does what you claim, and run all of them in a second. The value is not proving the code works today — it is that you can change it fearlessly tomorrow, because anything you break is reported immediately. Writing tests for one class you have already built is the single most useful next step after this course.

The second is a build tool. Maven or Gradle handle compiling, downloading libraries and running tests with one command, and they let anyone else build your project without instructions. Once you want to use a library — for JSON, for databases, for CSV — a build tool stops being optional.

As for the language itself, the modern features are worth adopting deliberately rather than by accident. If you learned from older material you may be writing Java 8 out of habit. Records, var, switch expressions, text blocks and pattern matching for instanceof all make code shorter without making it cleverer, which is exactly the right trade.

Where to go from here depends on your goal. For backend development, learn Spring Boot along with a database and JDBC. For Android, move on to Kotlin, which runs on the same JVM and calls the same libraries. For placements, keep practising data structures and algorithms in Java — the collections and complexity knowledge from this course is directly what those interviews test.

  • JUnit 5 — write tests for the class you are most afraid to change
  • Maven or Gradle — dependency management and reproducible builds
  • Git — non-negotiable, and expected in every interview
  • An IDE you know well — IntelliJ IDEA Community or Eclipse; learn the debugger properly instead of adding print statements
  • A logging framework — SLF4J with Logback, instead of System.out.println, once code goes anywhere real
  • Modern syntax — records, var, switch expressions, text blocks, pattern matching
  • Next steps — Spring Boot and JDBC for backend, Kotlin for Android, DSA practice for placements
Notes
  • Java releases a new version every six months, but only the long-term-support versions matter for jobs. Java 17 and Java 21 are the versions companies actually run, and Java 21 added virtual threads, which make writing highly concurrent servers dramatically simpler. You do not need to chase every release — knowing the current LTS well is worth more.
Ask AI