Java Best Practices




Writing Java code that compiles and runs is easy. Writing Java code that is readable, maintainable, secure, and performant — code that your teammates (and your future self) will thank you for — is a different challenge entirely. This tutorial distills the practices that separate production-quality Java from weekend-project Java.

We will cover 20 best practices organized into seven categories: code style, object-oriented design, error handling, performance, modern Java features, testing and quality, code organization, and security. Each practice includes concrete bad and good examples so you can immediately apply what you learn.



Code Style and Readability

You read code far more often than you write it. Studies consistently show that developers spend 70-80% of their time reading existing code. The practices in this section make that reading time dramatically more productive.

1. Naming Conventions

Names are the most important form of documentation in your code. A well-chosen name eliminates the need for comments, makes code self-documenting, and reduces the cognitive load on every developer who reads it. Java has established conventions that every professional project follows:

Element Convention Example
Classes and Interfaces PascalCase (nouns) UserAccount, PaymentProcessor
Methods camelCase (verbs) calculateTotal(), sendNotification()
Variables camelCase (nouns) firstName, orderCount
Constants UPPER_SNAKE_CASE MAX_RETRY_COUNT, DEFAULT_TIMEOUT
Packages all lowercase, dot-separated com.example.payment
Type Parameters Single uppercase letter T, E, K, V
Enums PascalCase (type), UPPER_SNAKE_CASE (values) OrderStatus.PENDING

Boolean names deserve special attention. A boolean variable or method should read like a true/false question:

// BAD - unclear what these represent
boolean flag = true;
boolean status = false;
boolean check = user.verify();

// GOOD - reads like a question: "Is it active?" -> true/false
boolean isActive = true;
boolean hasPermission = false;
boolean canExecute = user.hasRole("ADMIN");
boolean shouldRetry = attempts < MAX_RETRIES;
boolean wasProcessed = order.getStatus() == Status.COMPLETE;

Avoid abbreviations. Abbreviations save a few keystrokes but cost minutes of confusion every time someone reads the code. Modern IDEs have autocomplete -- there is no reason to abbreviate.

// BAD - abbreviations force the reader to decode
int qty;
String addr;
UserAcctMgr uam;
public void calcTtlAmt() { ... }

// GOOD - instantly clear
int quantity;
String address;
UserAccountManager userAccountManager;
public void calculateTotalAmount() { ... }

Use meaningful, intention-revealing names. The name should tell you why it exists, what it does, and how it is used. If a name requires a comment, the name is wrong.

// BAD - what does d represent? What are the 86400000?
long d = System.currentTimeMillis() - start;
if (d > 86400000) { ... }

// GOOD - the name tells the whole story
long elapsedTimeMillis = System.currentTimeMillis() - startTimeMillis;
long ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000L;
if (elapsedTimeMillis > ONE_DAY_IN_MILLIS) { ... }

Avoid single-letter variables except in short loop counters (i, j, k) or lambda parameters where the type makes the meaning obvious (e.g., users.forEach(u -> u.activate())).

2. Code Formatting

Consistent formatting is not about personal preference -- it is about team velocity. When everyone follows the same formatting rules, code reviews focus on logic instead of style debates, and every file is instantly familiar.

Key formatting rules:

  • Indentation: Use 4 spaces (not tabs). This is the Java standard.
  • Line length: Keep lines under 120 characters. Long lines force horizontal scrolling and break readability.
  • Braces: Always use braces for if, for, while, even for single statements. This prevents bugs when someone adds a second line.
  • Blank lines: Use blank lines to separate logical blocks within a method, between methods, and between field groups.
  • One statement per line: Never chain multiple statements on a single line.
// BAD - missing braces, inconsistent spacing, dense
if(active) doSomething();
for(int i=0;i<10;i++) process(i);
if (x > 0)
    doThis();
    doThat(); // BUG: doThat() runs ALWAYS, not inside the if

// GOOD - consistent braces, spacing, and structure
if (active) {
    doSomething();
}

for (int i = 0; i < 10; i++) {
    process(i);
}

if (x > 0) {
    doThis();
    doThat(); // clearly inside the if block
}

Use your IDE's auto-format. In IntelliJ IDEA, press Ctrl+Alt+L (Windows/Linux) or Cmd+Option+L (Mac). In Eclipse, press Ctrl+Shift+F. Configure your team's formatting rules once and share the settings file across the project. Consider adding a .editorconfig file to your repository.

3. Comments and Documentation

The best code needs no comments because the names and structure explain everything. When comments are necessary, they should explain why, not what. If you find yourself writing a comment that explains what the code does, consider renaming variables or extracting a method instead.

// BAD - comments that repeat the code (add nothing)
// Increment counter by 1
counter++;

// Check if user is null
if (user == null) {
    return;
}

// Get the list of users from the database
List users = userRepository.findAll();

// GOOD - comments that explain WHY
// Circuit breaker: stop retrying after 3 failures to avoid
// cascading timeouts to downstream services
if (failureCount >= MAX_FAILURES) {
    return fallbackResponse();
}

// Using insertion order map because the API contract guarantees
// fields are returned in the order they were added
Map fields = new LinkedHashMap<>();

Javadoc for public APIs. Every public class and public method should have Javadoc. This is non-negotiable for library code and strongly recommended for application code.

