Java Foundations
for Spring Boot
Before you touch a single Spring annotation, you need to understand the language underneath. Most Spring Boot problems are really Java problems wearing a Spring costume.
- Object-Oriented Programming — Deep Dive
- JVM Architecture & Internals
- Memory Management — Heap & Stack
- Garbage Collection
- Multithreading
- Concurrency & Thread Safety
- Collections Framework
- Streams API & Functional Programming
- Exception Handling
- Generics & Type Safety
- Reflection — How Spring Uses It
- Lambdas & Functional Interfaces
- CompletableFuture & Async Java
- Java Records & Modern Java
- HTTP Clients in Java
Object-Oriented Programming — Deep Dive
Every Spring application is built on Java's OOP model. If you don't understand polymorphism deeply, you'll be confused by Spring's proxy mechanisms. If you don't understand interfaces, dependency injection won't click. OOP is not just syntax — it's a design philosophy.
Spring's entire dependency injection system is built on interfaces and polymorphism. When you @Autowire a UserRepository, Spring injects a proxy object that implements that interface. Without deep OOP understanding, this feels like magic.
The Four Pillars — With Real Consequences
You've heard of the four pillars. But let's talk about what breaks when you misuse them in production systems.
Encapsulation is not just making fields private. It's about hiding implementation details so your internal state can't be corrupted by external code.
// ❌ BAD: No encapsulation — balance can be set to anything
public class BankAccount {
public double balance; // Anyone can do account.balance = -99999
}
// ✅ GOOD: Encapsulated — business rules enforced
public class BankAccount {
private final String accountId;
private double balance;
private final List<String> transactions = new ArrayList<>();
public BankAccount(String accountId, double initialBalance) {
if (initialBalance < 0) throw new IllegalArgumentException("Initial balance cannot be negative");
this.accountId = accountId;
this.balance = initialBalance;
}
// Business rule enforced INSIDE the class
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
this.balance += amount;
transactions.add("DEPOSIT: +" + amount);
}
public void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
if (amount > balance) throw new InsufficientFundsException("Not enough balance");
this.balance -= amount;
transactions.add("WITHDRAWAL: -" + amount);
}
// Read-only view — defensive copy
public double getBalance() { return balance; }
public List<String> getTransactionHistory() {
return Collections.unmodifiableList(transactions);
}
}
A very common Spring Boot bug: JPA entities with public setters allow invalid state to be persisted. Always enforce business rules inside the entity, not in the controller or service layer.
Inheritance is powerful but dangerously overused. The rule of thumb in modern backend engineering: prefer composition over inheritance.
// ❌ FRAGILE: Deep inheritance hierarchies break at scale
class Animal { void breathe() {...} }
class Mammal extends Animal { void regulateTemp() {...} }
class Dog extends Mammal { void bark() {...} }
class ServiceDog extends Dog { void guide() {...} }
// Any change to Animal breaks ALL subclasses
// ✅ BETTER: Composition with interfaces
interface Breathable { void breathe(); }
interface Trainable { void train(String command); }
class Dog implements Breathable {
private final TrainingBehavior training; // composed in
public Dog(TrainingBehavior training) {
this.training = training;
}
@Override
public void breathe() { /* ... */ }
public void train(String cmd) { training.execute(cmd); }
}
// Spring LOVES this pattern — @Service beans are composed,
// not inherited. That's why DI works so cleanly.
Spring uses this principle everywhere. Your @Service beans don't extend framework classes — they implement interfaces. This is why Spring can swap implementations and create proxies seamlessly.
Polymorphism is the engine behind Spring's dependency injection. When Spring injects a bean, it doesn't care about the concrete class — only the interface.
// The interface — the contract
public interface NotificationService {
void send(String userId, String message);
}
// Multiple implementations — runtime polymorphism
@Service("email")
public class EmailNotificationService implements NotificationService {
@Override
public void send(String userId, String message) {
// Send email via SMTP
}
}
@Service("sms")
public class SmsNotificationService implements NotificationService {
@Override
public void send(String userId, String message) {
// Send SMS via Twilio
}
}
// The consumer doesn't know which implementation it gets
@Service
public class OrderService {
private final NotificationService notifier; // Polymorphic!
public OrderService(@Qualifier("email") NotificationService notifier) {
this.notifier = notifier;
}
public void processOrder(Order order) {
// ... business logic ...
notifier.send(order.getUserId(), "Your order is confirmed!"); // calls the right impl
}
}
// Spring resolves the concrete implementation at startup.
// Switch from email to SMS: change ONE annotation. Zero other code changes.
Abstraction is about creating useful boundaries. In Spring Boot, every layer is an abstraction: the controller abstracts HTTP, the service abstracts business logic, the repository abstracts data access.
// Controller abstracts HTTP — knows nothing about database
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService; // Only knows the service abstraction
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
}
// Service abstracts business logic — knows nothing about HTTP or SQL
@Service
public class UserService {
private final UserRepository userRepository; // Only knows the repository abstraction
public UserDto findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return UserMapper.toDto(user); // Maps to safe DTO
}
}
// Repository abstracts SQL — JPA generates the queries
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // Spring generates SQL
}
JVM Architecture & Internals
When you run a Spring Boot application, you're not running it on your OS. You're running it inside the Java Virtual Machine — a complete virtual computer with its own memory model, threading model, and instruction set. Understanding the JVM separates engineers who tune applications from engineers who guess at problems.
"Write once, run anywhere" isn't magic. It's the JVM converting bytecode to platform-specific machine code at runtime. That compilation — the JIT — is why Java is fast.
The JVM Execution Pipeline
When you run java -jar myapp.jar, here's what actually happens:
# Your .java file is compiled to bytecode
javac UserService.java → UserService.class (bytecode, platform-neutral)
# JVM loads the class
ClassLoader → reads .class → loads into Method Area
# JIT compiles HOT code to native machine code
Interpreter → runs bytecode slowly at first
JIT Compiler → identifies "hot" methods (called 10,000+ times)
→ compiles to native CPU instructions
→ now runs at near-native speed
# That's why Spring Boot is slow to start but fast in production!
Allocate objects, run garbage collection, and push/pop stack frames. Watch how memory actually works inside the JVM.
Why This Matters for Spring Boot
Spring Boot creates thousands of objects at startup (beans, proxies, configurations). If your heap is too small, GC runs constantly and your app gets slow. If your Metaspace is too small, you get OutOfMemoryError: Metaspace. These are real production incidents — and they start with understanding JVM memory.
Memory Management — Heap vs Stack
Java manages two fundamentally different types of memory. Confusing them leads to memory leaks, StackOverflowError, and OutOfMemoryError — three of the most common production outages in Java applications.
| Property | Stack | Heap |
|---|---|---|
| What lives here | Method calls, local variables, primitive values, references | All objects — new Object(), arrays, strings (interned) |
| Per-thread? | ✅ Yes — each thread has its own stack | ❌ No — shared across ALL threads |
| Size | Fixed (default ~512KB–1MB per thread) | Configurable (-Xmx flag) |
| Managed by | JVM automatically (LIFO push/pop) | Garbage Collector (GC) |
| What goes wrong | StackOverflowError (infinite recursion) | OutOfMemoryError: Java heap space |
| Thread safety | ✅ Inherently thread-safe | ❌ Requires synchronization |
| Speed | ⚡ Very fast (pointer arithmetic) | 🐢 Slower (GC overhead) |
public class MemoryDemo {
// Static fields → Method Area (Metaspace in JDK8+)
private static final String APP_NAME = "MyApp";
public void processOrder(int orderId) {
// orderId → Stack (primitive, per-frame local variable)
// orderRef → Stack (reference/pointer variable)
// new Order(...) → Heap (actual object)
Order order = new Order(orderId);
// name → Stack (reference)
// "Baljeet" → Heap (String object, possibly interned)
String name = order.getCustomerName();
calculateTax(order); // New frame pushed onto Stack
} // ← Frame popped from Stack. order ref gone. Object eligible for GC.
private double calculateTax(Order order) {
double rate = 0.18; // Stack — primitive double
return order.getAmount() * rate;
} // ← Frame popped. rate gone from Stack.
}
// COMMON MISTAKE: Holding references prevents GC
public class LeakyCache {
// This Map holds STRONG references — objects can never be GC'd!
private static final Map<String, byte[]> cache = new HashMap<>();
public void addToCache(String key) {
// Each call adds 1MB to heap. GC CANNOT reclaim this.
cache.put(key, new byte[1024 * 1024]);
}
// Fix: Use WeakHashMap or Caffeine cache with eviction policy
}
Garbage Collection
Garbage Collection is one of Java's greatest engineering achievements — and one of the biggest sources of production pain when misunderstood. GC is what frees you from manual memory management, but it also causes the infamous "GC pauses" that tank your application's latency.
Generational Hypothesis
The entire GC design is based on a single observed fact: most objects die young. In a typical Spring Boot application, 90% of objects created during a request are garbage by the time the response is sent. GC exploits this.
java -jar myapp.jar \
-Xms512m \ # Initial heap — set equal to Xmx to avoid resize pauses
-Xmx2g \ # Max heap — don't exceed 70-75% of available RAM
-XX:+UseG1GC \ # G1GC: best balance for most Spring Boot apps
-XX:MaxGCPauseMillis=200 \ # Target: keep GC pauses under 200ms
-XX:+PrintGCDetails \ # Log GC events (diagnose problems)
-XX:+HeapDumpOnOutOfMemoryError \ # Dump heap on OOM for analysis
-XX:HeapDumpPath=/var/logs/heapdump.hprof \
-XX:MetaspaceSize=128m \ # Set initial Metaspace to avoid early GC
-XX:MaxMetaspaceSize=512m # Cap Metaspace to prevent runaway growth
# For low-latency services (APIs, real-time):
# Use ZGC (JDK15+) — sub-millisecond pauses even with large heaps
java -jar myapp.jar -XX:+UseZGC -Xmx16g
Multithreading
Spring Boot handles every HTTP request in a separate thread. Your application is always multi-threaded. If you don't understand threading, you will write code that works in development and silently corrupts data in production.
Thread Lifecycle
A Java thread can be in exactly one of six states at any point. Understanding these states is essential for debugging concurrency issues — and for reading thread dumps from production incidents.
Create threads, transition them between states, and simulate real concurrency problems like deadlocks and race conditions.
Creating Threads — Three Ways
// Way 1: Extending Thread (avoid in modern code)
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running in: " + Thread.currentThread().getName());
}
}
new MyThread().start();
// Way 2: Implementing Runnable (better — separates task from executor)
Runnable task = () -> System.out.println("Lambda Runnable");
new Thread(task).start();
// Way 3: ExecutorService (production standard — reuses threads)
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> processOrder(orderId));
executor.shutdown(); // Always shut down!
// Way 4: Spring's @Async (recommended in Spring Boot apps)
@Service
public class EmailService {
@Async // Spring runs this in a separate thread automatically
public CompletableFuture<Void> sendEmailAsync(String to, String body) {
// Runs in Spring's task executor thread pool
sendEmail(to, body);
return CompletableFuture.completedFuture(null);
}
}
// Configure the thread pool for @Async
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-exec-");
executor.initialize();
return executor;
}
}
Concurrency & Thread Safety
This is where most backend engineers have the deepest knowledge gaps. Concurrency bugs are the hardest bugs to reproduce, debug, and fix. They often only appear under load — when multiple threads hit the same code path simultaneously.
Spring beans are singletons by default. A single bean instance is shared across ALL threads. If your bean has mutable instance fields, you have a race condition waiting to happen in production.
// ❌ DANGEROUS: Singleton bean with mutable state
@Service // This bean is shared across ALL threads!
public class OrderCounter {
private int count = 0; // MUTABLE INSTANCE FIELD — race condition!
public void increment() {
count++; // NOT ATOMIC! count = count + 1 is THREE operations:
// 1. Read count
// 2. Add 1
// 3. Write back
// Two threads can read the same value and both write back,
// losing an increment. At 1000 req/sec, you lose many counts.
}
public int getCount() { return count; }
}
// ✅ FIX 1: Use AtomicInteger — lock-free thread safety
@Service
public class OrderCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Atomic — guaranteed correct
}
public int getCount() { return count.get(); }
}
// ✅ FIX 2: Use synchronized (heavier, but sometimes needed)
@Service
public class OrderCounter {
private int count = 0;
public synchronized void increment() { count++; } // Only one thread at a time
public synchronized int getCount() { return count; }
}
// ✅ FIX 3: Stateless design (best for Spring services!)
@Service
public class OrderService {
// NO instance fields that change — stateless = thread-safe by design
private final OrderRepository orderRepo; // final, injected, immutable ref
public OrderDto processOrder(OrderRequest request) {
// All state is in local variables (stack) — thread-safe!
Order order = new Order(request);
return orderRepo.save(order);
}
}
The Happens-Before Relationship
The Java Memory Model (JMM) guarantees visibility of writes between threads only under specific conditions. Without these guarantees, one thread may read stale data written by another thread — a visibility bug.
// ❌ VISIBILITY BUG: Thread may NEVER see the update
class Runner {
private boolean running = true; // NOT volatile
public void stop() { running = false; } // Writer thread
public void run() {
while (running) { // Reader thread might read cached CPU value!
doWork(); // This loop may NEVER exit
}
}
}
// ✅ FIX: volatile forces main memory read/write
class Runner {
private volatile boolean running = true;
// volatile guarantees: writes by Thread-1 are VISIBLE to Thread-2
public void stop() { running = false; }
public void run() { while (running) { doWork(); } } // Now works correctly
}
// volatile does NOT make compound operations atomic!
// For i++ use AtomicInteger, not volatile int
Collections Framework
Wrong collection choice causes real performance problems. A LinkedList where you needed an ArrayList, or a HashMap where you needed ConcurrentHashMap, can crash your application in production.
| Collection | Use When | Thread Safe? | Ordered? | Time Complexity |
|---|---|---|---|---|
ArrayList |
Random access, frequent reads | ❌ No | ✅ Insertion order | O(1) get, O(n) insert middle |
LinkedList |
Frequent insert/delete at head/tail | ❌ No | ✅ Insertion order | O(1) add/remove ends, O(n) get |
HashMap |
Key-value lookup, single thread | ❌ No | ❌ No | O(1) avg get/put |
LinkedHashMap |
Insertion-ordered map (LRU cache) | ❌ No | ✅ Insertion order | O(1) avg get/put |
TreeMap |
Sorted keys, range queries | ❌ No | ✅ Sorted | O(log n) get/put |
ConcurrentHashMap |
Multi-threaded key-value access | ✅ Yes | ❌ No | O(1) avg, segment-locked |
CopyOnWriteArrayList |
Many reads, rare writes | ✅ Yes | ✅ Insertion order | O(n) write, O(1) read |
HashSet |
Unique elements, fast membership | ❌ No | ❌ No | O(1) add/contains |
Collections.synchronizedList(list) synchronizes individual methods, but iteration is still unsafe. Two threads can corrupt a compound operation. Always use CopyOnWriteArrayList or explicit locks for concurrent iteration.
Streams API & Functional Programming
The Streams API, introduced in Java 8, transformed how Java code is written. Spring Boot uses it everywhere — in repository queries, configuration processing, and data transformation. Understanding streams lets you write concise, readable, and often faster code.
// SCENARIO: Process a list of orders from the database
List<Order> orders = orderRepository.findAll();
// ❌ Old imperative style
List<OrderDto> result = new ArrayList<>();
for (Order order : orders) {
if (order.getStatus() == OrderStatus.PENDING
&& order.getAmount() > 100) {
result.add(new OrderDto(order.getId(), order.getAmount()));
}
}
result.sort(Comparator.comparing(OrderDto::getAmount).reversed());
// ✅ Streams — declarative, readable, and parallel-capable
List<OrderDto> result = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.PENDING)
.filter(o -> o.getAmount() > 100)
.map(o -> new OrderDto(o.getId(), o.getAmount()))
.sorted(Comparator.comparing(OrderDto::getAmount).reversed())
.collect(Collectors.toList());
// SCENARIO: Group orders by status
Map<OrderStatus, List<Order>> byStatus = orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
// SCENARIO: Total revenue by product category
Map<String, Double> revenueByCategory = orders.stream()
.collect(Collectors.groupingBy(
o -> o.getProduct().getCategory(),
Collectors.summingDouble(Order::getAmount)
));
// SCENARIO: Parallel stream for CPU-intensive work
// ⚠️ WARNING: Only use parallel streams for CPU-bound tasks on large datasets!
// DO NOT use for I/O-bound tasks (DB calls, HTTP calls) — use CompletableFuture instead
long totalRevenue = orders.parallelStream()
.mapToLong(Order::getAmount)
.sum();
// SCENARIO: FlatMap — flattening nested collections
List<String> allTags = orders.stream()
.flatMap(o -> o.getTags().stream()) // Order has List<String> tags
.distinct()
.sorted()
.collect(Collectors.toList());
Exception Handling
Bad exception handling is one of the most common sources of production bugs. Swallowed exceptions, overly broad catch blocks, and unchecked exceptions bubbling up as 500 errors — these are things you need to design upfront, not fix after the fact.
// Design your exception hierarchy deliberately
// Use RuntimeExceptions for domain errors (no forced try-catch)
// Base exception for your domain
public abstract class ApplicationException extends RuntimeException {
private final String errorCode;
public ApplicationException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// Specific exceptions
public class ResourceNotFoundException extends ApplicationException {
public ResourceNotFoundException(String resource, Object id) {
super("RESOURCE_NOT_FOUND",
String.format("%s with id '%s' not found", resource, id));
}
}
public class BusinessRuleViolationException extends ApplicationException {
public BusinessRuleViolationException(String message) {
super("BUSINESS_RULE_VIOLATION", message);
}
}
public class InsufficientFundsException extends ApplicationException {
public InsufficientFundsException(double required, double available) {
super("INSUFFICIENT_FUNDS",
String.format("Required: %.2f, Available: %.2f", required, available));
}
}
// Spring Boot global handler — catches ALL domain exceptions
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse(ex.getErrorCode(), ex.getMessage(), 404);
}
@ExceptionHandler(BusinessRuleViolationException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponse handleBusinessRule(BusinessRuleViolationException ex) {
return new ErrorResponse(ex.getErrorCode(), ex.getMessage(), 422);
}
@ExceptionHandler(Exception.class) // Catch-all — don't expose internals!
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex) {
log.error("Unhandled exception", ex); // Log the full trace internally
return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred", 500);
// NEVER return ex.getMessage() — it might leak SQL, stack traces, etc.
}
}
Generics & Type Safety
Spring Boot uses generics extensively — JpaRepository<User, Long>, ResponseEntity<UserDto>, Optional<Order>, List<String>. Understanding generics is necessary to read and write production Spring Boot code without compiler warnings.
// Generic repository pattern (what Spring Data JPA gives you)
public interface Repository<T, ID> {
T findById(ID id);
T save(T entity);
void delete(T entity);
List<T> findAll();
}
// Bounded type parameters — T must extend Serializable
public class Cache<T extends Serializable> {
private final Map<String, T> store = new ConcurrentHashMap<>();
public void put(String key, T value) { store.put(key, value); }
public Optional<T> get(String key) { return Optional.ofNullable(store.get(key)); }
}
// Generic API response wrapper (common Spring pattern)
public class ApiResponse<T> {
private final T data;
private final String message;
private final int status;
private final Instant timestamp = Instant.now();
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(data, "Success", 200);
}
public static <T> ApiResponse<T> error(String message, int status) {
return new ApiResponse<>(null, message, status);
}
}
// Usage in controller
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserDto>> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return ResponseEntity.ok(ApiResponse.success(user));
}
// Wildcard — when you need flexibility
public double calculateTotal(List<? extends Order> orders) {
// Works with List<Order>, List<PremiumOrder>, List<TrialOrder>
return orders.stream().mapToDouble(Order::getAmount).sum();
}
Reflection — How Spring Uses It
Reflection is Spring's superpower. When Spring sees @Autowired, it uses reflection to inspect field types and inject beans. When Hibernate sees @Entity, it reflects on field names to generate SQL. Understanding reflection helps you understand why Spring Boot works the way it does.
// This is SIMPLIFIED pseudocode of what Spring does with @Autowired
// The real source is 100x more complex, but this is the idea:
public class SimpleDIContainer {
private final Map<Class<?>, Object> beans = new HashMap<>();
public void register(Object bean) {
beans.put(bean.getClass(), bean);
}
// This is what @Autowired triggers
public void injectDependencies(Object target) throws Exception {
Class<?> clazz = target.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(Autowired.class)) {
field.setAccessible(true); // Bypass private access!
Object dependency = beans.get(field.getType());
field.set(target, dependency); // Inject the bean
}
}
}
}
// And this is how @Transactional works — runtime proxy via reflection
public class TransactionProxy implements InvocationHandler {
private final Object target;
private final TransactionManager txManager;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.isAnnotationPresent(Transactional.class)) {
txManager.begin();
try {
Object result = method.invoke(target, args); // Call real method
txManager.commit();
return result;
} catch (Exception e) {
txManager.rollback();
throw e;
}
}
return method.invoke(target, args);
}
}
// Spring creates a Proxy.newProxyInstance() wrapping your @Service —
// THAT'S why @Transactional doesn't work when you call methods internally!
// Internal calls skip the proxy.
When methodA() calls methodB() on the same bean, the call goes directly to the real object — not through the proxy. So @Transactional on methodB() is ignored. This is one of the most common Spring Boot bugs.
Lambdas & Functional Interfaces
Lambdas are syntactic sugar for anonymous classes that implement a functional interface. But they fundamentally changed how Spring Boot APIs are designed. RestTemplate, WebClient, and all modern Spring APIs accept lambdas heavily.
// Functional interfaces — take a lambda, get a behavior
@FunctionalInterface
public interface Validator<T> {
boolean validate(T value);
// Only ONE abstract method = functional interface
}
// Now use lambdas to pass behavior:
Validator<String> emailValidator = email -> email.contains("@") && email.contains(".");
Validator<Integer> ageValidator = age -> age >= 18 && age <= 120;
// BUILT-IN functional interfaces (java.util.function)
// Predicate<T>: T -> boolean (filtering)
Predicate<User> isActive = user -> user.getStatus() == UserStatus.ACTIVE;
List<User> activeUsers = users.stream().filter(isActive).collect(toList());
// Function<T,R>: T -> R (transformation/mapping)
Function<User, UserDto> toDto = user -> new UserDto(user.getId(), user.getName());
List<UserDto> dtos = users.stream().map(toDto).collect(toList());
// Consumer<T>: T -> void (side effects)
Consumer<Order> auditOrder = order -> auditLog.record(order.getId(), "PROCESSED");
orders.forEach(auditOrder);
// Supplier<T>: () -> T (lazy evaluation)
Supplier<String> errorMsg = () -> "Error occurred at " + LocalDateTime.now();
// The string is only created if we actually need it
// Method references — even cleaner lambdas
users.stream()
.map(User::getName) // instance method reference
.filter(Objects::nonNull) // static method reference
.forEach(System.out::println); // instance method reference
// Spring Security uses Predicate-like lambdas
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
CompletableFuture & Async Java
Modern Spring Boot applications are expected to handle high concurrency without blocking threads. CompletableFuture is Java's native solution for async, non-blocking programming — and Spring Boot integrates with it through @Async.
// SCENARIO: Place an order — multiple independent async tasks
@Service
public class OrderService {
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final NotificationService notificationService;
private final Executor asyncExecutor;
public OrderConfirmation placeOrder(OrderRequest request) {
// These 3 can run IN PARALLEL — no dependency between them
CompletableFuture<Boolean> inventoryCheck = CompletableFuture.supplyAsync(
() -> inventoryService.checkAvailability(request.getProductId()),
asyncExecutor
);
CompletableFuture<PaymentResult> paymentProcess = CompletableFuture.supplyAsync(
() -> paymentService.charge(request.getPaymentDetails()),
asyncExecutor
);
// Wait for BOTH inventory and payment before proceeding
CompletableFuture<OrderConfirmation> orderFuture = inventoryCheck
.thenCombine(paymentProcess, (inventoryOk, paymentResult) -> {
if (!inventoryOk) throw new BusinessRuleViolationException("Out of stock");
if (!paymentResult.isSuccess()) throw new BusinessRuleViolationException("Payment failed");
Order order = createAndSaveOrder(request, paymentResult);
return new OrderConfirmation(order.getId(), order.getStatus());
});
// Non-blocking: send notification AFTER order is confirmed
orderFuture.thenAcceptAsync(
confirmation -> notificationService.sendConfirmation(confirmation),
asyncExecutor
);
// Wait for result (in a real async controller, return the Future directly)
try {
return orderFuture.get(5, TimeUnit.SECONDS); // Timeout is critical!
} catch (TimeoutException e) {
throw new ServiceUnavailableException("Order processing timed out");
}
}
}
// ERROR HANDLING in async pipelines
CompletableFuture<User> userFuture = CompletableFuture
.supplyAsync(() -> userService.findById(userId))
.exceptionally(ex -> {
log.error("Failed to fetch user {}: {}", userId, ex.getMessage());
return User.anonymous(); // Return fallback value
})
.thenApply(user -> {
user.setLastAccessed(Instant.now());
return user;
});
Java Records & Modern Java
Java records, introduced in Java 16, are immutable data carriers. They're perfect for DTOs in Spring Boot — replacing verbose boilerplate POJOs with clean, concise declarations. Spring Boot fully supports records for request bodies, response DTOs, and even JPA projections.
// BEFORE Records: Verbose DTO (this was 50 lines with Lombok)
public class CreateUserRequest {
@NotBlank private String name;
@Email private String email;
@Size(min = 8) private String password;
// getters, setters, equals, hashCode, toString...
}
// WITH Records: Concise, immutable, with validation
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Size(min = 8) String password
) {}
// Records in a controller — works perfectly
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
@RequestBody @Valid CreateUserRequest request) {
User user = userService.create(request.name(), request.email(), request.password());
return ResponseEntity.created(URI.create("/users/" + user.getId()))
.body(new UserResponse(user.getId(), user.getName()));
}
// Response DTO as a record
public record UserResponse(Long id, String name) {}
// Records work with JPA projections too!
public interface UserProjection {
record UserSummary(Long id, String name, String email) {}
List<UserSummary> findAllProjectedBy(); // Spring Data generates SQL
}
HTTP Clients in Java
Backend services rarely live in isolation. Your Spring Boot app will call other services, third-party APIs, and microservices. Choosing and configuring the right HTTP client is a production engineering decision.
// 1. RestTemplate (legacy — synchronous, blocking)
// Use only for simple cases or legacy codebases
@Service
public class UserServiceClient {
private final RestTemplate restTemplate;
public UserDto getUser(Long id) {
// Blocks the current thread until response!
return restTemplate.getForObject("http://user-service/users/" + id, UserDto.class);
}
}
// 2. WebClient (modern — non-blocking, reactive)
// Best for microservices communicating with each other
@Service
public class UserServiceClient {
private final WebClient webClient;
public UserServiceClient(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("http://user-service")
.defaultHeader("X-Api-Key", "secret")
.build();
}
// Returns a Mono — doesn't block the thread
public Mono<UserDto> getUser(Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
response -> Mono.error(new UserNotFoundException(id)))
.bodyToMono(UserDto.class)
.timeout(Duration.ofSeconds(3)) // Always set timeouts!
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))); // Retry on failure
}
}
// 3. Java 11+ HttpClient (native, no Spring dependency)
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Authorization", "Bearer " + token)
.GET()
.build();
// Async non-blocking send
client.sendAsync(request, BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenApply(body -> objectMapper.readValue(body, UserDto.class))
.exceptionally(ex -> { log.error("HTTP call failed", ex); return null; });
Test Your Understanding
Knowledge Check — Java Concurrency
Question 1 of 3@Service bean has a field private int requestCount = 0; that is incremented on every API call. Why is this a problem?
count++, you get a race condition. The ++ operation is NOT atomic — it's three operations: read, add, write. Use AtomicInteger or design the bean to be stateless.
Knowledge Check — JVM Memory
Question 2 of 3OutOfMemoryError: Java heap space under heavy load, even though there are no obvious memory leaks. Which is the MOST likely cause?
-Xmx, check for memory leaks with heap dumps (-XX:+HeapDumpOnOutOfMemoryError), and review if objects are being held in caches or static collections.
Knowledge Check — @Transactional
Question 3 of 3@Service with method methodA() calling methodB(). methodB() is annotated with @Transactional. Will the transaction on methodB() be applied?
@Transactional works via a proxy. External calls go through the proxy (transaction applied). Internal calls go directly to the real object (proxy bypassed, no transaction). Fix: inject the bean into itself (self-injection) or refactor into two different beans.
Coding Exercise
Implement a generic SimpleCache<K,V> class that is:
1. Thread-safe — use ConcurrentHashMap
2. Size-limited — maximum 100 entries (evict oldest on overflow)
3. Time-expiring — entries expire after a configurable TTL
4. Methods: put(key, value), get(key) returns Optional<V>, evictExpired()