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>() { ... }withComparator.comparing(...) - Replace
Collections.sort(list, cmp)withlist.sort(cmp) - Replace manual null checks in return values with
Optional - Replace
new Date()/CalendarwithInstant.now()/LocalDateTime.now() - Replace
SimpleDateFormatwithDateTimeFormatter(and make itstatic final)
Collections
- Replace
forloops that filter+collect withstream().filter().collect() - Replace manual frequency maps (
if containsKey...put) withmap.merge(k, 1, Integer::sum) - Replace
map.get()+ null check withmap.getOrDefault()orcomputeIfAbsent - Replace iterator-based removal with
collection.removeIf()
Concurrency
- Replace
AtomicLonghigh-contention counters withLongAdder - Replace chained
Future.get()calls withCompletableFuturepipeline - Replace
ReadWriteLockin read-heavy code withStampedLock
JVM Flags
- Remove
-XX:PermSizeand-XX:MaxPermSizefrom JVM startup scripts - Optionally add
-XX:MaxMetaspaceSize=256mto cap native memory growth - Consider adding
-XX:+UseG1GCfor heaps > 4 GB - Remove
-client/-server/-d32/-d64flags (obsolete in Java 8)
Build
- Set
maven.compiler.source=8andmaven.compiler.target=8(orrelease=8) - Upgrade
maven-compiler-pluginto 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=...)withassertThrows(() -> ...)
Production-Readiness Checklist
Before deploying a Java 8 codebase to production:
| Area | Check |
|---|---|
| Streams | No get() on streams after terminal op; no shared mutable state in parallel streams |
| Optional | No Optional fields; no get() without isPresent(); using orElseGet for expensive defaults |
| Date/Time | No new Date() or SimpleDateFormat; DateTimeFormatter is static final |
| CompletableFuture | Custom pool for I/O; error handling on every chain; no blocking get() in callbacks |
| Metaspace | -XX:MaxMetaspaceSize set to prevent unbounded native memory growth |
| Base64 | Using java.util.Base64; not sun.misc.BASE64Encoder |
| Logging | Supplier-based logging for lazy evaluation of expensive log arguments |
Summary
Java 8 introduced a new programming model for Java. The most important practices:
- Streams are for data pipelines — transformation, filtering, aggregation. Use them for that; use loops for imperative logic.
- Optional belongs in return types at API boundaries, not in fields or parameters.
- Lambdas should be short. If a lambda is longer than 3 lines, extract it to a named method.
- CompletableFuture needs a custom I/O pool and explicit error handling — never assume it’s fire-and-forget.
- Date/Time — always
java.time, neverjava.util.Date.Instantfor storage,ZonedDateTimefor display. - Metaspace — set
-XX:MaxMetaspaceSizein production to prevent unbounded native memory growth.
This is the final article in the Java 8 Tutorial series. Continue your Java journey:
- Java 11 Tutorial — Module System, HTTP Client API, TLS 1.3, ZGC
- Java 17 Tutorial — Sealed Classes, Pattern Matching, Records
- Java 21 Tutorial — Virtual Threads, Structured Concurrency, Pattern Matching for switch
Part of the DevOps Monk Java tutorial series: Java 8 → Java 11 → Java 17 → Java 21