/**
 * Transfers funds between two accounts within a single transaction.
 *
 * 

Both accounts must be active and belong to the same currency. * The transfer is atomic -- if any step fails, both accounts * remain unchanged.

* * @param fromAccountId the source account ID * @param toAccountId the destination account ID * @param amount the amount to transfer (must be positive) * @return the transaction confirmation with a unique reference ID * @throws InsufficientFundsException if the source account balance is too low * @throws AccountNotFoundException if either account does not exist * @throws IllegalArgumentException if amount is zero or negative */ public TransactionConfirmation transfer( String fromAccountId, String toAccountId, BigDecimal amount) { // implementation }

TODO and FIXME conventions:

  • // TODO: -- something that needs to be done but is not urgent
  • // FIXME: -- a known bug or problematic code that needs fixing
  • // HACK: -- a workaround that should be replaced with a proper solution

Always include your name or ticket number so someone can follow up: // TODO(JIRA-1234): Replace with batch API when available



Object-Oriented Design

Java is fundamentally an object-oriented language. These design practices help you write code that is flexible, testable, and resilient to change.

4. Follow SOLID Principles

SOLID is a set of five design principles that guide you toward maintainable object-oriented code. Here is a quick reminder of each with a practical Java example:

Principle Meaning One-Line Summary
Single Responsibility A class should have only one reason to change Separate UserValidator from UserRepository
Open/Closed Open for extension, closed for modification Add new behavior via new classes, not by editing existing ones
Liskov Substitution Subtypes must be substitutable for their base types If Square extends Rectangle, setting width must not break area calculation
Interface Segregation No client should depend on methods it does not use Split Worker into Workable and Feedable
Dependency Inversion Depend on abstractions, not concretions Constructor takes NotificationService interface, not EmailService class
// BAD - Single Responsibility violation: one class does everything
public class UserService {
    public void registerUser(User user) { ... }
    public void sendWelcomeEmail(User user) { ... }   // email logic
    public void generatePdfReport(User user) { ... }  // PDF logic
    public boolean validateUser(User user) { ... }     // validation logic
}

// GOOD - each class has one responsibility
public class UserService {
    private final UserValidator validator;
    private final UserRepository repository;
    private final NotificationService notificationService;

    public UserService(UserValidator validator,
                       UserRepository repository,
                       NotificationService notificationService) {
        this.validator = validator;
        this.repository = repository;
        this.notificationService = notificationService;
    }

    public void registerUser(User user) {
        validator.validate(user);
        repository.save(user);
        notificationService.sendWelcomeNotification(user);
    }
}

5. Prefer Composition Over Inheritance

Inheritance creates a tight coupling between parent and child classes. A change in the parent can break every child. Composition -- where a class contains another class rather than extending it -- gives you the same code reuse with much more flexibility.

The rule of thumb: use inheritance only when there is a genuine is-a relationship (Dog is an Animal). If the relationship is has-a or uses-a, use composition.

// BAD - inheritance for code reuse (fragile base class problem)
public class Logger {
    public void log(String message) {
        System.out.println("[LOG] " + message);
    }
}

public class UserService extends Logger {
    // UserService IS-A Logger? That makes no sense.
    public void createUser(String name) {
        log("Creating user: " + name);  // inherited method
        // ...
    }
}

// GOOD - composition: UserService HAS-A Logger
public class UserService {
    private final Logger logger;

    public UserService(Logger logger) {
        this.logger = logger;
    }

    public void createUser(String name) {
        logger.log("Creating user: " + name);
        // ...
    }
}
// Now you can swap FileLogger, ConsoleLogger, or NoOpLogger
// without touching UserService at all.

6. Program to Interfaces

When you declare variables, parameters, and return types, use the most general type that still provides the methods you need. This decouples your code from specific implementations and makes it easy to swap one implementation for another.

// BAD - tied to a specific implementation
ArrayList names = new ArrayList<>();
HashMap userMap = new HashMap<>();

public ArrayList getNames() {
    return names;
}

// GOOD - programmed to the interface
List names = new ArrayList<>();
Map userMap = new HashMap<>();

public List getNames() {
    return names;
}
// Tomorrow you can switch to LinkedList or CopyOnWriteArrayList
// without changing any calling code.

This principle extends to dependency injection. Your classes should depend on interfaces, not concrete classes:

// BAD - hard dependency on a concrete class
public class OrderService {
    private MySQLOrderRepository repository = new MySQLOrderRepository();
}

// GOOD - depends on an abstraction, injected via constructor
public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}
// In production: new OrderService(new MySQLOrderRepository())
// In tests:      new OrderService(new FakeOrderRepository())

7. Immutability

An immutable object cannot be modified after creation. Immutable objects are inherently thread-safe, simpler to reason about, and safer to share across your codebase. Make your classes immutable unless there is a compelling reason not to.

To make a class immutable:

  1. Declare the class final (prevent subclassing)
  2. Make all fields private final
  3. Do not provide setter methods
  4. Perform defensive copies of mutable fields in the constructor and getters
// BAD - mutable class with exposed internals
public class Order {
    public List items;  // anyone can modify this list
    public double total;        // anyone can change the total

    public Order(List items) {
        this.items = items;  // stores a reference -- caller can mutate it
    }
}

// GOOD - immutable class with defensive copying
public final class Order {
    private final List items;
    private final double total;

    public Order(List items, double total) {
        // Defensive copy: changes to the original list won't affect us
        this.items = List.copyOf(items);
        this.total = total;
    }

