Part 16 of 16

Java 8 Best Practices and Patterns for Production Code

Streams Best Practices

Do: Use streams for transformations and aggregations

// Good: filter → transform → collect
List<String> premiumNames = customers.stream()
    .filter(Customer::isPremium)
    .map(Customer::getName)
    .sorted()
    .collect(Collectors.toList());

// Good: aggregation
Map<String, Long> countByCity = customers.stream()
    .collect(Collectors.groupingBy(Customer::getCity, Collectors.counting()));

Don’t: Use streams for simple indexed iteration

// Bad: stream for something a loop does more clearly
IntStream.range(0, list.size())
    .forEach(i -> System.out.println(i + ": " + list.get(i)));

// Good: traditional loop wins here
for (int i = 0; i < list.size(); i++) {
    System.out.println(i + ": " + list.get(i));
}

Do: Use method references when the intent is clearer

// Good
stream.map(String::toUpperCase)
stream.filter(String::isEmpty)
stream.forEach(System.out::println)

// Bad: no improvement over the method reference
stream.map(s -> s.toUpperCase())
stream.filter(s -> s.isEmpty())

Don’t: Use streams for side effects as a primary purpose

// Bad: using stream purely for side effects
names.stream().forEach(name -> emailService.sendWelcome(name));

// Good: forEach on the collection directly
names.forEach(name -> emailService.sendWelcome(name));
// or just a loop
for (String name : names) emailService.sendWelcome(name);

Streams shine for data transformation pipelines. For side effects, a loop or Iterable.forEach is clearer.

Do: Prefer primitive streams for numeric work

// Bad: boxes each int to Integer — heap pressure
int sum = numbers.stream()
    .map(n -> n * 2)
    .reduce(0, Integer::sum);

// Good: no boxing
int sum = numbers.stream()
    .mapToInt(Integer::intValue)
    .map(n -> n * 2)
    .sum();

Don’t: Accumulate into external collections in forEach

// Bad: not thread-safe, mixes concerns
List<String> result = new ArrayList<>();
stream.filter(s -> s.length() > 3)
      .forEach(result::add); // side effect

// Good
List<String> result = stream
    .filter(s -> s.length() > 3)
    .collect(Collectors.toList());

Do: Handle sorted/distinct carefully in parallel streams

// sorted() in a parallel stream has overhead — forces re-ordering after parallel processing
// Only use sorted() in parallel if you truly need it
stream.parallel().sorted().collect(...); // fine, but costs extra

// If you don't need order in the result, drop sorted():
stream.parallel().collect(...); // faster

Don’t: Call get() on a stream more than once

Stream<String> stream = list.stream();
List<String> r1 = stream.collect(Collectors.toList()); // OK
long count = stream.count(); // IllegalStateException — stream already consumed

// Always create a new stream:
long count = list.stream().count();

Optional Best Practices

Do: Use Optional only as a return type

// Good: signals "might not be found" to callers
public Optional<User> findByEmail(String email) { ... }

// Bad: as a field — not Serializable, wastes memory
private Optional<String> middleName;
// Good: nullable field with Optional getter
private String middleName;
public Optional<String> getMiddleName() { return Optional.ofNullable(middleName); }

Do: Use orElseGet for expensive fallbacks

// Bad: createDefault() always called even when present
User user = opt.orElse(createDefault());

// Good: createDefault() only called when absent
User user = opt.orElseGet(this::createDefault);

Don’t: Call get() without isPresent()

// Bad: throws NoSuchElementException if empty
String name = opt.get();

// Good: use orElseThrow for clear intent
String name = opt.orElseThrow(() -> new UserNotFoundException(id));

// Good: use map/orElse for transformations with defaults
String name = opt.map(User::getName).orElse("unknown");

Don’t: Chain Optional with isPresent() + get()

// Bad: verbose null check in disguise
if (opt.isPresent()) {
    System.out.println(opt.get().getName());
}

// Good
opt.ifPresent(user -> System.out.println(user.getName()));
opt.map(User::getName).ifPresent(System.out::println);

Don’t: Use Optional.of() for potentially-null values

// Bad: throws NPE if repo.findById returns null
Optional<User> opt = Optional.of(repo.findById(id));

// Good
Optional<User> opt = Optional.ofNullable(repo.findById(id));

Lambda Readability Rules

Rule 1: If a lambda is longer than 3 lines, extract it to a named method

// Bad: complex logic inside lambda
users.stream()
    .filter(user -> {
        if (user.getAge() < 18) return false;
        if (!user.isActive()) return false;
        if (user.getSubscriptionExpiry().isBefore(LocalDate.now())) return false;
        return user.hasVerifiedEmail();
    })
    .collect(Collectors.toList());

// Good: named method with clear intent
users.stream()
    .filter(this::isEligible)
    .collect(Collectors.toList());

private boolean isEligible(User user) {
    return user.getAge() >= 18
        && user.isActive()
        && !user.getSubscriptionExpiry().isBefore(LocalDate.now())
        && user.hasVerifiedEmail();
}

Rule 2: Name lambda parameters meaningfully

// Bad: single-letter parameters lose context
users.stream()
    .filter(u -> u.getAge() > 18)
    .map(u -> u.getName());

// Good: descriptive names
users.stream()
    .filter(user -> user.getAge() > 18)
    .map(User::getName);  // method reference is even better

Rule 3: Prefer method references when they add clarity

// Good: method reference is clear
.map(String::toUpperCase)
.filter(Objects::nonNull)
.sorted(Comparator.comparing(Order::getDate))

// Lambda is better when the method reference obscures context
// Bad: hard to tell what 'this::process' does without knowing the class
.map(this::process)
// Good: lambda makes the operation visible
.map(item -> item.applyDiscount(0.1))

