Spring Advanced Topics

Spring Advanced Topics

Reactive Programming (Spring WebFlux)

A non-blocking, event-loop-based alternative to the traditional Spring MVC thread-per-request model — built on Project Reactor’s Mono/Flux types, running on Netty by default rather than Tomcat.

@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/{id}")
    public Mono<OrderResponse> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
            .switchIfEmpty(Mono.error(new OrderNotFoundException(id)));
    }

    @GetMapping
    public Flux<OrderResponse> listOrders(@RequestParam OrderStatus status) {
        return orderService.findByStatus(status);
    }

    @PostMapping
    public Mono<ResponseEntity<OrderResponse>> create(@Valid @RequestBody Mono<OrderRequest> request) {
        return request
            .flatMap(orderService::create)
            .map(created -> ResponseEntity.status(HttpStatus.CREATED).body(created));
    }
}

Mono<T> vs Flux<T> — the core vocabulary:

Type Represents
Mono<T> 0 or 1 element — analogous to Optional<T>/CompletableFuture<T> but lazily evaluated and composable
Flux<T> 0 to N elements — an asynchronous stream, analogous to Stream<T> but push-based and backpressure-aware

The defining trade-off, and the one architects must get right: reactive’s throughput benefit only materializes if the entire call chain is non-blocking end-to-end — a single blocking call (a JDBC query, Thread.sleep, a synchronous HTTP client call) buried inside a reactive chain blocks the Netty event-loop thread it’s running on, and since WebFlux runs a small, fixed number of event-loop threads (typically = CPU core count), blocking even one stalls all requests sharing that thread, not just the one that made the blocking call. This is categorically worse than the equivalent mistake in a blocking MVC app, where a slow thread only affects that one request’s dedicated thread.

// WRONG — blocking JDBC call inside a reactive chain, defeats the entire architecture
public Mono<Order> getOrder(Long id) {
    return Mono.fromCallable(() -> jdbcOrderRepository.findById(id))  // still blocks the event loop thread!
        .subscribeOn(Schedulers.boundedElastic());  // mitigation: offload to a dedicated blocking-safe pool
}

// RIGHT — fully non-blocking via R2DBC end-to-end (see next section)
public Mono<Order> getOrder(Long id) {
    return r2dbcOrderRepository.findById(id); // never touches an event-loop thread for blocking I/O
}

Schedulers.boundedElastic() is the escape hatch for unavoidable blocking calls (a legacy library with no reactive equivalent) — but it’s a mitigation, not a fix; the architecturally correct answer is keeping the entire pipeline reactive, reserving boundedElastic offloading for genuinely unavoidable exceptions, not as the default pattern.

Architect decision criteria — when WebFlux is actually the right choice, since it’s frequently adopted reflexively rather than deliberately:

  • Good fit: high-concurrency, I/O-bound workloads (many slow downstream calls, streaming responses, WebSocket/SSE-heavy APIs) where thread-per-request would need an impractically large thread pool to match throughput.
  • Poor fit: CPU-bound workloads (reactive adds no benefit — the bottleneck is computation, not waiting on I/O), teams without reactive debugging experience (stack traces span async boundaries and are genuinely harder to read), or systems where most downstream dependencies (a legacy JDBC-only DB driver, a blocking SOAP client) can’t be made non-blocking anyway — in that case Spring MVC with a well-sized thread pool is simpler and just as effective, and adopting WebFlux without addressing the blocking dependencies underneath is a documented anti-pattern that adds complexity for zero actual throughput gain.

R2DBC

Reactive Relational Database Connectivity — a non-blocking database driver specification, the reactive-world equivalent of JDBC. JDBC is fundamentally blocking by design (its API contract assumes a thread waits for the result) and cannot be made truly non-blocking even with wrapping — R2DBC exists precisely to close that gap, since “reactive web layer over a blocking JDBC layer” was the most common mistake teams made in early WebFlux adoption.

public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {

    Flux<Order> findByStatus(OrderStatus status);

    @Query("SELECT * FROM orders WHERE customer_id = :customerId ORDER BY created_at DESC")
    Flux<Order> findByCustomer(Long customerId);
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final DatabaseClient databaseClient; // lower-level access when repository abstraction isn't enough

    public Mono<Order> create(OrderRequest request) {
        Order order = buildOrder(request);
        return orderRepository.save(order);
    }

    public Mono<OrderSummary> getSummary(Long customerId) {
        return databaseClient.sql("SELECT COUNT(*), SUM(total_amount) FROM orders WHERE customer_id = :id")
            .bind("id", customerId)
            .map((row, meta) -> new OrderSummary(
                row.get(0, Long.class), row.get(1, BigDecimal.class)))
            .one();
    }
}
spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/orders
    username: app
    password: ${DB_PASSWORD}
    pool:
      initial-size: 10
      max-size: 50

