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

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.

💡
Engineering Insight

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.

BankAccount.java — Encapsulation Done Right Java
// ❌ 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);
    }
}
⚠️
Production Bug Pattern

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.

Inheritance vs Composition Java
// ❌ 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.

Polymorphism — How Spring Uses It Java
// 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.

Layered Abstraction in Spring Boot Java
// 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:

JVM Execution Pipeline bash
# 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!
JVM Memory Visualizer — Interactive

Allocate objects, run garbage collection, and push/pop stack frames. Watch how memory actually works inside the JVM.

Why This Matters for Spring Boot

🏭
Production Reality

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)
Stack vs Heap — What goes where Java
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.

Heap Generations — How GC Works
YOUNG GENERATION (Eden + Survivors)
New objects are born here. Minor GC runs frequently (~100ms). 90% of objects die here. Fast to collect.
Eden
S0
S1
OLD GENERATION
Objects that survived 15+ Minor GCs promoted here. Major GC ("Full GC") runs rarely but causes STOP-THE-WORLD pauses (100ms–several seconds).
METASPACE
Class metadata. Spring Boot loads hundreds of classes at startup — each adds to Metaspace. Default: no limit. Bad news if you load too many dynamic classes.
GC ROOTS
Static fields, thread stack references, JNI references. Objects reachable from GC roots are NEVER collected. Memory leaks happen here.
🏭 GC Tuning for Spring Boot — Production Config Real JVM flags used in production
JVM flags for a Spring Boot production app bash
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.

Thread State Simulator — Interactive

Create threads, transition them between states, and simulate real concurrency problems like deadlocks and race conditions.

Creating Threads — Three Ways

Thread Creation — Old to Modern Java
// 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.

🚨
The Most Dangerous Pattern in Spring Boot

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.

Race Condition in a Spring Bean — Classic Bug Java
// ❌ 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 — volatile keyword Java
// ❌ 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.

CollectionUse WhenThread 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 is NOT enough

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.

Streams — Real Backend Use Cases Java
// 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.

Exception Hierarchy — What Spring Boot Sees java
// 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.

Generics — The Patterns Spring Boot Uses Java
// 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.

Reflection — What Spring Does Internally Java
// 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.
💡
Why @Transactional Internal Calls Fail

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.

Lambdas in Spring Boot Context Java
// 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.

CompletableFuture — Async Pipeline Java
// 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.

Records in Spring Boot Java
// 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.

HTTP Clients — RestTemplate vs WebClient vs HttpClient Java
// 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
A Spring Boot @Service bean has a field private int requestCount = 0; that is incremented on every API call. Why is this a problem?
Because @Service beans are not allowed to have instance fields.
Because the bean is a singleton shared across all threads. Multiple threads incrementing the same int concurrently causes a race condition — some increments will be lost.
Because int is a primitive type and cannot be used in Spring beans. It needs to be an Integer.
There is no problem — Spring automatically synchronizes access to bean fields.
Correct! Spring singleton beans are shared instances. When 1,000 concurrent requests each increment 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 3
A Spring Boot application keeps throwing OutOfMemoryError: Java heap space under heavy load, even though there are no obvious memory leaks. Which is the MOST likely cause?
The application has too many threads.
The JVM's Stack memory is too small.
The heap size is too small (-Xmx not configured) and the GC cannot keep up with the allocation rate under load, causing the heap to fill up.
The application is using too many lambdas, which consume excessive memory.
Correct! Under load, the allocation rate exceeds GC's ability to reclaim memory. First steps: increase -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
You have a @Service with method methodA() calling methodB(). methodB() is annotated with @Transactional. Will the transaction on methodB() be applied?
Yes — @Transactional always creates a transaction regardless of how the method is called.
No — when methodA() calls methodB() on the same bean, the call bypasses Spring's proxy. @Transactional is ignored for internal (self) calls.
Yes, but only if methodA() is also annotated with @Transactional.
It depends on the database — some databases ignore the transaction annotation.
Correct! This is one of the most common Spring Boot bugs. Spring's @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

Build a Thread-Safe Cache
Implement a simple in-memory cache using ConcurrentHashMap with time-based expiry
Medium

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()

Section Progress
Mark this section complete when you're confident in the material