    public List getItems() {
        // Returns an unmodifiable view -- caller cannot add/remove items
        return items; // already unmodifiable from List.copyOf()
    }

    public double getTotal() {
        return total;
    }
}

Use unmodifiable collections when returning collections from methods. This prevents callers from accidentally modifying your internal state.

// Java 9+ factory methods create unmodifiable collections
List colors = List.of("Red", "Green", "Blue");
Set primes = Set.of(2, 3, 5, 7, 11);
Map scores = Map.of("Alice", 95, "Bob", 87);

// For mutable lists you need to return unmodifiable views
public List getUsers() {
    return Collections.unmodifiableList(users);
}

// Java 16+ records are inherently immutable (no setter generation)
public record Money(BigDecimal amount, Currency currency) { }



Error Handling

How you handle errors defines the reliability of your application. Poor error handling leads to silent failures, lost data, and hours of debugging. These practices ensure your application fails predictably and provides useful information when things go wrong.

8. Exception Handling Best Practices

Catch specific exceptions. Catching Exception or Throwable hides bugs, catches things you never intended to handle (like OutOfMemoryError), and makes debugging a nightmare.

// BAD - catches everything, including programming errors
try {
    processOrder(order);
} catch (Exception e) {
    log.error("Something went wrong");  // what went wrong? No clue.
}

// GOOD - catch specific exceptions, handle each appropriately
try {
    processOrder(order);
} catch (InsufficientFundsException e) {
    log.warn("Insufficient funds for order {}: {}", order.getId(), e.getMessage());
    notifyCustomer(order, "Payment failed: insufficient funds");
} catch (InventoryException e) {
    log.error("Inventory check failed for order {}", order.getId(), e);
    queueForRetry(order);
}

Never leave a catch block empty. An empty catch block silently swallows the error and guarantees that someone will spend hours debugging a problem that the application already detected and threw away.

// BAD - the worst thing you can do with an exception
try {
    Files.delete(tempFile);
} catch (IOException e) {
    // silently ignored -- if the delete fails, you'll never know
}

// GOOD - at minimum, log it. Better: handle it.
try {
    Files.delete(tempFile);
} catch (IOException e) {
    log.warn("Failed to delete temp file {}: {}", tempFile, e.getMessage());
}

Use try-with-resources for any object that implements AutoCloseable. This guarantees cleanup even if an exception occurs, and it is more concise than try-finally.

// BAD - manual resource management (verbose and error-prone)
Connection conn = null;
PreparedStatement stmt = null;
try {
    conn = dataSource.getConnection();
    stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
    stmt.setLong(1, userId);
    ResultSet rs = stmt.executeQuery();
    // process results
} catch (SQLException e) {
    log.error("Query failed", e);
} finally {
    if (stmt != null) { try { stmt.close(); } catch (SQLException e) { } }
    if (conn != null) { try { conn.close(); } catch (SQLException e) { } }
}

// GOOD - try-with-resources handles all cleanup automatically
try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement(
         "SELECT * FROM users WHERE id = ?")) {

    stmt.setLong(1, userId);
    try (ResultSet rs = stmt.executeQuery()) {
        // process results
    }
} catch (SQLException e) {
    log.error("Query failed for user {}", userId, e);
}

Create custom exceptions for business logic. Custom exceptions make your error handling expressive and allow callers to react differently to different business failures.

public class InsufficientFundsException extends RuntimeException {
    private final BigDecimal currentBalance;
    private final BigDecimal requestedAmount;

    public InsufficientFundsException(BigDecimal currentBalance,
                                       BigDecimal requestedAmount) {
        super(String.format("Cannot withdraw %s: current balance is %s",
              requestedAmount, currentBalance));
        this.currentBalance = currentBalance;
        this.requestedAmount = requestedAmount;
    }

    public BigDecimal getCurrentBalance() { return currentBalance; }
    public BigDecimal getRequestedAmount() { return requestedAmount; }
}

// Usage: fail fast with a meaningful exception
public void withdraw(BigDecimal amount) {
    if (amount.compareTo(balance) > 0) {
        throw new InsufficientFundsException(balance, amount);
    }
    balance = balance.subtract(amount);
}

Fail fast. Validate inputs at the beginning of a method. Do not let invalid data travel deep into your code where it causes confusing errors far from the source of the problem.

9. Null Safety

Tony Hoare, who invented null references, called them his "billion-dollar mistake." In Java, NullPointerException is the most common runtime exception. These practices help you avoid it.

Return empty collections instead of null.

// BAD - forces every caller to check for null
public List getOrdersByCustomer(String customerId) {
    List orders = repository.findByCustomerId(customerId);
    if (orders.isEmpty()) {
        return null;  // caller MUST check: if (orders != null)
    }
    return orders;
}

// GOOD - return empty collection, callers can iterate safely
public List getOrdersByCustomer(String customerId) {
    List orders = repository.findByCustomerId(customerId);
    return orders != null ? orders : Collections.emptyList();
}

// Even better with modern Java:
public List getOrdersByCustomer(String customerId) {
    return Objects.requireNonNullElse(
        repository.findByCustomerId(customerId),
        List.of()
    );
}

Use Optional for values that might not exist. Optional is for return types that legitimately may have no value. It forces the caller to handle the absence explicitly.

// BAD - returning null for "not found"
public User findByEmail(String email) {
    // Returns null if not found -- caller might forget to check
    return userMap.get(email);
}