Architect-level limitations worth knowing, since this is where R2DBC honestly falls short of JPA/Hibernate:

  • No first/second-level cache — every query genuinely hits the DB; R2DBC repositories don’t carry JPA’s persistence-context caching behavior.
  • No lazy loading of associations — there’s no proxy mechanism equivalent to JPA’s lazy-loaded entity graphs; relationships must be explicitly fetched/joined per query, which is arguably more honest about what’s happening but loses JPA’s convenience.
  • Less mature ORM-style tooling — no JPQL/Criteria API equivalent, no automatic dirty-checking, no cascading persistence the way JPA’s CascadeType provides — Spring Data R2DBC is intentionally a thinner abstraction than Spring Data JPA, closer to a reactive query-mapper than a full ORM.
  • Driver ecosystem is narrower than the mature JDBC driver landscape, though major databases (Postgres, MySQL, SQL Server, H2) are well covered today.

Practical architect guidance: adopt R2DBC only when the rest of the stack is genuinely reactive end-to-end (WebFlux controllers, reactive downstream clients) — pairing it with an otherwise blocking-style service layer gains nothing, and the reduced ORM convenience is a real cost that should be a deliberate trade-off for the concurrency benefit, not a default choice made without considering whether MVC + JPA would simply be the better engineering fit for the actual workload.


Modular Monolith with Spring

An architectural middle ground between a tangled “big ball of mud” monolith and a fully distributed microservices estate — strong internal module boundaries, enforced at compile time, while still deploying as a single artifact. Increasingly the recommended default starting architecture for new systems, with microservices reserved for when genuine independent-scaling or independent-deployment needs actually materialize.

Package-by-feature, not package-by-layer — the structural foundation:

com.acme.orders
├── ordermanagement/        (module)
│   ├── api/                (public contract — what other modules may call)
│   ├── domain/              (package-private internals)
│   └── infrastructure/
├── inventory/               (module)
│   ├── api/
│   ├── domain/
│   └── infrastructure/
└── customer/                 (module)
    ├── api/
    ├── domain/
    └── infrastructure/

Each module’s domain/infrastructure packages are deliberately not public — only the api package is, forcing genuine cross-module interaction to go through an intentional contract rather than reaching into another module’s internals because nothing technically stops it.

Spring Modulith — the library purpose-built for this pattern, providing tooling Spring alone doesn’t enforce by default:

@ApplicationModule(
    allowedDependencies = {"customer::api", "inventory::api"}
)
package com.acme.orders.ordermanagement;
@Test
void verifyModularStructure() {
    ApplicationModules modules = ApplicationModules.of(OrderApplication.class);
    modules.verify(); // FAILS the build if a module reaches into another's internals improperly
}

This is the architecturally significant piece: module boundary violations become a compile/test-time failure, not a code-review-discipline hope — modules.verify() running in CI is what actually prevents boundary erosion over time as a team grows and turnover happens, which is precisely the failure mode that turns a clean modular monolith into a tangled one within a year or two without enforcement.

Cross-module communication patterns, in order of preference:

  1. Direct API calls through the module’s public interface — simplest, fine for synchronous, same-transaction needs.
  2. Spring’s ApplicationEventPublisher (in-process events) for decoupled, eventually-consistent cross-module reactions — e.g., OrderPlacedEvent published by ordermanagement, consumed by inventory and notification modules without ordermanagement knowing either exists.
@Component
public class OrderPlacedListener {
    @ApplicationModuleListener // Spring Modulith annotation — transactional, async by default
    void on(OrderPlacedEvent event) {
        inventoryService.reserve(event.orderId(), event.items());
    }
}

Architect-level positioning, since this is a live, currently-relevant debate worth being sharp on: a modular monolith gives nearly all the maintainability benefit teams reach for microservices to get (clear boundaries, independent reasoning about each module, enforced decoupling) without the operational cost microservices impose unconditionally (distributed tracing, network failure modes, eventual consistency everywhere, deployment orchestration complexity) — costs that are only worth paying when there’s an actual need for independent scaling or independent team/deployment cadence per module. The well-regarded migration path is modular monolith first, with module boundaries designed deliberately so that extracting a genuinely-needs-to-scale-independently module into its own service later is a mechanical, low-risk move rather than a rewrite — Spring Modulith’s enforced boundaries are specifically what make that future extraction cheap if and when it’s actually needed.


