Best Practices

Overview

Writing quality Java code requires understanding fundamental design principles that guide everyday decisions. These best practices emerge from decades of collective experience and help you write code that is maintainable, testable, and robust.

Core Design Principles

Favor Immutability

Immutable objects cannot be modified after creation, eliminating entire classes of bugs related to unexpected state changes.

Why it matters:

  • Thread-safe by default without synchronization overhead
  • Prevents defensive copying
  • Makes code easier to reason about
  • Enables safe sharing across components

Example:

// ❌ Mutable - prone to bugs
public class MutablePoint {
  public int x;
  public int y;

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

// Caller can accidentally modify
MutablePoint point = new MutablePoint(5, 10);
point.x = 100; // Unexpected mutation

// ✅ Immutable - safe and predictable
public final 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; }

  public Point withX(int newX) {
    return new Point(newX, this.y);
  }
}

Trade-offs:

  • Creates new objects for every change (acceptable for most business logic)
  • May increase memory usage (JVM optimizes short-lived objects well)
  • Perfect for value objects, DTOs, and domain entities

Composition Over Inheritance

Prefer building functionality by combining objects rather than extending classes.

Why it matters:

  • Avoids fragile base class problem
  • Enables changing behavior at runtime
  • Promotes loose coupling
  • Follows Single Responsibility Principle

Example:

// ❌ Inheritance - rigid and fragile
public class ElectricCar extends Car {
  private Battery battery;

  @Override
  public void refuel() {
    // Doesn't make sense for electric car
    throw new UnsupportedOperationException();
  }
}

// ✅ Composition - flexible and clear
public class ElectricCar {
  private final Engine engine;
  private final Battery battery;
  private final ChargingSystem chargingSystem;

  public ElectricCar(Engine engine, Battery battery, ChargingSystem chargingSystem) {
    this.engine = engine;
    this.battery = battery;
    this.chargingSystem = chargingSystem;
  }

  public void charge() {
    chargingSystem.charge(battery);
  }
}

When to use inheritance:

  • True “is-a” relationship exists
  • Extending a class designed for inheritance (abstract base class)
  • Framework requirements (servlets, activities)

Program to Interfaces

Depend on abstractions rather than concrete implementations.

Why it matters:

  • Enables swapping implementations without changing clients
  • Facilitates testing with mock objects
  • Reduces coupling between components
  • Supports dependency injection

Example:

// ❌ Depends on concrete class
public class OrderService {
  private MySQLOrderRepository repository = new MySQLOrderRepository();

  public void saveOrder(Order order) {
    repository.save(order);
  }
}

// ✅ Depends on interface
public class OrderService {
  private final OrderRepository repository;

  public OrderService(OrderRepository repository) {
    this.repository = repository;
  }

  public void saveOrder(Order order) {
    repository.save(order);
  }
}

// Easy to test
OrderService service = new OrderService(new InMemoryOrderRepository());

// Easy to switch implementations
OrderService prodService = new OrderService(new PostgresOrderRepository());

Fail-Fast Principle

Detect and report errors as early as possible, ideally at the point where the error occurs.

Why it matters:

  • Prevents cascading failures
  • Makes debugging easier (stack trace shows actual problem location)
  • Avoids silent data corruption
  • Improves system reliability

Example:

// ❌ Fails late - error detected far from source
public class UserService {
  private String username;

  public void setUsername(String username) {
    this.username = username; // Accepts null
  }

  public void sendEmail() {
    // NullPointerException thrown here, far from actual problem
    String email = username.toLowerCase() + "@company.com";
  }
}

// ✅ Fails fast - error detected immediately
public class UserService {
  private final String username;

  public UserService(String username) {
    this.username = Objects.requireNonNull(username, "username cannot be null");
  }

  public void sendEmail() {
    String email = username.toLowerCase() + "@company.com";
  }
}

Use Descriptive Names

Names should reveal intention and eliminate the need for comments.

