skip to content

How do nested routes (nest / path) and filter functions (filter / before / after / onError) work in WebMvc.fn?

level: seniorimportance: should knowfreq 28%

answer

  1. nest(predicate) / path(prefix) = shared condition
  2. filter(req, next) = wrap + short-circuit
  3. before = transform request, after = transform response
  4. onError = exception -> ServerResponse
  5. Immutable: return copies (ServerResponse.from)

basics

~20 s

nest() (or path()) groups routes under a shared predicate like /users, so you don't repeat it. Inside a nest you can attach filters that apply only to those routes: filter() wraps the handler, before()/after() transform the request/response, and onError() maps exceptions to responses.

solid answer

~40 s

The RouterFunctions.route() builder supports nesting: nest(predicate, consumer) — or the path(pattern, consumer) shortcut — groups child routes under a shared RequestPredicate (common path prefix, content type, etc.), avoiding repetition and scoping cross-cutting concerns. Filters attached inside a nest apply only to that group; filters at the top level apply to all routes. The filter primitives are: filter(HandlerFilterFunction) — the general form, receiving (request, next handler) so you can run logic before and after and short-circuit; before(Function<ServerRequest,ServerRequest>) — transform/inspect the request; after(BiFunction<ServerRequest,ServerResponse,ServerResponse>) — transform the response; and onError(Predicate<Throwable>, handler) — map matching exceptions to a ServerResponse. Filters compose in registration order and are the functional analogue of interceptors/AOP for routes — ideal for auth, logging, and metrics scoped to a route group.

code

java · 19 lines
java
import static org.springframework.web.servlet.function.RequestPredicates.accept;
import org.springframework.web.servlet.function.*;
import org.springframework.http.*;

@Bean
RouterFunction<ServerResponse> userRoutes(UserHandler h, AuthFilter auth) {
    return RouterFunctions.route()
        .nest(accept(MediaType.APPLICATION_JSON), b -> b
            .path("/users", u -> u
                .GET("", h::list)
                .GET("/{id}", h::get)
                .POST("", h::create)
                .filter(auth::require)))          // scoped to /users/**
        .after((req, res) ->
            ServerResponse.from(res).header("X-App", "katajob").build())
        .onError(IllegalArgumentException.class,
            (ex, req) -> ServerResponse.badRequest().body(ex.getMessage()))
        .build();
}

go deeper

for a junior

Know that nest/path avoids repeating a shared path and that filters run around handlers.

for a middle

Distinguish filter vs before/after/onError and know filters are scoped by nesting.

for a senior

Explain short-circuiting, immutability (return copies), filter composition order, and scope-by-nest semantics.

for a principal

Design route-group cross-cutting strategy (auth/observability), reason about filter ordering pitfalls and interaction with global HandlerExceptionResolvers.

## Nesting routes When many routes share a condition (usually a path prefix), repeating that predicate is noisy and error-prone. The builder provides: - **`nest(RequestPredicate, Consumer<Builder>)`** — the general nesting operator: every route defined in the consumer is implicitly ANDed with the outer predicate. - **`path(String pattern, Consumer<Builder>)`** — a shortcut for `nest(RequestPredicates.path(pattern), ...)`, the common case of a shared path prefix. ```java RouterFunctions.route() .path("/users", b -> b .GET("", handler::list) // GET /users .GET("/{id}", handler::get) // GET /users/{id} .POST("", handler::create)) // POST /users .build(); ``` Nesting can be **arbitrarily deep**, and you can nest on any predicate, not just paths — e.g. `nest(accept(APPLICATION_JSON), ...)` to scope routes to a content type. ## Filter functions — the four flavors Filters wrap handler invocation. Registered on the builder, they apply to **all routes defined at that level or below** (so a filter inside a `nest` is scoped to that nest only). 1. **`filter(HandlerFilterFunction<T,R>)`** — the general form. Signature: `R filter(ServerRequest request, HandlerFunction<T> next)`. You decide whether/when to call `next.handle(request)`, so you can run code **before and after**, modify inputs/outputs, or **short-circuit** by returning a response without calling `next` (e.g. reject unauthenticated requests). This is the analogue of a servlet `Filter` or `HandlerInterceptor`, scoped to routes. 2. **`before(Function<ServerRequest,ServerRequest>)`** — pre-processing that returns a (possibly new) `ServerRequest`. Because `ServerRequest` is immutable, you return a transformed copy (e.g. add an attribute). Good for logging or enriching the request. 3. **`after(BiFunction<ServerRequest,ServerResponse,ServerResponse>)`** — post-processing that receives the original request and the produced response and returns a (possibly new) response (e.g. add a header). Runs after the handler. 4. **`onError(Predicate<Throwable>, BiFunction<Throwable,ServerRequest,ServerResponse>)`** — error mapping: if the handler (or a downstream filter) throws a `Throwable` matching the predicate, the function produces a `ServerResponse` instead of propagating. A convenience overload takes an exception `Class`. ```java RouterFunctions.route() .path("/admin", b -> b .GET("/stats", handler::stats) .filter((request, next) -> { // scoped to /admin/** if (!isAuthorized(request)) { return ServerResponse.status(HttpStatus.FORBIDDEN).build(); } return next.handle(request); })) .before(req -> { log.info("{} {}", req.method(), req.uri()); return req; }) .after((req, res) -> ServerResponse.from(res).header("X-Trace", id()).build()) .onError(IllegalArgumentException.class, (e, req) -> ServerResponse.badRequest().body(e.getMessage())) .build(); ``` ## Ordering and composition semantics - Filters compose like nested wrappers. When multiple `filter`s are registered, they wrap in a defined order; conceptually the **last-registered filter is the outermost** wrapper around the handler in the Spring MVC functional builder. Because ordering can be subtle, keep filters at the level where their scope is clear (top-level for global, inside a `nest` for group-scoped). - `before` runs before the handler; `after` runs after; `onError` catches downstream throwables. A general `filter` can straddle all of these in one place. - Short-circuiting: only the general `filter` form (and `onError`) can produce a response *without* invoking the handler. `before`/`after` always let the handler run. ## When to use which - **Cross-cutting auth / rate-limit / short-circuit** → `filter` (needs to optionally skip the handler). - **Request enrichment / MDC / logging pre** → `before`. - **Response decoration / headers / logging post** → `after`. - **Turning exceptions into HTTP responses locally** → `onError`. - **Shared path or media-type grouping** → `nest` / `path`. ## Gotchas - `ServerRequest`/`ServerResponse` are immutable — `before`/`after` must **return** the transformed instance; mutating in place does nothing. Use `ServerResponse.from(existing)` to copy-and-modify. - Filter scope follows the builder structure: a filter placed *after* some routes and *inside* a nest still applies to the whole nest, not just routes below it — think in terms of the nest block, not textual position. - `onError` only catches throwables from routes it wraps; unhandled ones still fall through to the global `HandlerExceptionResolver` chain (e.g. `@ControllerAdvice`).

  • What's the difference between filter() and before()/after()?
    before/after are one-directional transforms of the request or response and always let the handler run. filter() receives the next HandlerFunction, so it can run logic on both sides AND short-circuit by returning a response without calling next — required for auth/rejection.
  • If a filter is inside a nest, what does it apply to?
    Only the routes defined within that nest block, not the whole router. Top-level filters apply to all routes. This scoping is how you attach concerns like auth to just one route group.
  • How does onError relate to @ControllerAdvice?
    onError handles matching exceptions locally within the router. Anything not caught propagates to the DispatcherServlet's HandlerExceptionResolver chain, so global @ControllerAdvice/@ExceptionHandler still applies as a fallback.

saying these in an interview costs you the question

  • Saying before() can short-circuit and skip the handler (only filter/onError can)
  • Mutating ServerRequest/ServerResponse in a filter instead of returning a new one
  • Believing a filter inside a nest applies globally
  • Confusing nest() with a runtime redirect rather than a compile-time predicate grouping

context