DDD with Spring

Domain-Driven Design isn’t a Spring-specific feature — it’s a modeling discipline that Spring’s flexibility (no forced anemic-entity pattern, POJOs as first-class citizens) accommodates well, in contrast to frameworks that push toward thinner, more data-centric models.

Aggregate — the core DDD building block, and the natural fit with modular monolith module boundaries above:

public class Order {  // the Aggregate Root — the ONLY entry point for mutating anything inside this aggregate

    private OrderId id;
    private OrderStatus status;
    private List<OrderLine> lines;  // NOT exposed as a mutable public list

    public static Order place(CustomerId customerId, List<OrderLineRequest> requests) {
        // factory method enforces invariants at creation — can't construct an invalid Order
        if (requests.isEmpty()) {
            throw new IllegalArgumentException("Order must contain at least one line");
        }
        return new Order(customerId, requests.stream().map(OrderLine::from).toList());
    }

    public void cancel() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Cannot cancel a shipped order");
            // business rule enforced INSIDE the domain model, not in a service-layer if-check
        }
        this.status = OrderStatus.CANCELLED;
    }

    public List<OrderLine> getLines() {
        return List.copyOf(lines); // defensive copy — external code cannot mutate internal state directly
    }
}

Why this matters architecturally, contrasted against the common “anemic domain model” anti-pattern: an anemic model is a plain data-holder (getters/setters, no behavior) with all logic pushed into a service class — OrderService.cancel(Order order) checking if (order.getStatus() == SHIPPED) throw.... This scatters business-rule enforcement across every service method that happens to touch the entity, with no guarantee every call site remembers the rule. DDD’s rich domain model pulls that invariant into the entity itself — order.cancel() is the only way to cancel, and the rule lives in exactly one place, impossible to bypass accidentally.

Repository — in DDD terms, specifically an abstraction over aggregate persistence, not a generic CRUD-everything interface:

public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);  // persists the WHOLE aggregate atomically — lines included, never partially
}

Spring Data JPA repositories can absolutely implement this contract underneath, but the DDD discipline is that the repository interface itself is defined in domain-layer terms (aggregate-shaped, not table-shaped) — the Spring Data JPA mechanics are an infrastructure-layer implementation detail behind it, not something the domain model or service layer should know about.

Domain Events — the connective tissue between DDD aggregates and the modular-monolith event pattern above:

public class Order {
    private final List<Object> domainEvents = new ArrayList<>();

    public void confirm() {
        this.status = OrderStatus.CONFIRMED;
        domainEvents.add(new OrderConfirmedEvent(this.id));
    }
}

@Service
@RequiredArgsConstructor
public class OrderApplicationService {  // the "Application Service" layer — orchestrates, doesn't contain business rules itself

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void confirmOrder(OrderId id) {
        Order order = orderRepository.findById(id).orElseThrow();
        order.confirm();                          // business rule lives in the aggregate
        orderRepository.save(order);
        order.pullDomainEvents().forEach(eventPublisher::publishEvent); // side effects dispatched after persistence
    }
}

Bounded Context — the DDD concept that maps almost directly onto the modular monolith’s module boundaries: each module (ordermanagement, inventory, customer) represents its own Bounded Context, with its own ubiquitous language and its own model of shared concepts (e.g., “Customer” in the ordermanagement context might only need customerId and creditLimit, while the customer module’s own full Customer model has dozens of additional fields irrelevant to order placement) — explicitly not sharing one giant Customer entity across every module, which is one of the most common and costly modeling mistakes in systems that nominally claim DDD but actually built one shared anemic entity graph.

Architect-level synthesis, tying this whole conversation’s modular-monolith and DDD topics together: Spring Modulith’s enforced module boundaries are the technical mechanism; DDD’s Bounded Context is the conceptual justification for why those boundaries should exist where they do and what each module’s internal model should actually contain. Treating them as the same underlying discipline — applied at the architecture-enforcement layer (Modulith) and the domain-modeling layer (DDD) respectively — is the kind of synthesis that distinguishes a strong architect-level answer from someone who’s read about both topics independently without connecting them, which is frequently exactly what a senior architecture interview is probing for.