Why it matters:

  • Code is read far more often than written
  • Reduces cognitive load
  • Makes code self-documenting
  • Prevents misunderstandings

Example:

// ❌ Cryptic names
public class Mgr {
  private List<Emp> es;

  public void doIt(int d) {
    // What does this do?
    for (Emp e : es) {
      if (e.d > d) {
        e.s = true;
      }
    }
  }
}

// ✅ Descriptive names
public class EmployeeManager {
  private List<Employee> employees;

  public void markEmployeesEligibleForBonus(int minimumDaysWorked) {
    for (Employee employee : employees) {
      if (employee.getDaysWorked() > minimumDaysWorked) {
        employee.setEligibleForBonus(true);
      }
    }
  }
}

Resource Management

Use Try-with-Resources

Automatically close resources to prevent resource leaks.

Why it matters:

  • Eliminates resource leaks
  • Cleaner than manual try-finally blocks
  • Handles suppressed exceptions properly
  • Compiler-enforced cleanup

Example:

// ❌ Manual resource management - error-prone
public String readFile(String path) throws IOException {
  BufferedReader reader = null;
  try {
    reader = new BufferedReader(new FileReader(path));
    return reader.readLine();
  } finally {
    if (reader != null) {
      reader.close(); // Can throw exception, masking original
    }
  }
}

// ✅ Try-with-resources - automatic cleanup
public String readFile(String path) throws IOException {
  try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
    return reader.readLine();
  }
}

Error Handling

Use Exceptions for Exceptional Conditions

Exceptions should represent abnormal conditions, not normal control flow.

Why it matters:

  • Makes normal path clear and readable
  • Exceptions are expensive (stack trace creation)
  • Violates principle of least surprise
  • Difficult to optimize

Example:

// ❌ Using exceptions for control flow
public int findUserIndex(String username) {
  for (int i = 0; i < users.size(); i++) {
    if (users.get(i).getUsername().equals(username)) {
      return i;
    }
  }
  throw new UserNotFoundException(); // Normal case, not exceptional
}

// Caller forced to handle exception for normal flow
try {
  int index = findUserIndex("john");
} catch (UserNotFoundException e) {
  // User not found is a normal possibility
}

// ✅ Return Optional for normal cases
public Optional<Integer> findUserIndex(String username) {
  for (int i = 0; i < users.size(); i++) {
    if (users.get(i).getUsername().equals(username)) {
      return Optional.of(i);
    }
  }
  return Optional.empty();
}

// Clean handling
findUserIndex("john")
  .ifPresentOrElse(
    index -> System.out.println("Found at: " + index),
    () -> System.out.println("User not found")
  );

Preserve Exception Context

Include relevant context when throwing or wrapping exceptions.

Example:

// ❌ Lost context
public void processOrder(String orderId) {
  try {
    Order order = orderRepository.findById(orderId);
  } catch (SQLException e) {
    throw new RuntimeException("Database error");
  }
}

// ✅ Preserved context
public void processOrder(String orderId) {
  try {
    Order order = orderRepository.findById(orderId);
  } catch (SQLException e) {
    throw new OrderProcessingException(
      "Failed to retrieve order: " + orderId,
      e // Original exception as cause
    );
  }
}

Performance Considerations

Prefer Primitives Over Boxed Types

Use primitive types unless you need nullability or collections.

Why it matters:

  • Primitives consume less memory (no object overhead)
  • Faster access (no indirection)
  • Avoid accidental unboxing NPEs
  • Better cache locality

Example:

// ❌ Unnecessary boxing - slower and more memory
public long sumNumbers(List<Integer> numbers) {
  Long sum = 0L; // Boxed type
  for (Integer num : numbers) {
    sum += num; // Unbox, add, box repeatedly
  }
  return sum;
}

// ✅ Primitives - faster and efficient
public long sumNumbers(List<Integer> numbers) {
  long sum = 0L; // Primitive
  for (int num : numbers) {
    sum += num; // Simple addition
  }
  return sum;
}

Choose Appropriate Collection Types

Select collections based on usage patterns and requirements.

Example:

// ❌ Wrong collection for use case
public class UserRegistry {
  // Frequent lookups by ID, but using List
  private List<User> users = new ArrayList<>();

  public User findById(String id) {
    // O(n) linear search every time
    for (User user : users) {
      if (user.getId().equals(id)) {
        return user;
      }
    }
    return null;
  }
}

// ✅ Right collection for use case
public class UserRegistry {
  // HashMap for O(1) lookups
  private Map<String, User> users = new HashMap<>();

  public User findById(String id) {
    return users.get(id); // O(1) lookup
  }
}

Code Organization

Keep Methods Small and Focused

Each method should do one thing well.

Why it matters:

  • Easier to understand
  • Easier to test
  • Easier to reuse
  • Follows Single Responsibility Principle

Example:

// ❌ Large method doing too much
public void processOrder(Order order) {
  // Validate
  if (order.getItems().isEmpty()) {
    throw new IllegalArgumentException();
  }

  // Calculate total
  BigDecimal total = BigDecimal.ZERO;
  for (Item item : order.getItems()) {
    total = total.add(item.getPrice().multiply(new BigDecimal(item.getQuantity())));
  }

  // Apply discount
  if (order.getCustomer().isPremium()) {
    total = total.multiply(new BigDecimal("0.9"));
  }

  // Save to database
  orderRepository.save(order);

  // Send email
  String subject = "Order Confirmation";
  String body = "Your order total: " + total;
  emailService.send(order.getCustomer().getEmail(), subject, body);
}

// ✅ Small, focused methods
public void processOrder(Order order) {
  validateOrder(order);
  BigDecimal total = calculateTotal(order);
  total = applyDiscounts(order, total);
  saveOrder(order);
  sendConfirmationEmail(order, total);
}

private void validateOrder(Order order) {
  if (order.getItems().isEmpty()) {
    throw new IllegalArgumentException("Order must contain items");
  }
}

private BigDecimal calculateTotal(Order order) {
  return order.getItems().stream()
    .map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity())))
    .reduce(BigDecimal.ZERO, BigDecimal::add);
}

private BigDecimal applyDiscounts(Order order, BigDecimal total) {
  if (order.getCustomer().isPremium()) {
    return total.multiply(new BigDecimal("0.9"));
  }
  return total;
}

Testing Best Practices

Write Testable Code

Design code with testing in mind from the start.

Why it matters:

  • Catches bugs before production
  • Documents expected behavior
  • Enables safe refactoring
  • Improves design quality

Example:

// ❌ Hard to test - hidden dependencies
public class PaymentProcessor {
  public void processPayment(BigDecimal amount) {
    // Direct instantiation - cannot mock
    EmailService emailService = new EmailService();
    PaymentGateway gateway = new PaymentGateway();

    gateway.charge(amount);
    emailService.sendReceipt(amount);
  }
}

// ✅ Easy to test - dependencies injected
public class PaymentProcessor {
  private final EmailService emailService;
  private final PaymentGateway gateway;

  public PaymentProcessor(EmailService emailService, PaymentGateway gateway) {
    this.emailService = emailService;
    this.gateway = gateway;
  }

  public void processPayment(BigDecimal amount) {
    gateway.charge(amount);
    emailService.sendReceipt(amount);
  }
}

// Test with mocks
@Test
void testPaymentProcessing() {
  EmailService mockEmail = mock(EmailService.class);
  PaymentGateway mockGateway = mock(PaymentGateway.class);

  PaymentProcessor processor = new PaymentProcessor(mockEmail, mockGateway);
  processor.processPayment(new BigDecimal("100.00"));

  verify(mockGateway).charge(new BigDecimal("100.00"));
  verify(mockEmail).sendReceipt(new BigDecimal("100.00"));
}

Test Behavior, Not Implementation

Focus tests on observable behavior rather than internal details.

Example:

// ❌ Tests implementation details
@Test
void testCalculateDiscount_CallsGetPriceThreeTimes() {
  Order order = spy(new Order());
  order.calculateDiscount();
  verify(order, times(3)).getPrice(); // Brittle test
}

// ✅ Tests behavior
@Test
void testCalculateDiscount_AppliesTenPercentForPremiumCustomers() {
  Order order = new Order(new BigDecimal("100.00"), true);
  BigDecimal discount = order.calculateDiscount();
  assertEquals(new BigDecimal("10.00"), discount);
}

Concurrency Best Practices

Prefer Immutable Objects for Thread Safety

Immutable objects eliminate synchronization needs.

Example:

// ❌ Mutable shared state - requires synchronization
public class Counter {
  private int count = 0;

  public synchronized void increment() {
    count++;
  }

  public synchronized int getCount() {
    return count;
  }
}

// ✅ Immutable - thread-safe by design
public final class CounterSnapshot {
  private final int count;

  public CounterSnapshot(int count) {
    this.count = count;
  }

  public int getCount() {
    return count;
  }

  public CounterSnapshot increment() {
    return new CounterSnapshot(count + 1);
  }
}

Use Concurrent Collections

Java provides thread-safe collections that outperform manual synchronization.

Example:

// ❌ Synchronized collections - performance bottleneck
Map<String, User> users = Collections.synchronizedMap(new HashMap<>());

// ✅ Concurrent collections - better performance
Map<String, User> users = new ConcurrentHashMap<>();

// ✅ For high contention scenarios
BlockingQueue<Task> tasks = new LinkedBlockingQueue<>();

Design Philosophy

SOLID Principles

Single Responsibility: A class should have one reason to change.

Open/Closed: Open for extension, closed for modification.

Liskov Substitution: Subtypes must be substitutable for base types.

Interface Segregation: Clients should not depend on interfaces they do not use.

Dependency Inversion: Depend on abstractions, not concretions.

These principles work together to create flexible, maintainable systems. Each best practice shown above supports one or more SOLID principles.

When to Break the Rules

Best practices are guidelines, not absolute laws. Break them when:

  • Performance critical paths: Profiling shows a practice hurts performance
  • Framework constraints: Framework requires specific patterns
  • Pragmatic trade-offs: Cost of applying practice exceeds benefit
  • Team consensus: Team agrees on different approach for specific context

Always document deviations with clear reasoning.

Summary

Quality Java code emerges from consistently applying fundamental principles that reinforce each other over time. Favoring immutability eliminates entire categories of bugs related to unexpected state changes while making your code naturally thread-safe. When you need to combine behaviors, composition provides the flexibility to change implementations and mix capabilities without the rigidity of inheritance hierarchies.

Programming to interfaces rather than concrete classes loosens the coupling between components, enabling you to swap implementations and test with mocks. The fail-fast principle catches errors at their source rather than letting them propagate and corrupt state far from the original problem. Descriptive names eliminate the need for comments by making your code self-documenting - spend the extra seconds choosing names that reveal intent.

Resource management becomes reliable and automatic when you use try-with-resources for anything that needs cleanup. Exception handling should distinguish between programming errors that indicate bugs and recoverable conditions that callers can handle meaningfully. Choose your data structures based on actual access patterns rather than defaulting to ArrayList for everything - the right collection makes algorithms naturally efficient.

Keep your methods small and focused on doing one thing well. This makes them easier to understand, test, and reuse. Large methods that do many things are really several methods waiting to be extracted. Small, focused methods compose naturally into larger behaviors while remaining individually comprehensible.

These practices compound their benefits over the lifetime of your codebase. Immutability makes refactoring safer. Composition enables adding features without breaking existing code. Interfaces and dependency injection make testing straightforward. Clear names and small methods make maintenance faster. Together, these principles create code that’s easier to work with, whether you’re adding features, fixing bugs, or bringing new developers onto the team.

Related Content

Explanations:

How-To Guides:

Tutorials:

Reference:

Last updated