// GOOD - Optional makes the absence explicit
public Optional findByEmail(String email) {
    return Optional.ofNullable(userMap.get(email));
}

// Callers are forced to handle both cases:
userService.findByEmail("alice@example.com")
    .ifPresentOrElse(
        user -> System.out.println("Found: " + user.getName()),
        () -> System.out.println("User not found")
    );

// Or with a default:
User user = userService.findByEmail("alice@example.com")
    .orElseThrow(() -> new UserNotFoundException("alice@example.com"));

Use Objects.requireNonNull() at the start of constructors and methods to catch null early with a clear message.

public class EmailService {
    private final SmtpClient client;
    private final TemplateEngine templateEngine;

    public EmailService(SmtpClient client, TemplateEngine templateEngine) {
        // Fail immediately with a clear message instead of getting a
        // NullPointerException 5 method calls later
        this.client = Objects.requireNonNull(client, "SmtpClient must not be null");
        this.templateEngine = Objects.requireNonNull(templateEngine,
            "TemplateEngine must not be null");
    }
}



Performance and Efficiency

Premature optimization is the root of all evil (Knuth), but knowing the common performance pitfalls saves you from writing obviously slow code. These practices are about choosing the right tool for the job.

10. String Handling

Strings are immutable in Java. Every concatenation creates a new String object. For single-line concatenation the compiler optimizes it, but inside loops you must use StringBuilder.

// BAD - creates a new String object on every iteration
// For 10,000 iterations, this creates ~10,000 intermediate String objects
public String buildReport(List lines) {
    String result = "";
    for (String line : lines) {
        result += line + "\n";  // new String every time
    }
    return result;
}

// GOOD - StringBuilder reuses a single internal buffer
public String buildReport(List lines) {
    StringBuilder sb = new StringBuilder(lines.size() * 80); // pre-size estimate
    for (String line : lines) {
        sb.append(line).append("\n");
    }
    return sb.toString();
}

// ALSO GOOD - String.join() for simple cases
public String buildReport(List lines) {
    return String.join("\n", lines);
}

Text blocks (Java 13+) make multi-line strings readable and maintainable:

// BAD - unreadable concatenation of a multi-line string
String json = "{\n" +
    "  \"name\": \"" + name + "\",\n" +
    "  \"email\": \"" + email + "\"\n" +
    "}";

// GOOD - text block (Java 13+) with formatted() (Java 15+)
String json = """
    {
      "name": "%s",
      "email": "%s"
    }
    """.formatted(name, email);

11. Collections Best Practices

Choosing the right collection is one of the most impactful performance decisions you can make. Here is a quick guide:

Need Use Why
Ordered list with random access ArrayList O(1) get by index, backed by array
Frequent insertions/removals at both ends ArrayDeque O(1) add/remove at head and tail
Unique elements, fast lookup HashSet O(1) contains, add, remove
Sorted unique elements TreeSet O(log n) operations, natural ordering
Key-value lookup HashMap O(1) get/put on average
Sorted key-value pairs TreeMap O(log n) operations, sorted keys
Insertion-ordered key-value pairs LinkedHashMap O(1) operations + maintains order
Thread-safe key-value ConcurrentHashMap Lock striping for concurrent access
// BAD - wrong collection for the job
LinkedList users = new LinkedList<>();  // random access is O(n)
users.get(500);  // traverses 500 nodes to get element

// GOOD - ArrayList for indexed access
ArrayList users = new ArrayList<>();
users.get(500);  // O(1) array index lookup

// Pre-size when you know the approximate size (avoids resizing)
List names = new ArrayList<>(1000);
Map cache = new HashMap<>(256);

// Use Collections utility methods for common operations
List numbers = Arrays.asList(5, 3, 8, 1, 9);
Collections.sort(numbers);                    // sort in place
int max = Collections.max(numbers);           // find max
Collections.shuffle(numbers);                 // random order
List synced = Collections.synchronizedList(numbers);

Use streams appropriately. Streams are excellent for transformations and filtering but add overhead. Do not use them for simple iterations where a for-each loop is clearer.

// OVERKILL - stream adds nothing for a simple loop
users.stream().forEach(user -> user.sendEmail());

// BETTER - plain for-each is simpler
for (User user : users) {
    user.sendEmail();
}

// GOOD USE OF STREAM - transformation, filtering, collecting
List activeEmails = users.stream()
    .filter(User::isActive)
    .map(User::getEmail)
    .sorted()
    .collect(Collectors.toList());

12. Resource Management

Every resource you open -- database connections, file handles, network sockets, thread pools -- must be closed. Failing to close resources causes memory leaks, connection pool exhaustion, and file handle starvation.

Always use try-with-resources (see Exception Handling section above). Here is a pattern for managing multiple resources:

// Read a file, process each line, write results to another file
public void processFile(Path input, Path output) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(input);
         BufferedWriter writer = Files.newBufferedWriter(output)) {

        String line;
        while ((line = reader.readLine()) != null) {
            String processed = transform(line);
            writer.write(processed);
            writer.newLine();
        }
    }
    // Both reader and writer are automatically closed here,
    // even if an exception occurred
}

// For custom resources, implement AutoCloseable
public class ConnectionPool implements AutoCloseable {
    private final List connections = new ArrayList<>();

    public Connection acquire() { /* ... */ }

