Post

Writing Better Java — Clean Code Practices for 2026

Modern Java clean code practices for 2026. Covers var usage, record patterns, Optional best practices, Stream vs loop decisions, immutability by default, and code smells to eliminate — with before/after examples.

Writing Better Java — Clean Code Practices for 2026

Why This Matters

Clean code isn’t about aesthetics — it’s about maintenance cost. Code is read 10x more than it’s written. Every shortcut you take today becomes a tax on every developer who touches that code later, including future you.

Java in 2026 gives us tools that make clean code easier to write. Records, pattern matching, sealed classes, and improved APIs mean we can express intent more directly. This post covers practical patterns — not theory.

flowchart LR
    A[Write Code] --> B[Read Code]
    B --> C[Understand Code]
    C --> D[Modify Code]
    D --> B
    
    style B fill:#f96,stroke:#333
    style C fill:#f96,stroke:#333

diagram

The red boxes are where you spend most of your time. Clean code optimizes for reading and understanding.


var — When It Helps (and When It Hurts)

var (local variable type inference) reduces boilerplate when the type is obvious. It does not mean “untyped.”

When var Helps

1
2
3
4
5
6
7
// BEFORE: Redundant — type appears twice
Map<String, List<CustomerOrder>> ordersByCustomer = new HashMap<String, List<CustomerOrder>>();
CustomerRegistrationService service = new CustomerRegistrationService();

// AFTER: Type is obvious from the right side
var ordersByCustomer = new HashMap<String, List<CustomerOrder>>();
var service = new CustomerRegistrationService();
1
2
3
4
5
6
7
8
9
10
// In loops — type is clear from context
for (var entry : ordersByCustomer.entrySet()) {
    var customer = entry.getKey();
    var orders = entry.getValue();
}

// With try-with-resources
try (var reader = new BufferedReader(new FileReader(path))) {
    var firstLine = reader.readLine();
}

When var Hurts

1
2
3
4
5
6
7
8
9
// BAD: What type does this return?
var result = service.process(input);
var data = repository.find(criteria);
var x = calculate(a, b, c);

// GOOD: Keep the type when it's not obvious
ProcessingResult result = service.process(input);
Optional<Customer> data = repository.find(criteria);
BigDecimal x = calculate(a, b, c);

The Rule

Use var when the type is obvious from the right side of the assignment or from the variable name. If a reader would need to hover/navigate to understand the type, spell it out.


Records Everywhere

Records should be your default for data carriers. Stop writing classes with 50 lines of boilerplate for what is essentially a tuple.

DTOs

1
2
3
4
5
6
7
8
9
10
// BEFORE: 45 lines of boilerplate
public class CustomerDto {
    private final Long id;
    private final String name;
    private final String email;
    // constructor, getters, equals, hashCode, toString...
}

// AFTER: 1 line
public record CustomerDto(Long id, String name, String email) {}

Value Objects

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount must not be null");
        Objects.requireNonNull(currency, "currency must not be null");
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("Too many decimal places");
        }
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot add different currencies");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

Method Returns (replace Pair/Tuple)

1
2
3
4
5
6
7
// BEFORE: What does Pair<String, Integer> even mean?
public Pair<String, Integer> processOrder(Order order) { ... }

// AFTER: Self-documenting
public record OrderResult(String confirmationId, int estimatedDays) {}

public OrderResult processOrder(Order order) { ... }

Optional Done Right

Optional is the most misused feature in Java. Here are the 5 rules:

The 5 Rules of Optional

RuleDoDon’t
1. Return type onlyOptional<User> findById(Long id)void process(Optional<User> user)
2. Never nullAlways return Optional.empty()Never return null from an Optional method
3. Don’t use for fieldsUse nullable fields internallyprivate Optional<String> middleName
4. Don’t use in collectionsList<User> (empty list = no results)Optional<List<User>>
5. Chain, don’t unwrapUse map, flatMap, orElseThrowDon’t if (opt.isPresent()) opt.get()

Before/After

1
2
3
4
5
6
7
8
9
10
11
12
// BAD: Treating Optional like a null check
Optional<User> maybeUser = userRepository.findById(id);
if (maybeUser.isPresent()) {
    User user = maybeUser.get();
    return user.getEmail();
}
return "unknown";

// GOOD: Fluent chain
return userRepository.findById(id)
        .map(User::getEmail)
        .orElse("unknown");
1
2
3
4
5
6
// BAD: Optional for parameters
public void sendEmail(Optional<String> cc) { ... }

