Part 16 of 16

Java 8 Best Practices and Patterns for Production Code

Introduction

Java 8 introduced a fundamentally different programming model. The features — lambdas, streams, Optional, CompletableFuture — interact with each other in ways that produce clean, readable code when used correctly and confusing, brittle code when used incorrectly. This article consolidates the most important production-ready guidance from across the series into a single reference, along with the migration checklist for moving a Java 7 codebase to Java 8.


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

The table below is designed for use during a migration code review. Each row maps a Java 7 pattern to its idiomatic Java 8 equivalent, explains why the replacement is better, and notes any caveats.

Language and API

Java 7 PatternJava 8 ReplacementBenefitCaveat
new Runnable() { public void run() { ... } }() -> ...5× less boilerplateNone
new Comparator<T>() { public int compare(...) { ... } }Comparator.comparing(T::field)Readable, composableMulti-key: chain .thenComparing()
Collections.sort(list, cmp)list.sort(cmp)Method on the instanceRequires ArrayList or mutable List
Null return value for “not found”Optional.ofNullable(value)Forces callers to handle absenceUse only in return types, not fields
new Date() / new Date(long)Instant.now() / Instant.ofEpochMilli(ms)Immutable, thread-safeInterop with legacy: Date.from(instant)
new GregorianCalendar(...)ZonedDateTime.of(...)DST-aware, readableConvert at boundary: cal.toInstant()
SimpleDateFormat (non-thread-safe)DateTimeFormatter (thread-safe)Safe to share as static finalPattern letters differ slightly from SDF
Anonymous class for strategy patternLambda or method referenceNo class file generatedOnly for single-method interfaces
for (T t : list) { if (...) result.add(t); }list.stream().filter(...).collect(toList())Declarative, composableNot always faster; benchmark numeric loops
for (T t : list) { result.add(transform(t)); }list.stream().map(this::transform).collect(toList())Composable, parallelisableNone

Collections and Maps

Java 7 PatternJava 8 ReplacementBenefit
if (!map.containsKey(k)) map.put(k, v);map.putIfAbsent(k, v)Atomic; fewer lines
if (!map.containsKey(k)) map.put(k, new ArrayList<>()); map.get(k).add(v);map.computeIfAbsent(k, x -> new ArrayList<>()).add(v)Atomic; half the lines
map.containsKey(k) ? map.get(k) : defaultValmap.getOrDefault(k, defaultVal)No double lookup
Manual word-frequency if/else putmap.merge(word, 1, Integer::sum)Atomic accumulation
Iterator-based removal during loopcollection.removeIf(predicate)No ConcurrentModificationException risk
for loop setting each elementlist.replaceAll(UnaryOperator)In-place transform, no index arithmetic
for (Map.Entry<K,V> e : map.entrySet())map.forEach((k, v) -> ...)No .entrySet() boilerplate

Concurrency

Java 7 PatternJava 8 ReplacementBenefitCaveat
Chained Future.get() callsCompletableFuture with thenCompose / thenCombineNon-blocking; composableAlways attach exceptionally or handle
AtomicLong under high contentionLongAdderFar less CAS contentionsum() is approximate under concurrent updates
ReadWriteLock in read-heavy codeStampedLock with optimistic readsHigher throughputNot reentrant; more complex API
synchronized on a simple counterLongAdder / AtomicLongLock-freeLongAdder for pure counters; AtomicLong for CAS
ThreadLocal<SimpleDateFormat>static final DateTimeFormatterNo ThreadLocal managementDateTimeFormatter is already thread-safe

JVM and Build

AreaJava 7Java 8 Action
JVM flag-XX:PermSize=NRemove — PermGen does not exist
JVM flag-XX:MaxPermSize=NReplace with -XX:MaxMetaspaceSize=N
JVM flag-client / -serverRemove — ignored in Java 8 64-bit
JVM flag-d32 / -d64Remove — no longer supported
Heap OOMOutOfMemoryError: PermGen spaceAdd -XX:MaxMetaspaceSize=256m to cap native memory
GCCMS (-XX:+UseConcMarkSweepGC)Consider -XX:+UseG1GC for heaps > 4 GB
Maven<source>1.7</source>Change to <source>8</source> and <target>8</target>
Maven pluginmaven-compiler-plugin 2.xUpgrade to 3.8.0+
GradlesourceCompatibility = '1.7'Change to JavaVersion.VERSION_1_8
LibraryApache Commons Base64 / sun.misc.BASE64EncoderReplace with java.util.Base64
TestingJUnit 4 @Test(expected=...)JUnit 5 assertThrows(() -> ...)
TestingMockito 1.xUpgrade to Mockito 2.x+ (Java 8 compatible)

Checklist Items (tick-box format)

Language

  • Replace all new Runnable() { ... } with () -> ...
  • Replace all new Comparator<T>() { ... } with Comparator.comparing(...)
  • Replace Collections.sort(list, cmp) with list.sort(cmp)
  • Replace null-returning methods with Optional-returning equivalents at API boundaries
  • Replace new Date() / Calendar with Instant.now() / LocalDateTime.now()
  • Replace SimpleDateFormat with DateTimeFormatter (make it static final)

Collections

  • Replace filter+collect for loops with stream().filter().collect(toList())
  • Replace manual frequency maps 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 high-contention AtomicLong counters with LongAdder
  • Replace chained Future.get() calls with CompletableFuture pipeline
  • Replace ReadWriteLock in read-heavy paths with StampedLock

JVM Flags

  • Remove -XX:PermSize and -XX:MaxPermSize from all JVM startup scripts
  • Add -XX:MaxMetaspaceSize=256m to cap native memory growth
  • Consider -XX:+UseG1GC for heaps > 4 GB
  • Remove -client / -server / -d32 / -d64 flags

Build

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

Testing

  • Upgrade to JUnit 5 for lambda-style assertions
  • Upgrade Mockito to 2.x+
  • 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