    @Override
    public void close() {
        connections.forEach(conn -> {
            try { conn.close(); } catch (SQLException e) {
                log.warn("Failed to close connection", e);
            }
        });
        connections.clear();
    }
}



Modern Java

Java has evolved rapidly since Java 8. Using modern language features makes your code more concise, readable, and less error-prone. If your project supports Java 17 or later, take advantage of these features.

13. Use Modern Java Features

Local variable type inference -- var (Java 10+): Use var when the type is obvious from the right side. Do not use it when it obscures the type.

// GOOD uses of var - type is obvious from context
var names = new ArrayList();         // clearly ArrayList
var reader = new BufferedReader(new FileReader("data.txt"));
var count = 0;                               // clearly int
var entry = map.entrySet().iterator().next(); // avoids verbose generic type

// BAD uses of var - type is unclear
var result = process();    // what type is result?
var data = getData();      // what type is data?
var x = calculate(a, b);  // impossible to understand without IDE

Records (Java 16+): Use records for simple data carriers. A record automatically generates the constructor, getters, equals(), hashCode(), and toString().

// BAD - 50 lines of boilerplate for a simple data class
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    @Override
    public boolean equals(Object o) { /* 10 lines */ }
    @Override
    public int hashCode() { return Objects.hash(x, y); }
    @Override
    public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

// GOOD - one line with Java 16+ records
public record Point(int x, int y) { }

// Records can have validation and custom methods
public record EmailAddress(String value) {
    public EmailAddress {
        // Compact constructor for validation
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("Invalid email: " + value);
        }
    }
}

Sealed classes (Java 17+): Restrict which classes can extend a type. This enables exhaustive pattern matching in switch expressions.

// Sealed class - only these three can extend Shape
public sealed interface Shape
    permits Circle, Rectangle, Triangle {
}

public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }

// The compiler KNOWS all subtypes, so switch can be exhaustive (Java 21+)
public double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // No default needed -- compiler verifies all cases are covered
    };
}

Switch expressions (Java 14+): Switch can now return values and use arrow syntax to eliminate fall-through bugs.

// BAD - traditional switch with fall-through risk
String dayType;
switch (day) {
    case MONDAY:
    case TUESDAY:
    case WEDNESDAY:
    case THURSDAY:
    case FRIDAY:
        dayType = "Weekday";
        break;     // forget this break and you get a bug
    case SATURDAY:
    case SUNDAY:
        dayType = "Weekend";
        break;
    default:
        dayType = "Unknown";
}

// GOOD - switch expression (Java 14+), no fall-through possible
String dayType = switch (day) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
    case SATURDAY, SUNDAY -> "Weekend";
};

Pattern matching for instanceof (Java 16+): Eliminates the redundant cast after an instanceof check.

// BAD - old way: check instanceof, then cast
if (obj instanceof String) {
    String s = (String) obj;  // redundant cast
    System.out.println(s.length());
}

// GOOD - pattern matching (Java 16+): check and cast in one step
if (obj instanceof String s) {
    System.out.println(s.length());
}

// Works great with negation too
if (!(obj instanceof String s)) {
    return; // early exit
}
// s is in scope here
System.out.println(s.toUpperCase());

14. Streams and Lambdas

Streams and lambdas are powerful tools for functional-style programming in Java. Used well, they make code concise and declarative. Used poorly, they create unreadable chains that are harder to debug than simple loops.

When to use streams:

  • Transforming a collection (map, filter, reduce)
  • Collecting results into a new collection
  • Aggregating data (sum, average, grouping)
  • Finding elements (findFirst, anyMatch)

When NOT to use streams:

  • Simple iteration with side effects (use for-each)
  • When you need to modify local variables (streams require effectively final)
  • When the stream pipeline becomes longer than 5-6 operations (extract to methods)
// BAD - overly complex stream, hard to read and debug
Map> result = orders.stream()
    .filter(o -> o.getStatus() != null && o.getStatus().equals("ACTIVE")
        && o.getTotal() != null && o.getTotal().compareTo(BigDecimal.ZERO) > 0)
    .sorted(Comparator.comparing(Order::getCreatedAt).reversed())
    .map(o -> new OrderDTO(o.getId(), o.getCustomer().getName(),
        o.getItems().stream().map(Item::getName).collect(Collectors.joining(", ")),
        o.getTotal()))
    .collect(Collectors.groupingBy(OrderDTO::customerName));

// GOOD - break it into readable steps
List activeOrders = orders.stream()
    .filter(this::isActiveWithPositiveTotal)
    .sorted(Comparator.comparing(Order::getCreatedAt).reversed())
    .toList();

Map> result = activeOrders.stream()
    .map(this::toOrderDTO)
    .collect(Collectors.groupingBy(OrderDTO::customerName));

// Helper methods make it self-documenting
private boolean isActiveWithPositiveTotal(Order order) {
    return "ACTIVE".equals(order.getStatus())
        && order.getTotal() != null
        && order.getTotal().compareTo(BigDecimal.ZERO) > 0;
}

private OrderDTO toOrderDTO(Order order) {
    String itemNames = order.getItems().stream()
        .map(Item::getName)
        .collect(Collectors.joining(", "));
    return new OrderDTO(order.getId(),
        order.getCustomer().getName(), itemNames, order.getTotal());
}

Prefer method references over lambdas when the lambda simply delegates to an existing method:

// OK - lambda
names.stream().map(name -> name.toUpperCase()).collect(Collectors.toList());
names.stream().filter(name -> name.isEmpty()).count();

// BETTER - method reference (cleaner, less noise)
names.stream().map(String::toUpperCase).collect(Collectors.toList());
names.stream().filter(String::isEmpty).count();

// More method reference examples
users.stream().map(User::getEmail)...                  // instance method
numbers.stream().forEach(System.out::println);          // static method
names.stream().map(String::new)...                      // constructor

Avoid side effects in streams. Streams are designed for functional-style operations. Modifying external state inside a stream is confusing and can cause bugs with parallel streams.

// BAD - side effects: modifying external list inside a stream
List results = new ArrayList<>();
names.stream()
    .filter(n -> n.length() > 3)
    .forEach(n -> results.add(n));  // mutating external state

// GOOD - collect into a new list (no side effects)
List results = names.stream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList());

// Or with Java 16+
List results = names.stream()
    .filter(n -> n.length() > 3)
    .toList();



Testing and Quality

Code without tests is legacy code the moment it is written. These practices ensure your code is testable and that your tests are useful.

15. Write Testable Code

Testability is a design quality. If your code is hard to test, that is a sign of poor design. The following principles make code easy to test:

Use dependency injection. Classes that create their own dependencies are impossible to test in isolation.

// BAD - untestable: creates its own dependencies
public class OrderProcessor {
    private final PaymentGateway gateway = new StripePaymentGateway();
    private final EmailService email = new SmtpEmailService();

    public void process(Order order) {
        gateway.charge(order.getTotal());  // hits real Stripe API in tests!
        email.send(order.getEmail(), "Order confirmed");  // sends real emails!
    }
}

// GOOD - testable: dependencies are injected
public class OrderProcessor {
    private final PaymentGateway gateway;
    private final EmailService email;

    public OrderProcessor(PaymentGateway gateway, EmailService email) {
        this.gateway = gateway;
        this.email = email;
    }

    public void process(Order order) {
        gateway.charge(order.getTotal());
        email.send(order.getEmail(), "Order confirmed");
    }
}

// In tests, inject fakes or mocks:
@Test
void shouldChargeAndSendEmail() {
    var fakeGateway = new FakePaymentGateway();
    var fakeEmail = new FakeEmailService();
    var processor = new OrderProcessor(fakeGateway, fakeEmail);

    processor.process(testOrder);

    assertTrue(fakeGateway.wasCharged());
    assertEquals("Order confirmed", fakeEmail.getLastMessage());
}

Keep methods small and focused. A method that does one thing is easy to test. A method that does five things requires a combinatorial explosion of test cases.

Use meaningful test names that describe the scenario and expected behavior:

// BAD - test names that tell you nothing when they fail
@Test void test1() { ... }
@Test void testProcess() { ... }
@Test void testUser() { ... }

// GOOD - test names describe scenario and expected behavior
@Test void withdraw_withSufficientFunds_shouldReduceBalance() { ... }
@Test void withdraw_withInsufficientFunds_shouldThrowException() { ... }
@Test void withdraw_withNegativeAmount_shouldThrowIllegalArgument() { ... }
@Test void findByEmail_withNonexistentEmail_shouldReturnEmpty() { ... }

// ALSO GOOD - Given/When/Then style
@Test void givenInactiveUser_whenLogin_thenAccountLockedExceptionThrown() { ... }

Avoid static methods for business logic. Static methods cannot be overridden, mocked, or injected. They couple your code to a specific implementation.

// BAD - static utility for business logic is untestable in callers
public class PricingUtils {
    public static BigDecimal calculateDiscount(Order order) {
        // Complex business logic tied to a static method
        // Callers cannot replace this in tests
    }
}

// GOOD - instance method on an injectable service
public class PricingService {
    public BigDecimal calculateDiscount(Order order) {
        // Same logic, but now callers can inject a fake
    }
}

// Static methods are fine for pure utility functions with no side effects:
public final class StringUtils {
    public static boolean isBlank(String s) {
        return s == null || s.trim().isEmpty();
    }
}

16. Logging Best Practices

Logging is your primary tool for understanding what your application does in production. Poor logging means you are flying blind when things go wrong.

Use SLF4J as the logging facade. SLF4J lets you swap the underlying implementation (Logback, Log4j2) without changing application code.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PaymentService {
    // One logger per class, declared as a static final field
    private static final Logger log = LoggerFactory.getLogger(PaymentService.class);

    public PaymentResult processPayment(PaymentRequest request) {
        log.info("Processing payment for order {}", request.getOrderId());

        try {
            PaymentResult result = gateway.charge(request);
            log.info("Payment successful: orderId={}, transactionId={}",
                request.getOrderId(), result.getTransactionId());
            return result;
        } catch (PaymentDeclinedException e) {
            log.warn("Payment declined for order {}: {}",
                request.getOrderId(), e.getReason());
            throw e;
        } catch (Exception e) {
            log.error("Payment processing failed for order {}",
                request.getOrderId(), e);  // exception as last arg logs stack trace
            throw new PaymentProcessingException("Failed to process payment", e);
        }
    }
}

Use the correct log levels:

Level When to Use Example
ERROR Something failed that requires attention Database connection lost, payment failed
WARN Something unexpected but the system recovered Retry succeeded, fallback used, deprecated API called
INFO Significant business events User registered, order placed, payment processed
DEBUG Detailed flow information for troubleshooting Method entry/exit, intermediate values
TRACE Very detailed debugging (rarely used) Loop iterations, full request/response bodies