// GOOD: Overloaded methods or @Nullable
public void sendEmail() { sendEmail(null); }
public void sendEmail(@Nullable String cc) { ... }
1
2
3
4
5
6
7
8
// GOOD: Flat-mapping nested Optionals
Optional<String> city = userRepository.findById(id)
        .map(User::getAddress)       // Optional<Address>
        .map(Address::getCity);      // Optional<String>

// GOOD: orElseThrow with meaningful exception
User user = userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));

Streams vs Loops

Streams aren’t always better. Here’s a decision framework:

flowchart TD
    A[Collection Operation] --> B{Simple transform/filter?}
    B -->|Yes| C{Single pipeline?}
    C -->|Yes| D[Use Stream]
    C -->|No, multiple passes| E[Use Loop]
    B -->|No| F{Side effects needed?}
    F -->|Yes, accumulate/modify| E
    F -->|No| G{Readability?}
    G -->|Stream is clearer| D
    G -->|Loop is clearer| E

diagram

Use Streams When

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Filtering + transforming — natural pipeline
var activeEmails = users.stream()
        .filter(User::isActive)
        .map(User::getEmail)
        .sorted()
        .toList();

// Grouping/collecting — expressive
var byDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::department));

// Reducing — clean aggregation
var totalSalary = employees.stream()
        .map(Employee::salary)
        .reduce(BigDecimal.ZERO, BigDecimal::add);

Use Loops When

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Multiple side effects per iteration
for (var order : pendingOrders) {
    order.markProcessing();
    emailService.send(order.customerEmail(), "Processing...");
    auditLog.record(order.id(), "PROCESSING");
    metrics.increment("orders.processed");
}

// Complex control flow (break, continue, early return)
for (var candidate : candidates) {
    if (candidate.isDisqualified()) continue;
    var score = evaluate(candidate);
    if (score > threshold) {
        return candidate; // early return
    }
}

// Index-dependent logic
for (int i = 0; i < items.size(); i++) {
    if (i > 0 && items.get(i).equals(items.get(i - 1))) {
        duplicates.add(items.get(i));
    }
}

The Readability Threshold

If your stream pipeline exceeds 3 operations, extract intermediate steps or use a loop:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// TOO LONG: Hard to read
var result = orders.stream()
        .filter(o -> o.getStatus() == COMPLETED)
        .filter(o -> o.getDate().isAfter(cutoff))
        .map(Order::getLineItems)
        .flatMap(Collection::stream)
        .filter(li -> li.getCategory() == ELECTRONICS)
        .map(LineItem::getPrice)
        .reduce(BigDecimal.ZERO, BigDecimal::add);

// BETTER: Name intermediate steps
var completedAfterCutoff = orders.stream()
        .filter(o -> o.getStatus() == COMPLETED)
        .filter(o -> o.getDate().isAfter(cutoff))
        .toList();

var electronicsTotal = completedAfterCutoff.stream()
        .flatMap(o -> o.getLineItems().stream())
        .filter(li -> li.getCategory() == ELECTRONICS)
        .map(LineItem::getPrice)
        .reduce(BigDecimal.ZERO, BigDecimal::add);

Immutability by Default

Mutable state is the #1 source of bugs in concurrent code. Default to immutable.

Unmodifiable Collections

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Creating immutable collections
var names = List.of("Alice", "Bob", "Charlie");    // immutable
var scores = Map.of("Alice", 95, "Bob", 87);       // immutable
var tags = Set.of("java", "spring", "docker");     // immutable

// From a mutable source — copy to immutable
var result = mutableList.stream()
        .filter(predicate)
        .collect(Collectors.toUnmodifiableList());

// Or using toList() (already unmodifiable in Java 16+)
var result = mutableList.stream()
        .filter(predicate)
        .toList();

final by Habit

1
2
3
4
5
6
7
8
9
10
// Make parameters and local variables final (or effectively final)
public OrderResult process(final Order order) {
    final var customer = customerRepository.findById(order.customerId())
            .orElseThrow(() -> new CustomerNotFoundException(order.customerId()));

    final var discount = calculateDiscount(customer);
    final var total = order.subtotal().subtract(discount);

    return new OrderResult(order.id(), total, Instant.now());
}

Immutable Domain Objects

1
2
3
4
5
6
7
// Use records for immutable state
public record Address(String street, String city, String zip, String country) {
    // "modification" returns a new instance
    public Address withCity(String newCity) {
        return new Address(street, newCity, zip, country);
    }
}

Null Handling Strategy

ApproachWhen to UseExample
Optional return typePublic API methods that may not find a resultOptional<User> findById(Long id)
@Nullable annotationInternal code, framework integration@Nullable String middleName
Null Object patternWhen you need a no-op implementationNotification.NONE
Default valuesConfiguration, user preferencesconfig.getOrDefault("timeout", 30)
Early validationMethod entry pointsObjects.requireNonNull(param, "msg")
Empty collectionsInstead of returning null listsreturn Collections.emptyList()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Pattern: Validate at the boundary, trust internally
public class OrderService {

    public OrderResult createOrder(CreateOrderRequest request) {
        // Validate at entry — fail fast
        Objects.requireNonNull(request, "request must not be null");
        Objects.requireNonNull(request.customerId(), "customerId required");

        if (request.items().isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }

        // Internal code can trust non-null values
        return processValidOrder(request);
    }
}

Modern Switch (Eliminate if-else Chains)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// BEFORE: if-else chain (hard to read, easy to miss a case)
public BigDecimal calculateShipping(Order order) {
    if (order.type() == OrderType.DIGITAL) {
        return BigDecimal.ZERO;
    } else if (order.type() == OrderType.STANDARD) {
        return new BigDecimal("5.99");
    } else if (order.type() == OrderType.EXPRESS) {
        return new BigDecimal("14.99");
    } else if (order.type() == OrderType.SAME_DAY) {
        return new BigDecimal("24.99");
    } else {
        throw new IllegalArgumentException("Unknown type: " + order.type());
    }
}

// AFTER: Switch expression (exhaustive, clean)
public BigDecimal calculateShipping(Order order) {
    return switch (order.type()) {
        case DIGITAL  -> BigDecimal.ZERO;
        case STANDARD -> new BigDecimal("5.99");
        case EXPRESS  -> new BigDecimal("14.99");
        case SAME_DAY -> new BigDecimal("24.99");
    };
}

Exception Handling Patterns

Custom Exception Hierarchy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Base exception for your domain
public sealed class OrderException extends RuntimeException
    permits OrderNotFoundException, OrderValidationException, OrderProcessingException {

    private final String orderId;

    protected OrderException(String orderId, String message) {
        super(message);
        this.orderId = orderId;
    }

    public String getOrderId() { return orderId; }
}

public final class OrderNotFoundException extends OrderException {
    public OrderNotFoundException(String orderId) {
        super(orderId, "Order not found: " + orderId);
    }
}

public final class OrderValidationException extends OrderException {
    private final List<String> violations;

    public OrderValidationException(String orderId, List<String> violations) {
        super(orderId, "Validation failed for order " + orderId);
        this.violations = List.copyOf(violations);
    }
}

public final class OrderProcessingException extends OrderException {
    public OrderProcessingException(String orderId, Throwable cause) {
        super(orderId, "Processing failed for order " + orderId);
        initCause(cause);
    }
}

Pattern: Exception Mapping with switch

1
2
3
4
5
6
7
8
9
10
11
12
@ExceptionHandler(OrderException.class)
public ResponseEntity<ErrorResponse> handleOrderException(OrderException ex) {
    var response = switch (ex) {
        case OrderNotFoundException e ->
            new ErrorResponse(404, e.getMessage());
        case OrderValidationException e ->
            new ErrorResponse(400, e.getMessage(), e.getViolations());
        case OrderProcessingException e ->
            new ErrorResponse(500, "Internal processing error");
    };
    return ResponseEntity.status(response.status()).body(response);
}

Code Smells to Kill in 2026

SmellExampleFix
Primitive obsessionString email, int ageValue objects: Email, Age record
Boolean paramsprocess(order, true, false)Enums or parameter objects
Null returnsreturn nullOptional, empty collection, or throw
Raw typesList itemsList<OrderItem> items
String typingstatus = "ACTIVE"enum Status { ACTIVE, ... }
God classOrderManager (500+ lines)Split by responsibility
Deep nesting4+ levels of if/forEarly returns, extract methods
Comment-as-code// check if user is active before if(user.isActive())Delete obvious comments
Mutable DTOSetters on data carriersRecords
Checked exceptions everywherethrows IOException on business methodsWrap in domain exceptions
Magic numbersif (retries > 3)private static final int MAX_RETRIES = 3
Temporal couplingMust call init() before process()Constructor does initialization

References

This post is licensed under CC BY 4.0 by the author.