Skip to content

Java – Best Practices

This page covers the programming practices that produce production-grade Java at Cygnus Dynamics. These go beyond naming and formatting to cover how to write code that is correct, safe, testable, and maintainable over time.

Immutability First

Prefer immutable objects. Immutable objects are inherently thread-safe, easier to reason about, and eliminate an entire class of bugs related to unexpected state mutation.

// ✅ Correct — fields are final, set once in the constructor
public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        Objects.requireNonNull(amount, "amount must not be null");
        Objects.requireNonNull(currency, "currency must not be null");
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("amount must not be negative");
        }
        this.amount = amount;
        this.currency = currency;
    }

    public BigDecimal getAmount() { return amount; }
    public Currency getCurrency() { return currency; }

    // Returns a new instance — does not mutate this
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException(this.currency, other.currency);
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

// ❌ Incorrect — mutable state, setters expose internal state to mutation
public class Money {
    private BigDecimal amount;
    private Currency currency;

    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public void setCurrency(Currency currency) { this.currency = currency; }
}

Records for Immutable Data Classes (Java 16+)

Use records for immutable data carriers — DTOs, value objects, and response types. Records eliminate the boilerplate of constructors, getters, equals, hashCode, and toString.

// ✅ Correct — record replaces a 40-line boilerplate class
public record CreateOrderRequest(
    Long userId,
    List<OrderItemRequest> items,
    String couponCode
) {
    // Compact constructor for validation
    public CreateOrderRequest {
        Objects.requireNonNull(userId, "userId must not be null");
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("items must not be empty");
        }
        items = List.copyOf(items); // defensive copy — makes the list truly immutable
    }
}

public record OrderItemRequest(Long productId, int quantity) {
    public OrderItemRequest {
        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
    }
}

// ✅ Usage — all generated automatically
var req = new CreateOrderRequest(userId, items, "SAVE10");
Long userId = req.userId();                        // generated getter
System.out.println(req);                           // generated toString
boolean same = req.equals(otherReq);               // generated equals

// ❌ Incorrect — manual boilerplate that records eliminate
public class CreateOrderRequest {
    private final Long userId;
    private final List<OrderItemRequest> items;

    public CreateOrderRequest(Long userId, List<OrderItemRequest> items) {
        this.userId = userId;
        this.items = items;
    }

    public Long getUserId() { return userId; }
    public List<OrderItemRequest> getItems() { return items; }

    @Override public boolean equals(Object o) { /* 10 lines */ }
    @Override public int hashCode() { /* 5 lines */ }
    @Override public String toString() { /* 5 lines */ }
}

Optional for Nullable Return Values

Use Optional<T> for methods that may legitimately return no value. Never return null from a public method — it forces every caller to add a null check that they might forget.

// ✅ Correct — Optional makes the possibility of absence explicit
public Optional<User> findUserByEmail(String email) {
    return userRepository.findByEmail(email);
}

// ✅ Correct — throw when absence is an error condition
public User getUserById(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));
}

// ✅ Correct — Optional usage at the call site
Optional<User> user = findUserByEmail("alice@example.com");
user.ifPresent(u -> notificationService.sendWelcomeBack(u));
String name = user.map(User::getFullName).orElse("Guest");

// ❌ Incorrect — null return forces caller to remember to null-check
public User findUserByEmail(String email) {
    return userRepository.findByEmail(email).orElse(null);
}

// ❌ Incorrect — Optional is not for collections (use empty list instead)
public Optional<List<Order>> findOrdersByUser(Long userId) { }
// ✅ Correct — return empty list
public List<Order> findOrdersByUser(Long userId) {
    return orderRepository.findByUserId(userId);  // JPA returns empty list, never null
}

Optional is for return values only

Do not use Optional as a method parameter, a field type, or in a collection. It is designed exclusively for return types where absence is a meaningful outcome.

Streams and Lambdas

Use streams for collection transformations, filtering, and aggregation. Keep stream pipelines readable — break long pipelines across lines.

// ✅ Correct — each operation on its own line, descriptive variable name
List<OrderSummary> confirmedOrders = orders.stream()
    .filter(order -> order.getStatus() == OrderStatus.CONFIRMED)
    .sorted(Comparator.comparing(Order::getCreatedAt).reversed())
    .map(orderMapper::toSummary)
    .collect(Collectors.toList());

// ✅ Correct — aggregation
BigDecimal totalRevenue = confirmedOrders.stream()
    .map(OrderSummary::getTotal)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// ✅ Correct — grouping
Map<OrderStatus, List<Order>> ordersByStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::getStatus));

// ❌ Incorrect — more than 2 levels of nesting in a lambda — extract to a method
List<String> result = orders.stream()
    .filter(o -> o.getItems().stream()
        .anyMatch(i -> i.getProduct().getCategory().equals("electronics") && i.getQuantity() > 1))
    .map(o -> o.getUser().getEmail())
    .collect(Collectors.toList());

// ✅ Correct — extract complex predicates to named methods
List<String> result = orders.stream()
    .filter(this::hasElectronicsItemWithMultipleQuantity)
    .map(order -> order.getUser().getEmail())
    .collect(Collectors.toList());

private boolean hasElectronicsItemWithMultipleQuantity(Order order) {
    return order.getItems().stream()
        .anyMatch(item ->
            item.getProduct().getCategory().equals("electronics") &&
            item.getQuantity() > 1
        );
}

Sealed Classes for Restricted Hierarchies (Java 17+)

Use sealed classes when a type has a fixed, known set of subtypes. This gives the compiler exhaustiveness checking in switch expressions and eliminates the need for a default fallback case.

// ✅ Correct — sealed hierarchy makes all states explicit and exhaustive
public sealed interface PaymentResult
    permits PaymentResult.Success, PaymentResult.Declined, PaymentResult.Error {

    record Success(String transactionId, BigDecimal amount) implements PaymentResult { }
    record Declined(String declineCode, String reason) implements PaymentResult { }
    record Error(String message, Throwable cause) implements PaymentResult { }
}

// ✅ Exhaustive switch — compiler verifies all cases are covered, no default needed
String message = switch (result) {
    case PaymentResult.Success s  -> "Payment confirmed: " + s.transactionId();
    case PaymentResult.Declined d -> "Card declined: " + d.reason();
    case PaymentResult.Error e    -> "Payment error: " + e.message();
};

Pattern Matching for instanceof (Java 16+)

Use pattern matching to eliminate the explicit cast that follows an instanceof check.

// ✅ Correct — pattern matching eliminates the cast
public String describe(Object event) {
    return switch (event) {
        case OrderCreatedEvent e  -> "Order created: " + e.getOrderId();
        case PaymentReceivedEvent e -> "Payment received: " + e.getAmount();
        case OrderCancelledEvent e -> "Order cancelled: " + e.getReason();
        default -> "Unknown event: " + event.getClass().getSimpleName();
    };
}

// ❌ Incorrect — old-style instanceof with manual cast
public String describe(Object event) {
    if (event instanceof OrderCreatedEvent) {
        OrderCreatedEvent e = (OrderCreatedEvent) event;  // redundant cast
        return "Order created: " + e.getOrderId();
    } else if (event instanceof PaymentReceivedEvent) {
        PaymentReceivedEvent e = (PaymentReceivedEvent) event;
        return "Payment received: " + e.getAmount();
    }
    return "Unknown";
}

Always Override hashCode When Overriding equals

The equals/hashCode contract is fundamental to how Java collections work. Breaking it produces silent, intermittent bugs in HashMap, HashSet, and HashTable.

// ✅ Correct — consistent equals and hashCode using Objects utility
public class Product {
    private final Long id;
    private final String sku;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Product product)) return false;
        return Objects.equals(id, product.id) && Objects.equals(sku, product.sku);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, sku);  // same fields as equals
    }
}

// ❌ Incorrect — overrides equals but not hashCode
// Two equal Products will have different hashCodes — breaks HashMap behaviour
public class Product {
    @Override
    public boolean equals(Object o) { /* ... */ }
    // hashCode not overridden — inherits Object.hashCode() based on identity
}

Use @Transactional Correctly

Transaction boundaries are managed at the service layer only. Never place @Transactional on a controller method.

// ✅ Correct — transaction at service layer, read-only where appropriate
@Service
public class OrderService {