Use parameterized logging. Never concatenate strings in log statements. With parameterized logging, the string is only built if the log level is enabled.

// BAD - string concatenation happens even if DEBUG is disabled
log.debug("Processing user " + user.getName() + " with id " + user.getId());

// GOOD - parameterized: {} placeholders are only resolved if level is enabled
log.debug("Processing user {} with id {}", user.getName(), user.getId());

// BAD - checking log level AND using concatenation (defeats the purpose)
if (log.isDebugEnabled()) {
    log.debug("Processing user " + user.getName());
}

// GOOD - parameterized logging handles the check internally
log.debug("Processing user {}", user.getName());

What to log:

  • Application startup and shutdown events
  • Authentication and authorization events (login, logout, access denied)
  • Business transactions (order placed, payment processed)
  • Integration points (API calls, database queries, message queue operations)
  • Errors and exceptions with full context (IDs, parameters)

What NOT to log:

  • Passwords, tokens, credit card numbers, or any sensitive data
  • Full request/response bodies at INFO level (use DEBUG or TRACE)
  • Every loop iteration (use aggregate logging instead)



Code Organization

How you organize your code determines how quickly developers can find what they need, understand the system architecture, and make changes without breaking unrelated features.

17. Project Structure

Package by feature, not by layer. The traditional approach of packaging by layer (controllers, services, repositories) scatters related code across the project. Packaging by feature keeps everything related together.

// BAD - package by layer (a change to "orders" touches 4 packages)
com.example.app
├── controller/
│   ├── OrderController.java
│   ├── UserController.java
│   └── ProductController.java
├── service/
│   ├── OrderService.java
│   ├── UserService.java
│   └── ProductService.java
├── repository/
│   ├── OrderRepository.java
│   ├── UserRepository.java
│   └── ProductRepository.java
└── model/
    ├── Order.java
    ├── User.java
    └── Product.java

// GOOD - package by feature (everything about "orders" is together)
com.example.app
├── order/
│   ├── OrderController.java
│   ├── OrderService.java
│   ├── OrderRepository.java
│   ├── Order.java
│   └── OrderDTO.java
├── user/
│   ├── UserController.java
│   ├── UserService.java
│   ├── UserRepository.java
│   └── User.java
└── product/
    ├── ProductController.java
    ├── ProductService.java
    ├── ProductRepository.java
    └── Product.java

Benefits of packaging by feature:

  • Higher cohesion -- related code lives together
  • Easier to understand a feature by looking at one package
  • Easier to extract a feature into a separate module or microservice
  • Package-private visibility actually works (classes can be package-private)

18. Class Design

Keep classes focused (Single Responsibility Principle). A class should have one, and only one, reason to change. If you find yourself describing a class with "and" ("this class handles orders and sends emails and generates reports"), it has too many responsibilities.

Keep classes small. A class with 2000 lines is a "God class" that tries to do everything. Aim for classes under 200-300 lines. If a class is growing beyond that, look for responsibilities to extract.

Use the Builder pattern when a constructor has many parameters (more than 3-4). Builders are self-documenting and prevent parameter-ordering bugs.

// BAD - constructor with many parameters (which is email? which is phone?)
User user = new User("Alice", "Smith", "alice@example.com",
    "555-1234", "123 Main St", "Springfield", "IL", "62701", true, false);

// GOOD - Builder pattern: every parameter is labeled
User user = User.builder()
    .firstName("Alice")
    .lastName("Smith")
    .email("alice@example.com")
    .phone("555-1234")
    .address(Address.builder()
        .street("123 Main St")
        .city("Springfield")
        .state("IL")
        .zipCode("62701")
        .build())
    .isActive(true)
    .isAdmin(false)
    .build();

Here is a clean Builder implementation:

public class User {
    private final String firstName;
    private final String lastName;
    private final String email;

    private User(Builder builder) {
        this.firstName = Objects.requireNonNull(builder.firstName, "firstName required");
        this.lastName = Objects.requireNonNull(builder.lastName, "lastName required");
        this.email = Objects.requireNonNull(builder.email, "email required");
    }

    public static Builder builder() {
        return new Builder();
    }

    // Getters...
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public String getEmail() { return email; }

    public static class Builder {
        private String firstName;
        private String lastName;
        private String email;

        public Builder firstName(String firstName) {
            this.firstName = firstName;
            return this;
        }

        public Builder lastName(String lastName) {
            this.lastName = lastName;
            return this;
        }

        public Builder email(String email) {
            this.email = email;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }
}



Security

Security is not something you bolt on at the end. It must be part of how you write every line of code. These practices protect your application from the most common vulnerabilities.

19. Security Best Practices

Always validate input. Never trust data from external sources -- user input, API responses, file uploads, URL parameters. Validate, sanitize, and constrain before processing.

// BAD - no input validation
public void createUser(String name, String email, int age) {
    // Directly uses untrusted input
    User user = new User(name, email, age);
    repository.save(user);
}

// GOOD - validate everything
public void createUser(String name, String email, int age) {
    if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("Name must not be blank");
    }
    if (name.length() > 100) {
        throw new IllegalArgumentException("Name must not exceed 100 characters");
    }
    if (email == null || !EMAIL_PATTERN.matcher(email).matches()) {
        throw new IllegalArgumentException("Invalid email format");
    }
    if (age < 0 || age > 150) {
        throw new IllegalArgumentException("Age must be between 0 and 150");
    }
    User user = new User(name.trim(), email.toLowerCase().trim(), age);
    repository.save(user);
}

