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 |