    @Transactional(readOnly = true)  // read-only improves performance for queries
    public Order getOrder(Long id) {
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Transactional  // write transaction — rolls back on any RuntimeException
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(request);
        orderRepository.save(order);
        inventoryService.reserveStock(order.getItems());  // rolls back if this fails
        return order;
    }
}

// ❌ Incorrect — transaction on the controller
@RestController
public class OrderController {

    @Transactional  // wrong layer — Spring proxies may not work correctly here
    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
        return ResponseEntity.ok(orderService.createOrder(request));
    }
}

Close Resources with Try-With-Resources

Any class that implements AutoCloseable — connections, streams, readers, writers — must be managed with try-with-resources. See the Exception Handling page for full coverage.

// ✅ Always — resource closed automatically even on exception
try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement(SQL)) {
    stmt.setLong(1, userId);
    return stmt.executeQuery();
}

// ❌ Never — resource not closed if an exception is thrown mid-execution
Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(SQL);
stmt.setLong(1, userId);
ResultSet rs = stmt.executeQuery();
conn.close(); // skipped if exception thrown above

Avoid Raw Types

Always use generics with their type parameters. Raw types defeat the purpose of the type system and produce unchecked cast warnings that hide real bugs.

// ✅ Correct — parameterised types
List<Order> orders = new ArrayList<>();
Map<Long, User> userMap = new HashMap<>();
Optional<Payment> payment = paymentRepository.findById(id);

// ❌ Incorrect — raw types lose type safety
List orders = new ArrayList();        // compiler can't verify what goes in
Map userMap = new HashMap();
Optional payment = paymentRepository.findById(id);

Dependency Injection — Prefer Constructor Injection

Inject dependencies via the constructor, not field injection. Constructor injection makes dependencies explicit, supports immutability, and makes unit testing straightforward.

// ✅ Correct — constructor injection
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final NotificationService notificationService;

    public OrderService(
        OrderRepository orderRepository,
        PaymentService paymentService,
        NotificationService notificationService
    ) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
        this.notificationService = notificationService;
    }
}

// ❌ Incorrect — field injection hides dependencies, prevents final fields
@Service
public class OrderService {
    @Autowired private OrderRepository orderRepository;
    @Autowired private PaymentService paymentService;
    @Autowired private NotificationService notificationService;
}

Validate Method Inputs

Always validate the inputs to public methods. Do not assume callers have checked inputs before calling — they have not.

// ✅ Correct — validate early, fail fast with a clear message
public Order createOrder(CreateOrderRequest request) {
    Objects.requireNonNull(request, "request must not be null");
    Objects.requireNonNull(request.getUserId(), "userId must not be null");
    if (request.getItems() == null || request.getItems().isEmpty()) {
        throw new IllegalArgumentException("Order must contain at least one item");
    }
    // proceed with business logic
}

// ❌ Incorrect — no validation — NullPointerException thrown deep in the call chain
public Order createOrder(CreateOrderRequest request) {
    User user = userRepository.findById(request.getUserId()).orElseThrow();
    // NPE here if request is null — stack trace points here, not to the caller
}

Tooling Quick Reference

Tool Purpose Command
Google Java Format Auto-formats code — no style debates mvn fmt:format
Checkstyle Naming rules, import order, Javadoc mvn checkstyle:check
SpotBugs Static analysis — null dereferences, resource leaks mvn spotbugs:check
PMD Unused variables, empty catch blocks, over-complex code mvn pmd:check
JaCoCo Test coverage report and threshold enforcement mvn verify (runs as part of build)
OWASP Dependency Check Known CVE scan on all dependencies mvn dependency-check:check

All tools must be configured in pom.xml and run as part of the CI pipeline. A build that fails any tool check does not merge.

Best Practices Quick Reference

Practice Rule
Immutability Declare fields final; return new instances instead of mutating
Records Use for immutable DTOs and value objects (Java 16+)
Optional Return type only — never as parameter or field
Streams One operation per line; extract complex lambdas to named methods
Sealed classes Use for fixed, exhaustive type hierarchies (Java 17+)
hashCode / equals Always override both together; use Objects.hash() and Objects.equals()
@Transactional Service layer only; use readOnly = true for queries
Try-with-resources All AutoCloseable resources — no exceptions
Raw types Never — always parameterise generics
Constructor injection Always — never field injection with @Autowired
Input validation Always validate public method inputs at the earliest point