Rule 4: Don’t use lambdas to wrap constructors when new is clear

// Unnecessary
.map(s -> new StringBuilder(s))

// Clear
.map(StringBuilder::new)

CompletableFuture Best Practices

Do: Always use a custom executor for I/O

// Bad: blocks the shared ForkJoinPool with I/O waits
CompletableFuture.supplyAsync(() -> httpClient.get(url));

// Good: dedicated thread pool for I/O
ExecutorService ioPool = Executors.newCachedThreadPool();
CompletableFuture.supplyAsync(() -> httpClient.get(url), ioPool);

Do: Always add error handling

// Bad: exceptions are silently swallowed
CompletableFuture.supplyAsync(() -> riskyCall());

// Good
CompletableFuture.supplyAsync(() -> riskyCall())
    .exceptionally(ex -> { log.error("Failed", ex); return fallback; });

Do: Use thenCompose (not thenApply) for chaining async operations

// Bad: nested CompletableFuture<CompletableFuture<T>>
future.thenApply(user -> fetchOrders(user.getId())); // returns CF<CF<Orders>>

// Good
future.thenCompose(user -> fetchOrders(user.getId())); // returns CF<Orders>

Don’t: Block inside async callbacks

// Bad: deadlock risk
future.thenApply(user -> anotherFuture.get()); // blocking inside callback

// Good: chain with thenCompose
future.thenCompose(user -> anotherFuture);

Date/Time Best Practices

Do: Use Instant for timestamps, ZonedDateTime for display

// Storing an event timestamp — use Instant
Instant occurredAt = Instant.now();

// Displaying to a user — convert to their timezone
ZonedDateTime userTime = occurredAt.atZone(ZoneId.of("Asia/Kolkata"));

Do: Keep DateTimeFormatter as a static constant

// Bad: creates a new formatter object on every call
public String format(LocalDate date) {
    return date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")); // new object each time
}

// Good: formatter is immutable and thread-safe — safe as static
private static final DateTimeFormatter DATE_FMT =
    DateTimeFormatter.ofPattern("dd/MM/yyyy");

public String format(LocalDate date) {
    return date.format(DATE_FMT);
}

Don’t: Mix java.util.Date and java.time unless at boundaries

// At legacy API boundary, convert once and use java.time internally
Date legacyDate = legacyService.getDate();
LocalDateTime dt = legacyDate.toInstant()
    .atZone(ZoneId.systemDefault())
    .toLocalDateTime();
// Use dt from here on — never pass legacyDate further into your code

Java 7 to Java 8 Migration Checklist

Language

  • Replace new Runnable() { ... } with () -> ...
  • Replace new Comparator<T>() { ... } with Comparator.comparing(...)
  • Replace Collections.sort(list, cmp) with list.sort(cmp)
  • Replace manual null checks in return values with Optional
  • Replace new Date() / Calendar with Instant.now() / LocalDateTime.now()
  • Replace SimpleDateFormat with DateTimeFormatter (and make it static final)

Collections

  • Replace for loops that filter+collect with stream().filter().collect()
  • Replace manual frequency maps (if containsKey...put) with map.merge(k, 1, Integer::sum)
  • Replace map.get() + null check with map.getOrDefault() or computeIfAbsent
  • Replace iterator-based removal with collection.removeIf()

Concurrency

  • Replace AtomicLong high-contention counters with LongAdder
  • Replace chained Future.get() calls with CompletableFuture pipeline
  • Replace ReadWriteLock in read-heavy code with StampedLock

JVM Flags

  • Remove -XX:PermSize and -XX:MaxPermSize from JVM startup scripts
  • Optionally add -XX:MaxMetaspaceSize=256m to cap native memory growth
  • Consider adding -XX:+UseG1GC for heaps > 4 GB
  • Remove -client / -server / -d32 / -d64 flags (obsolete in Java 8)

Build

  • Set maven.compiler.source=8 and maven.compiler.target=8 (or release=8)
  • Upgrade maven-compiler-plugin to 3.8.0+
  • Upgrade Gradle to 5.0+ for full Java 8 annotation processing support

Testing

  • Update to JUnit 5 (supports Java 8 lambda-style tests)
  • Update Mockito to 2.x+ (full Java 8 compatibility)
  • Replace @Test(expected=...) with assertThrows(() -> ...)

Production-Readiness Checklist

Before deploying a Java 8 codebase to production:

AreaCheck
StreamsNo get() on streams after terminal op; no shared mutable state in parallel streams
OptionalNo Optional fields; no get() without isPresent(); using orElseGet for expensive defaults
Date/TimeNo new Date() or SimpleDateFormat; DateTimeFormatter is static final
CompletableFutureCustom pool for I/O; error handling on every chain; no blocking get() in callbacks
Metaspace-XX:MaxMetaspaceSize set to prevent unbounded native memory growth
Base64Using java.util.Base64; not sun.misc.BASE64Encoder
LoggingSupplier-based logging for lazy evaluation of expensive log arguments

Summary

Java 8 introduced a new programming model for Java. The most important practices:

  1. Streams are for data pipelines — transformation, filtering, aggregation. Use them for that; use loops for imperative logic.
  2. Optional belongs in return types at API boundaries, not in fields or parameters.
  3. Lambdas should be short. If a lambda is longer than 3 lines, extract it to a named method.
  4. CompletableFuture needs a custom I/O pool and explicit error handling — never assume it’s fire-and-forget.
  5. Date/Time — always java.time, never java.util.Date. Instant for storage, ZonedDateTime for display.
  6. Metaspace — set -XX:MaxMetaspaceSize in production to prevent unbounded native memory growth.

This is the final article in the Java 8 Tutorial series. Continue your Java journey:

Part of the DevOps Monk Java tutorial series: Java 8Java 11Java 17Java 21