private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");

Use parameterized queries. SQL injection is one of the most dangerous and preventable vulnerabilities. Never build SQL strings by concatenating user input.

// BAD - SQL injection vulnerability!
// If username is: ' OR '1'='1' -- , this returns ALL users
public User findUser(String username) {
    String sql = "SELECT * FROM users WHERE username = '" + username + "'";
    return jdbcTemplate.queryForObject(sql, userRowMapper);
}

// GOOD - parameterized query: input is treated as DATA, never as SQL
public User findUser(String username) {
    String sql = "SELECT * FROM users WHERE username = ?";
    return jdbcTemplate.queryForObject(sql, userRowMapper, username);
}

// GOOD - using JPA named parameters
@Query("SELECT u FROM User u WHERE u.username = :username")
Optional findByUsername(@Param("username") String username);

Never hardcode secrets. Passwords, API keys, database credentials, and tokens must never appear in source code. Use environment variables, secret managers, or configuration files that are excluded from version control.

// BAD - secrets in source code (visible in git history forever)
public class DatabaseConfig {
    private static final String DB_PASSWORD = "MyS3cretP@ssw0rd";
    private static final String API_KEY = "sk-1234567890abcdef";
}

// GOOD - read from environment variables
public class DatabaseConfig {
    private final String dbPassword = System.getenv("DB_PASSWORD");
    private final String apiKey = System.getenv("API_KEY");

    public DatabaseConfig() {
        if (dbPassword == null || dbPassword.isBlank()) {
            throw new IllegalStateException(
                "DB_PASSWORD environment variable is not set");
        }
    }
}

// GOOD - Spring Boot: use application.yml with environment variable references
// spring:
//   datasource:
//     password: ${DB_PASSWORD}
// API keys read from a vault or secrets manager in production

Apply the principle of least privilege. Give classes, methods, and fields the minimum visibility they need. Default to private and only widen access when there is a clear reason.

// BAD - everything is public (exposes internal implementation)
public class UserService {
    public List userCache = new ArrayList<>();  // internal state exposed
    public Connection dbConnection;                    // anyone can access this

    public String hashPassword(String password) { ... }  // internal method exposed
    public void updateCache(User user) { ... }           // internal method exposed
}

// GOOD - minimal visibility
public class UserService {
    private final List userCache = new ArrayList<>();
    private final DataSource dataSource;

    public UserService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // Only public methods that form the API contract
    public User findById(long id) { ... }
    public void register(User user) { ... }

    // Internal helpers are private
    private String hashPassword(String password) { ... }
    private void updateCache(User user) { ... }
}



20. Summary Quick Reference Table

Here is every best practice from this tutorial in one scannable table. Bookmark this page and use it as a checklist during code reviews.

# Best Practice Do NOT DO
1 Naming Conventions int d;, String addr;, void calc(); int daysSinceCreation;, String address;, void calculateTotal();
2 Code Formatting No braces on if/for, inconsistent indentation Always use braces, 4-space indent, IDE auto-format
3 Comments // increment i by 1, comments that repeat code Comment the why, Javadoc for public APIs
4 SOLID Principles God classes with 10 responsibilities One class, one responsibility, depend on abstractions
5 Composition over Inheritance class UserService extends Logger class UserService { private Logger logger; }
6 Program to Interfaces ArrayList<String> names = new ArrayList<>(); List<String> names = new ArrayList<>();
7 Immutability Public mutable fields, expose internal lists private final fields, defensive copies, List.copyOf()
8 Exception Handling catch (Exception e) { } Catch specific exceptions, try-with-resources, fail fast
9 Null Safety Return null for "not found" Return Optional, empty collections, Objects.requireNonNull()
10 String Handling result += line in a loop StringBuilder, String.join(), text blocks
11 Collections LinkedList for random access, no pre-sizing Right collection for the job, pre-size when known
12 Resource Management Manual close in finally blocks try-with-resources for all AutoCloseable objects
13 Modern Java Features 50-line boilerplate POJOs Records, sealed classes, pattern matching, switch expressions
14 Streams and Lambdas 10-operation chain, side effects in streams Break chains into methods, use method references, no side effects
15 Testable Code new StripeGateway() inside the class Constructor injection, small methods, meaningful test names
16 Logging log.debug("user " + name), inconsistent levels SLF4J with {} placeholders, correct log levels
17 Project Structure Package by layer (controller/service/repo) Package by feature (order/user/product)
18 Class Design 2000-line God class, 10-parameter constructors Small focused classes, Builder pattern
19 Security String concatenation in SQL, hardcoded passwords Parameterized queries, environment variables, input validation

Final Thoughts

Best practices are not rules carved in stone -- they are guidelines refined through decades of collective experience. There will be times when breaking a guideline makes sense, but you should always be able to articulate why you are breaking it.

Start by adopting these practices one at a time. Pick the ones that address your biggest pain points first. As they become habits, your code will become easier to read, safer to change, and more enjoyable to work with -- for you and for everyone on your team.

The best code is code that future developers (including future you) can understand, modify, and extend without fear.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *