Java 21 — Interview Questions

Stack context: This system uses Java 21 as the runtime for all six Spring Boot services. Java 21 is an LTS release introducing virtual threads (Project Loom), records, sealed classes, pattern matching, and text blocks — all relevant to writing clean, performant microservice code.


Q1 — What are the most important new features in Java 21? junior

Answer: Java 21 is an LTS (Long-Term Support) release with several production-ready features:

Language features:

Platform features:


Q2 — What are records in Java and when should you use them? junior

Answer: Records are transparent, immutable data carriers. The compiler auto-generates constructor, accessors, equals(), hashCode(), and toString().

// Record declaration
public record ProductDto(UUID id, String name, BigDecimal price, int stock) {}

// Usage
ProductDto product = new ProductDto(UUID.randomUUID(), "Laptop", new BigDecimal("999.99"), 50);
System.out.println(product.name()); // accessor (not getName())
System.out.println(product);        // auto toString: ProductDto[id=..., name=Laptop, ...]

Compact constructor (validation):

public record OrderRequest(UUID productId, int quantity) {
    public OrderRequest {   // compact constructor (no parens with params)
        if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
        Objects.requireNonNull(productId);
    }
}

Use when: DTOs, event objects, value objects, method return groupings. Don't use when: Mutable state is needed, JPA entities (need no-arg constructor), inheritance required.

This system: OrderCreatedEvent, PaymentProcessedEvent are candidates for records as they are immutable event payloads.


Q3 — What are sealed classes and when are they useful? junior

Answer: Sealed classes restrict which classes can extend or implement them, creating closed type hierarchies that the compiler can reason about exhaustively.

// Sealed hierarchy — only these three can implement PaymentResult
public sealed interface PaymentResult permits PaymentSuccess, PaymentFailure, PaymentPending {}

public record PaymentSuccess(String transactionId, BigDecimal amount) implements PaymentResult {}
public record PaymentFailure(String reason, int retryCount) implements PaymentResult {}
public record PaymentPending(Instant estimatedCompletion) implements PaymentResult {}

Exhaustive switch (compiler checks all cases covered):

String message = switch (result) {
    case PaymentSuccess s -> "Paid: " + s.transactionId();
    case PaymentFailure f -> "Failed: " + f.reason();
    case PaymentPending p -> "Pending until: " + p.estimatedCompletion();
    // No default needed — compiler knows all subtypes
};

Benefits: Replaces fragile if/instanceof chains, prevents unintended subclassing, enables exhaustive switch analysis.


Q4 — What is pattern matching for switch in Java 21? junior

Answer: Java 21 finalizes pattern matching in switch expressions, allowing matching on types, guarded patterns, and null handling.

// Old way (verbose, unsafe)
if (obj instanceof String s) {
    System.out.println(s.length());
} else if (obj instanceof Integer i) {
    System.out.println(i * 2);
}

// Java 21 pattern matching switch
String result = switch (obj) {
    case String s when s.length() > 10 -> "Long string: " + s;
    case String s -> "Short string: " + s;
    case Integer i -> "Integer: " + i;
    case null -> "null";
    default -> "Unknown: " + obj.getClass();
};

Guarded patterns (when clause): Refine a type match with an additional condition.

Null handling: Switch statements previously threw NullPointerException on null input. Java 21 switch allows explicit case null.

Real use case in this system:

String statusMessage = switch (order.getStatus()) {
    case PENDING -> "Awaiting payment";
    case CONFIRMED -> "Payment confirmed, preparing shipment";
    case FAILED -> "Payment failed. Please retry.";
};

Q5 — What are virtual threads in Java 21 and how do they improve concurrency? junior

Answer: Virtual threads (Project Loom) are lightweight threads managed by the JVM, not the OS. Unlike platform threads (which map 1:1 to OS threads and use ~1 MB stack), virtual threads use ~few KB of heap and the JVM multiplexes many virtual threads onto a small pool of carrier (platform) threads.

Key properties:

// Old: thread pool with ~200 threads
ExecutorService exec = Executors.newFixedThreadPool(200);

// Java 21: virtual thread executor — creates a new virtual thread per task
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();

// Spring Boot 3.2+ enable for all Tomcat request threads
// application.yml:
// spring.threads.virtual.enabled: true

Impact on this system: With spring.threads.virtual.enabled=true, each HTTP request in MVC services gets a virtual thread. Even if the request blocks on Redis or DB, the carrier thread is freed — enabling much higher concurrency without increasing thread count.


Q6 — What are text blocks and when are they useful? junior

Answer: Text blocks are multi-line string literals that preserve formatting and eliminate most escape sequences. They use triple-quote delimiters.

// Old way — escape-heavy JSON
String json = "{\"orderId\":\"123\",\"status\":\"CONFIRMED\"}";

// Text block — readable
String json = """
        {
            "orderId": "123",
            "status": "CONFIRMED"
        }
        """;

// SQL in text block
String sql = """
        SELECT o.id, o.status, u.email
        FROM orders o
        JOIN users u ON o.customer_id = u.id
        WHERE o.created_at > :since
        ORDER BY o.created_at DESC
        """;

// HTML email template
String html = """
        <html>
          <body>
            <h1>Order %s Confirmed</h1>
          </body>
        </html>
        """.formatted(orderId);

Indentation: The text block's indentation is determined by the position of the closing """. Leading whitespace up to that column is stripped.


Q7 — What is the difference between Optional and null in Java? junior

Answer: Optional<T> is a container that may or may not contain a non-null value. It forces callers to explicitly handle the absent case, making null intent explicit in the API.

// Method that might return nothing
public Optional<Product> findById(UUID id) {
    return productRepository.findById(id); // Spring Data returns Optional
}

// Caller must handle absence
productService.findById(id)
    .map(ProductDto::from)              // transform if present
    .orElseThrow(() -> new NotFoundException("Product " + id));  // or throw

// Don't use Optional as a parameter or field — only as return type

Rules:


Q8 — What is var type inference and what are its limitations? junior

Answer: var (introduced in Java 10) allows the compiler to infer the type from the right-hand side. It's a compile-time feature — the type is still statically determined.

// var infers Map<String, List<OrderDto>>
var ordersByCustomer = new HashMap<String, List<OrderDto>>();

// var in for-each
for (var entry : ordersByCustomer.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue().size());
}

// var reduces verbosity with long generics
var productService = applicationContext.getBean(ProductService.class);

Limitations:

Best practice: Use var where the type is obvious from the literal or constructor call, not when the type requires context to understand.


Q9 — What is the difference between Comparable and Comparator in Java? junior

Answer:

Comparable<T> Comparator<T>
Implemented by The class itself External / lambda
Defines Natural ordering Custom/multiple orderings
Method compareTo(T other) compare(T a, T b)
Modify existing class Yes (must own the class) No — works on any class
// Comparable — Order has a natural ordering by createdAt
public class Order implements Comparable<Order> {
    @Override
    public int compareTo(Order other) {
        return this.createdAt.compareTo(other.createdAt);
    }
}

// Comparator — multiple orderings without modifying Order
Comparator<Order> byAmount = Comparator.comparing(Order::getTotalAmount).reversed();
Comparator<Order> byCustomerThenDate = Comparator
    .comparing(Order::getCustomerId)
    .thenComparing(Order::getCreatedAt);

orders.sort(byCustomerThenDate);

Q10 — What are functional interfaces and how are lambdas used in Java? junior

Answer: A functional interface has exactly one abstract method. Lambdas provide a concise syntax to implement functional interfaces.

Built-in functional interfaces (java.util.function):

Interface Method Use
Function<T, R> R apply(T t) Transform T to R
Predicate<T> boolean test(T t) Filter/condition
Consumer<T> void accept(T t) Side effects
Supplier<T> T get() Lazy value provider
BiFunction<T, U, R> R apply(T t, U u) Two-arg transform
// Stream pipeline using lambdas
List<OrderDto> pendingOrders = orders.stream()
    .filter(o -> o.getStatus() == PENDING)          // Predicate
    .map(o -> new OrderDto(o.getId(), o.getTotal())) // Function
    .sorted(Comparator.comparing(OrderDto::getTotal).reversed())
    .collect(Collectors.toList());

Method references (shorthand for lambdas):

orders.stream().map(Order::getId)           // instance method
orders.stream().map(UUID::toString)         // static method
orders.stream().filter(Objects::nonNull)    // null check

Q11 — What are Java Streams and how do they differ from collections? junior

Answer: Streams are a declarative pipeline for processing sequences of elements. Unlike collections, streams:

// Imperative (collection-based)
List<String> result = new ArrayList<>();
for (Order o : orders) {
    if (o.getStatus() == CONFIRMED) {
        result.add(o.getId().toString());
    }
}

// Declarative (stream)
List<String> result = orders.stream()
    .filter(o -> o.getStatus() == CONFIRMED)
    .map(o -> o.getId().toString())
    .collect(Collectors.toList());

Intermediate operations (lazy): filter, map, flatMap, distinct, sorted, limit, skip. Terminal operations (trigger execution): collect, forEach, count, findFirst, anyMatch, reduce.


Q12 — What is the CompletableFuture API and how does it enable async programming? senior

Answer: CompletableFuture<T> represents an async computation that can be composed, chained, and combined without blocking.

// Async user lookup + product lookup in parallel, then combine
CompletableFuture<UserDto> userFuture = CompletableFuture.supplyAsync(
    () -> userService.getUser(userId));

CompletableFuture<List<ProductDto>> productsFuture = CompletableFuture.supplyAsync(
    () -> productService.getProducts(productIds));

CompletableFuture<OrderSummary> summary = userFuture
    .thenCombine(productsFuture, (user, products) ->
        new OrderSummary(user, products, calculateTotal(products)))
    .thenApply(this::enrichWithShippingInfo)
    .exceptionally(ex -> {
        log.error("Failed to build order summary", ex);
        return OrderSummary.empty();
    });

// Non-blocking: don't call .get() on the event loop!

Key methods:

vs. Virtual Threads: Virtual threads make blocking code scale like async code. Prefer virtual threads + blocking code for readability; use CompletableFuture when you genuinely need parallel fan-out.


Q13 — What is the memory model in Java and what does volatile mean? senior

Answer: The Java Memory Model (JMM) defines how threads share memory. Without synchronization, threads may work on local CPU cache copies of variables, leading to visibility and ordering issues.

volatile: Ensures:

  1. Visibility: Every read of a volatile variable sees the latest write by any thread (no caching).
  2. Ordering: Reads and writes to volatile cannot be reordered relative to other operations.
// Without volatile: thread B may never see thread A's write
private boolean running = true;

// With volatile: safe flag for single-writer, multiple-reader
private volatile boolean running = true;

// Thread A
running = false;

// Thread B
while (running) { doWork(); }  // reliably sees running = false

volatile limitations: Only safe for single-read/single-write operations. For compound operations (check-then-act, increment), use AtomicInteger, synchronized, or java.util.concurrent locks.

This system: OutboxPublisher.running flag for graceful shutdown could use volatile or AtomicBoolean.


Q14 — What is the difference between synchronized, ReentrantLock, and StampedLock? senior

Answer:

synchronized ReentrantLock StampedLock
Try-lock No Yes (tryLock()) Yes
Interruptible No Yes (lockInterruptibly()) Yes
Fairness No Optional No
Read-write No ReadWriteLock companion Built-in optimistic read
Condition wait/notify Condition API

StampedLock optimistic read (Java 8+):

StampedLock lock = new StampedLock();

// Optimistic read (no lock acquired — just a stamp)
long stamp = lock.tryOptimisticRead();
double x = this.x;
double y = this.y;
if (!lock.validate(stamp)) {
    // Data changed — fall back to read lock
    stamp = lock.readLock();
    try { x = this.x; y = this.y; }
    finally { lock.unlockRead(stamp); }
}

With Virtual Threads: synchronized can pin a virtual thread to its carrier thread. Prefer ReentrantLock in virtual-thread code to avoid carrier thread starvation.


Q15 — What is a ThreadLocal and what are the risks with virtual threads? senior

Answer: ThreadLocal<T> stores a value per thread. Used for holding per-request context (user ID, trace ID, transaction) without passing it through method parameters.

// Old pattern: store user context in ThreadLocal
public class SecurityContext {
    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

    public static void set(UserContext ctx) { CONTEXT.set(ctx); }
    public static UserContext get() { return CONTEXT.get(); }
    public static void clear() { CONTEXT.remove(); } // CRITICAL: always clear!
}

Risks with virtual threads:

  1. Memory leaks: Virtual threads are numerous and short-lived. If ThreadLocal is not cleared, values linger until GC.
  2. Excessive memory: 1 million virtual threads × 1 ThreadLocal value = 1 million copies.
  3. InheritableThreadLocal: Child virtual threads inherit parent's ThreadLocal — can cause unexpected value leakage.

Java 21 alternative: Scoped Values (ScopedValue) — immutable, inherited, and automatically cleaned up when scope exits. Preferred over ThreadLocal for virtual thread compatibility.

static final ScopedValue<UserContext> USER_CTX = ScopedValue.newInstance();

ScopedValue.where(USER_CTX, ctx).run(() -> {
    // USER_CTX.get() returns ctx within this scope only
    service.processRequest();
});

Q16 — How does the Java garbage collector work and what are the main GC algorithms? senior

Answer: The JVM automatically manages memory via garbage collection. The GC identifies objects not reachable from any GC root (stack variables, static fields) and reclaims their memory.

Heap regions:

Modern GC algorithms:

GC Latency Throughput Heap size Use case
G1 (default, Java 9+) Low-medium High Large Most apps
ZGC (Java 15+) Ultra-low (< 1 ms) Lower Very large (TB) Latency-critical
Shenandoah Ultra-low Lower Any Latency-critical, OpenJDK
Serial Poor Medium Small Embedded

For microservices (this system): G1GC is the default — good balance. For gateway/latency-sensitive services, consider ZGC: -XX:+UseZGC.

Tuning: -Xms512m -Xmx512m (fixed heap reduces GC overhead), -XX:MaxGCPauseMillis=50 (G1 target pause).


Q17 — What are generics and what is type erasure? senior

Answer: Generics add compile-time type safety without runtime overhead. The compiler enforces type constraints; at runtime, generic type parameters are erased (replaced with Object or bounds).

// Generic repository interface
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    T save(T entity);
    List<T> findAll();
}

// Wildcard — accepts Repository of any type
void process(Repository<?, ?> repo) { ... }

// Bounded wildcard — accepts Repository of Number or subtype
void sum(List<? extends Number> numbers) { ... }

Type erasure implications:

Practical: Spring's ParameterizedTypeReference works around erasure for HTTP responses:

ResponseEntity<List<OrderDto>> response = restTemplate.exchange(
    url, GET, null, new ParameterizedTypeReference<List<OrderDto>>() {});

Q18 — What is the equals/hashCode contract and why is it important for collections? senior

Answer: The contract: if a.equals(b) is true, then a.hashCode() == b.hashCode() must be true. The reverse is not required (hash collisions are allowed).

Why it matters:

// Correct record implementation — auto-generates correct equals/hashCode
public record ProductId(UUID value) {}

// Manual implementation (if using class)
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Product p)) return false;
    return Objects.equals(id, p.id);
}

@Override
public int hashCode() {
    return Objects.hash(id);
}

JPA note: JPA entities should implement equals/hashCode based on the business key or database ID — NOT auto-generated @Id values (which are null before persist).


Q19 — What is try-with-resources and how does it improve resource management? junior

Answer: Try-with-resources (Java 7+) automatically closes resources implementing AutoCloseable when the try block exits, whether normally or via exception.

// Old way — verbose, error-prone
BufferedReader reader = null;
try {
    reader = new BufferedReader(new FileReader(path));
    return reader.readLine();
} finally {
    if (reader != null) {
        try { reader.close(); } catch (IOException ignored) {}
    }
}

// Try-with-resources — clean and safe
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
    return reader.readLine();
}  // reader.close() called automatically

// Multiple resources (closed in reverse order)
try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    // ...
}

Suppressed exceptions: If both the try body and close() throw, the close() exception is added as a suppressed exception to the primary exception — not silently swallowed.


Q20 — What is a WeakReference and when is it used? senior

Answer: Java has four reference strengths: strong, soft, weak, and phantom. WeakReference<T> allows the GC to collect the referenced object when no strong references exist.

// Strong reference — prevents GC
Product product = new Product();

// Weak reference — GC can collect product even though weakRef exists
WeakReference<Product> weakRef = new WeakReference<>(product);
product = null;  // now only weakRef holds a reference

// After GC: weakRef.get() may return null
Product p = weakRef.get();
if (p != null) {
    // still alive
}

WeakHashMap: Keys are weak references. When a key is GC'd, the entry is automatically removed. Used for metadata/attribute maps where the key's lifetime defines the entry's lifetime.

SoftReference: Like weak, but GC only clears it under memory pressure. Used for memory-sensitive caches (Caffeine uses soft references internally as an option).

This system: Caffeine cache uses strong references with size-based eviction. WeakReference would be used for per-request metadata attached to connection objects.


Q21 — How does String.intern() work and what are its performance implications? senior

Answer: String.intern() returns a canonical representation from the JVM's string pool. Two interned strings that are equal (equals()) return the same object reference, enabling == comparison.

String a = new String("order");
String b = new String("order");
a == b;          // false — different heap objects
a.equals(b);     // true

String c = a.intern();
String d = b.intern();
c == d;          // true — same pool entry

Performance implications:


Q22 — What are the key differences between HashMap and ConcurrentHashMap? senior

Answer:

HashMap ConcurrentHashMap
Thread safety Not safe Safe — lock striping
Null keys/values Allows one null key Does NOT allow null key or value
Performance Fastest single-threaded Comparable single-threaded, better multi-threaded
Java 8 structure Array of linked lists/trees Segment-based to CAS-based

ConcurrentHashMap concurrency:

// Safe count per status
ConcurrentHashMap<OrderStatus, Long> counts = new ConcurrentHashMap<>();
orders.forEach(o -> counts.merge(o.getStatus(), 1L, Long::sum));

// Idiomatic cache pattern
Map<UUID, Product> cache = new ConcurrentHashMap<>();
Product p = cache.computeIfAbsent(id, k -> productRepo.findById(k).orElseThrow());

Java 21 alternative: HashMap + Virtual Threads is fine if you use synchronized blocks. ConcurrentHashMap remains the default for shared mutable maps.


Q23 — How do you read and write files efficiently in Java? junior

Answer: Reading entire file (small files):

// Java 11+
String content = Files.readString(Path.of("config.json"));
List<String> lines = Files.readAllLines(Path.of("data.csv"));

Reading large files (streaming):

// Process line-by-line without loading all into memory
try (Stream<String> lines = Files.lines(Path.of("large-file.txt"))) {
    lines.filter(l -> l.startsWith("ERROR"))
         .forEach(System.out::println);
}

Writing files:

// Write string (overwrites)
Files.writeString(Path.of("output.txt"), content);

// Append
Files.writeString(Path.of("log.txt"), entry, StandardOpenOption.APPEND, StandardOpenOption.CREATE);

Buffered I/O for performance (large binary files):

try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(dest))) {
    byte[] buffer = new byte[8192];
    int n;
    while ((n = bis.read(buffer)) != -1) bos.write(buffer, 0, n);
}

Q24 — What is instanceof pattern matching and how does it reduce boilerplate? junior

Answer: Pattern matching for instanceof (Java 16+) combines the type check and cast into a single operation, eliminating the redundant explicit cast.

// Old way — redundant cast
if (event instanceof OrderCreatedEvent) {
    OrderCreatedEvent orderEvent = (OrderCreatedEvent) event;
    processOrder(orderEvent.getOrderId());
}

// Java 16+ pattern matching
if (event instanceof OrderCreatedEvent orderEvent) {
    processOrder(orderEvent.getOrderId());
}

// Combined with logical operators
if (event instanceof OrderCreatedEvent e && e.getStatus() == PENDING) {
    processOrder(e.getOrderId());
}

In switch (Java 21):

void handleEvent(Object event) {
    switch (event) {
        case OrderCreatedEvent e -> processOrder(e);
        case PaymentProcessedEvent e -> updateOrderStatus(e);
        case null -> log.warn("Null event received");
        default -> log.warn("Unknown event type: {}", event.getClass());
    }
}

Q25 — What are the improvements in java.time API over Date and Calendar? junior

Answer: The java.time API (Java 8+) replaced the legacy Date/Calendar which were mutable, thread-unsafe, and poorly designed.

Key types:

Type Description Example
LocalDate Date without time/timezone 2026-05-31
LocalTime Time without date/timezone 14:30:00
LocalDateTime Date + time, no timezone 2026-05-31T14:30:00
ZonedDateTime Date + time + timezone 2026-05-31T14:30:00+07:00[Asia/Bangkok]
Instant Timestamp in UTC epoch Best for event timestamps
Duration Amount of time (hours, minutes) Duration.ofSeconds(30)
Period Amount of date (years, months, days) Period.ofDays(7)

This system: Order.createdAt uses Instant (UTC timestamp in DB). TTL calculations use Duration.

// Order expiry: 24 hours after creation
Instant expiry = order.getCreatedAt().plus(Duration.ofHours(24));
boolean isExpired = Instant.now().isAfter(expiry);

Q26 — What are Collectors in the Stream API and what are the most useful ones? junior

Answer: Collectors are terminal stream operations that accumulate stream elements into a result container.

List<Order> orders = getOrders();

// Group by status
Map<OrderStatus, List<Order>> byStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::getStatus));

// Count per status
Map<OrderStatus, Long> countByStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::getStatus, Collectors.counting()));

// Sum order totals
BigDecimal totalRevenue = orders.stream()
    .map(Order::getTotal)
    .collect(Collectors.reducing(BigDecimal.ZERO, BigDecimal::add));

// Partition into confirmed vs not
Map<Boolean, List<Order>> partition = orders.stream()
    .collect(Collectors.partitioningBy(o -> o.getStatus() == CONFIRMED));

// Join strings
String orderIds = orders.stream()
    .map(o -> o.getId().toString())
    .collect(Collectors.joining(", ", "[", "]"));

// Collect to unmodifiable list (Java 10+)
List<OrderDto> dtos = orders.stream().map(this::toDto)
    .collect(Collectors.toUnmodifiableList());

Q27 — What is Spliterator and how does parallel stream work? senior

Answer: A Spliterator (splittable iterator) is used by the Stream API to partition a data source for parallel processing.

Parallel stream:

// Uses ForkJoinPool.commonPool() by default
long confirmedCount = orders.parallelStream()
    .filter(o -> o.getStatus() == CONFIRMED)
    .count();

When parallel streams help:

When parallel streams hurt:

Custom thread pool (avoid monopolizing common pool):

ForkJoinPool customPool = new ForkJoinPool(4);
customPool.submit(() ->
    orders.parallelStream().filter(...).collect(...)
).get();

Q28 — How does Java handle exceptions and what is the difference between checked and unchecked? junior

Answer:

Checked Unchecked
Base class Exception RuntimeException (subclass of Exception)
Handling Compiler requires try-catch or throws Optional
Examples IOException, SQLException NullPointerException, IllegalArgumentException
Use when Recoverable, expected failure Programming error, unrecoverable
// Checked — must handle
public String readFile(Path path) throws IOException {
    return Files.readString(path);  // must declare or catch IOException
}

// Unchecked — optional handling
public Order findOrder(UUID id) {
    return orderRepo.findById(id)
        .orElseThrow(() -> new OrderNotFoundException(id)); // RuntimeException
}

Modern practice: Prefer unchecked exceptions for domain errors. Checked exceptions add noise and often lead to catch (Exception e) { throw new RuntimeException(e); } anti-patterns.

Spring: DataAccessException hierarchy is all unchecked — wraps checked JDBC exceptions transparently.


Q29 — What is the @FunctionalInterface annotation and why is it important? junior

Answer: @FunctionalInterface marks an interface as intended for lambda use. The compiler enforces exactly one abstract method, preventing accidental addition of a second abstract method that would break all existing lambda implementations.

@FunctionalInterface
public interface OrderValidator {
    ValidationResult validate(Order order);   // single abstract method

    // Default methods are fine
    default boolean isValid(Order order) {
        return validate(order).isOk();
    }

    // Static helper methods are fine
    static OrderValidator noOp() {
        return order -> ValidationResult.ok();
    }
}

// Usage
OrderValidator priceCheck = order -> order.getTotal().compareTo(BigDecimal.ZERO) > 0
    ? ValidationResult.ok()
    : ValidationResult.error("Price must be positive");

OrderValidator combined = priceCheck
    .andThen(stockCheck)    // compose validators
    .andThen(addressCheck);

Q30 — What are the performance benefits of using records over classes for DTOs in microservices? senior

Answer: Records provide several performance and design benefits for DTO/event objects:

Compile-time savings:

Memory:

JSON serialization (Jackson 2.12+):

// Jackson handles records natively — no extra annotations
public record ProductDto(UUID id, String name, BigDecimal price) {}

// Works out of the box
objectMapper.readValue(json, ProductDto.class);
objectMapper.writeValueAsString(new ProductDto(...));

Kafka Avro: Records can implement Avro-generated interfaces if the schema fields match, simplifying event type usage.

Practical impact: Replacing 50-line POJOs + Lombok with 1-line records reduces codebase size, eliminates Lombok dependency issues, and leverages compiler-guaranteed correctness.

This system: Avro generated classes are not records (generated code), but application-layer DTOs (CreateOrderRequest, OrderDto, ProductDto) are ideal record candidates.