Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Spring MVC & REST

Spring MVC is the web framework built on the Servlet API. In modern microservices, we mostly use it to build REST APIs. Real-world analogy: Think of Spring MVC like a well-organized restaurant. The DispatcherServlet is the host who greets every customer (HTTP request) at the door. The HandlerMapping is the seating chart that decides which waiter (controller method) handles which table. The waiter takes the order, the kitchen (service layer) prepares the food, and the response goes back through the same chain. Filters are the bouncers at the entrance who check IDs before anyone even gets to the host. Interceptors are the managers walking the floor, observing interactions between the host and the waiters.

1. REST Controller Annotations

AnnotationPurpose
@RestControllerCombines @Controller and @ResponseBody.
@RequestMappingBase path for the controller (e.g., /api/v1/users).
@GetMapping, @PostMappingShortcuts for specific HTTP methods.
@PutMapping, @DeleteMappingUpdate and Delete mappings.
@PathVariableExtract values from the URI path (e.g., /users/{id}).
@RequestParamExtract query parameters (e.g., /users?role=admin).
@RequestBodyMap the JSON body to a Java Object (POJO).
@ResponseStatusSet the HTTP status code (e.g., 201 CREATED).

2. Building a User API

Let’s build a CRUD API for a User resource. The Domain Model (DTO)
public record UserDto(Long id, String name, String email) {}
The Controller
@RestController
// Versioned API path -- always version your public APIs from day one.
// Changing /api/users to /api/v1/users later breaks every client.
@RequestMapping("/api/v1/users")
public class UserController {

    // In-memory list for demo purposes only.
    // In production, this would be a service layer backed by a repository.
    private final List<UserDto> users = new ArrayList<>();

    @GetMapping
    public List<UserDto> getAllUsers() {
        // In production, you would add pagination here (see Spring Data's Pageable).
        // Returning an unbounded list is a memory bomb waiting to go off.
        return users;
    }

    @GetMapping("/{id}")
    public UserDto getUserById(@PathVariable Long id) {
        return users.stream()
                .filter(u -> u.id().equals(id))
                .findFirst()
                // Never throw generic RuntimeException in production.
                // Use a custom ResourceNotFoundException (see @ControllerAdvice below).
                .orElseThrow(() -> new RuntimeException("User not found"));
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED) // Returns 201 instead of default 200
    public UserDto createUser(@RequestBody UserDto user) {
        users.add(user);
        // In production, return a ResponseEntity with a Location header:
        // ResponseEntity.created(URI.create("/api/v1/users/" + user.id())).body(user)
        return user;
    }
}

3. Exception Handling with @ControllerAdvice

Don’t let raw stack traces leak to the client. Use global exception handling.
// @RestControllerAdvice = @ControllerAdvice + @ResponseBody.
// This class acts as a centralized exception handler for ALL controllers.
// Think of it as a safety net stretched across your entire API surface.
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Catch ResourceNotFoundException and return 404 with a clean JSON body.
    // In production, create specific exception classes instead of catching RuntimeException.
    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(RuntimeException e) {
        return new ErrorResponse(404, e.getMessage());
    }

    // Production pattern: handle validation errors separately for structured field-level errors.
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationErrors(MethodArgumentNotValidException ex) {
        // Collect every field error into a map: {"email": "Invalid email format", ...}
        return ex.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        FieldError::getDefaultMessage,
                        (first, second) -> first // Keep first error if field has multiple
                ));
    }
}

record ErrorResponse(int status, String message) {}
Production tip: In enterprise APIs, standardize your error response format across all services using RFC 7807 Problem Details. Spring 6+ has built-in support via ProblemDetail. This gives clients a predictable contract for error handling regardless of which microservice they are talking to.

4. Bean Validation

Never trust client input. Use Hibernate Validator (implementation of Jakarta Bean Validation). Add dependency spring-boot-starter-validation. Add Constraints to DTO
public record UserCreateRequest(
    @NotBlank(message = "Name is required")
    String name,

    @Email(message = "Invalid email format")
    @NotBlank
    String email
) {}
Validate in Controller
@PostMapping
public UserDto createUser(@Valid @RequestBody UserCreateRequest request) { 
    // @Valid triggers Jakarta Bean Validation before the method body executes.
    // If validation fails, Spring throws MethodArgumentNotValidException
    // BEFORE your code runs -- you never see invalid data.
    // ... logic
}
You can then catch MethodArgumentNotValidException in your @RestControllerAdvice to return a nice list of validation errors (shown above). Pitfall — @Valid vs @Validated: @Valid is Jakarta standard and works on method parameters and nested objects. @Validated is Spring-specific and adds support for validation groups (e.g., validate different fields for create vs. update). Use @Valid by default; reach for @Validated only when you need group-based validation.

5. Content Negotiation

Spring Boot uses Jackson by default to serialize/deserialize Java Objects to JSON.
  • If you want XML, add jackson-dataformat-xml dependency.
  • Spring will check the Accept header of the request to decide whether to return JSON or XML.
Production tip: Configure Jackson globally rather than relying on defaults. Most enterprise APIs need these settings:
// In application.yml or a @Configuration class
@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> builder
                // Fail on unknown fields instead of silently ignoring them.
                // Catches client typos like "naem" instead of "name".
                .featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                // ISO-8601 dates instead of timestamps (1713000000 -> "2025-04-13T12:00:00Z")
                .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                // Do not include null fields in JSON output -- smaller payloads
                .serializationInclusion(JsonInclude.Include.NON_NULL);
    }
}

6. Internal Request Lifecycle (DispatcherServlet)

Spring MVC is designed around the Front Controller pattern. The DispatcherServlet handles all incoming requests.

7. Filters vs Interceptors vs AOP

Interviewers love this question.
FeatureFilterInterceptorAOP
LayerServlet Container (Tomcat)Spring MVC FrameworkSpring Bean (Method Level)
ScopeRuns for ALL requests (even non-Spring)Runs only for valid DispatcherServlet requestsRuns for method calls
AccessRaw ServletRequest / ServletResponseHandlerMethod (Knows which controller is mapped)Method Arguments & Return Value
Use CaseSecurity, GZip Compression, CORSAuth Checks, Logging execution timeTransaction mgmt, Audit Logging

Implementing an Interceptor

public class LoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // preHandle fires BEFORE the controller method executes.
        // Returning false short-circuits the entire request -- the controller never runs.
        // Use this for auth checks, request throttling, or tenant resolution.
        System.out.println("Incoming request: " + request.getRequestURI());
        return true; // Continue processing
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {
        // afterCompletion fires AFTER the response is written, even if an exception occurred.
        // Ideal for cleanup: MDC context, request-scoped resources, timing metrics.
        if (ex != null) {
            System.out.println("Request failed: " + ex.getMessage());
        }
    }
}

// Register it -- you can scope interceptors to specific URL patterns.
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoggingInterceptor())
                .addPathPatterns("/api/**")         // Only intercept API paths
                .excludePathPatterns("/api/health"); // Skip health checks
    }
}

8. Asynchronous Requests

If an API takes 10 seconds, you don’t want to block a Tomcat thread (default ~200 threads) for 10s. Once all threads are blocked, your entire server stops accepting new requests — even fast health-check endpoints go dark. Analogy: Imagine a bank with 200 teller windows. If a complex transaction takes 10 minutes per customer, all windows fill up and the line out the door grows forever. Async processing lets the teller say “I have started your wire transfer; step aside and we will call your number when it is done,” freeing the window for the next customer. CompletableFuture
@GetMapping("/async")
public CompletableFuture<String> asyncEndpoint() {
    return CompletableFuture.supplyAsync(() -> {
        // Runs in a separate ForkJoinPool thread, NOT a Tomcat thread.
        // The Tomcat thread is released immediately to serve other requests.
        slowService.process();
        return "Done";
    });
}
Tomcat thread is released immediately. The response is sent when the future completes. Production pitfall: The default ForkJoinPool.commonPool() is shared across the entire JVM. If your async tasks are slow, they can starve unrelated code that also uses the common pool (including parallel streams). In production, provide a dedicated Executor:
@Bean("asyncTaskExecutor")
public Executor asyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("async-");
    // Rejection policy: run in the caller thread rather than dropping the task
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

9. Spring Security Integration

Spring Security is simply a chain of standard Servlet Filters.
If AuthenticationFilter fails (e.g., bad token), it throws an exception and the request never reaches the Controller.

10. Deep Dive: Spring Security Architecture

Spring Security is a lot more than just a few annotations.

The Big Picture

  1. DelegatingFilterProxy: A standard Servlet Filter (registered with Tomcat) that delegates to a Spring Bean.
  2. FilterChainProxy: The Spring Bean that holds all security logic. It contains a list of SecurityFilterChains.
  3. SecurityFilterChain: A chain of filters matching a specific URL pattern.

The Authentication Flow

Key Components

  • AuthenticationManager: The API that defines how Spring Security’s Filters perform authentication.
  • ProviderManager: The standard implementation of AuthenticationManager. It delegates to a list of AuthenticationProviders.
  • AuthenticationProvider: Doing the actual work (e.g., DaoAuthenticationProvider talks to DB, LdapAuthenticationProvider talks to LDAP).
  • UserDetailsService: Interface to load user-specific data using a username.
Production pitfall — Security filter ordering matters: Spring Security filters execute in a strict order defined by FilterOrderRegistration. If you add a custom filter, use addFilterBefore() or addFilterAfter() with a reference filter, not addFilter(). Incorrect ordering is the number one cause of “my authentication works in tests but fails in production” bugs. A senior engineer would note: In a microservices architecture, you typically authenticate at the API Gateway (verify the JWT) and then propagate the user identity downstream via a trusted header (e.g., X-User-Id). Internal services behind the gateway skip JWT validation entirely and trust the header. This avoids every service needing access to the JWT signing key and eliminates redundant token parsing across the call chain.

Interview Deep-Dive

Strong Answer:
  • The request first hits the Servlet container (Tomcat). Before it reaches any Spring code, it passes through the Servlet Filter chain. This is where DelegatingFilterProxy lives, which is how Spring Security plugs into the Servlet layer. Filters see the raw HttpServletRequest and can reject, modify, or wrap it. Critically, filters run for every request — even requests to static resources that Spring MVC does not handle.
  • After filters, the request reaches the DispatcherServlet — Spring MVC’s front controller. It is a single Servlet registered with Tomcat that handles all Spring MVC requests. The DispatcherServlet first consults HandlerMapping implementations to find which controller method matches the URL and HTTP method. RequestMappingHandlerMapping is the one that reads @GetMapping, @PostMapping, etc.
  • Before the controller executes, HandlerInterceptor.preHandle() runs. Interceptors have access to the HandlerMethod object — they know which controller and method will be called, unlike filters which are Servlet-level and Spring-agnostic. This is the right place for cross-cutting concerns that need to know the handler: per-endpoint auth checks, rate limiting by controller, audit logging with method-level context.
  • The controller method executes. @RequestBody triggers HttpMessageConverter (Jackson’s MappingJackson2HttpMessageConverter by default) to deserialize the JSON body into your DTO. If @Valid is present, Bean Validation runs before the method body executes. On the way out, the return object is serialized back to JSON by the same converter mechanism.
  • If the controller throws an exception, @ControllerAdvice exception handlers catch it. These run after the controller but before the response is committed. They can transform the exception into a structured error response (like RFC 7807 Problem Details). If no handler matches, Spring returns a default error page.
  • After the controller returns (or after exception handling), HandlerInterceptor.afterCompletion() runs — even if an exception was thrown. This is your cleanup hook.
Follow-up: A developer puts authentication logic in a HandlerInterceptor, but Spring Security uses Filters. What is the practical difference, and why does Security choose Filters?The critical difference is ordering in the call chain. Filters run before DispatcherServlet, interceptors run after. If authentication is in an interceptor, the request has already been deserialized, handler mapping has been resolved, and potentially some computation has happened. With a filter, you reject unauthenticated requests before any Spring MVC processing occurs — saving CPU cycles and preventing information leakage (a failed handler mapping lookup can reveal valid URL patterns via timing differences). Additionally, Spring Security needs to protect non-DispatcherServlet resources (actuator endpoints, static resources, other servlets in the container), which interceptors cannot do since they only apply to DispatcherServlet-routed requests.
Strong Answer:
  • Spring Security registers a single Servlet Filter called DelegatingFilterProxy with the Servlet container. This delegates to a Spring-managed bean called FilterChainProxy, which is the actual brain of Spring Security. FilterChainProxy holds a list of SecurityFilterChain objects, each matching a URL pattern. For each incoming request, it finds the first matching chain and runs its filters in order.
  • A typical Security filter chain for JWT-based auth includes roughly 15 filters, but the key ones are: CorsFilter (handles preflight OPTIONS requests), CsrfFilter (which you typically disable for stateless APIs), BearerTokenAuthenticationFilter (extracts the JWT from the Authorization header), and AuthorizationFilter (checks whether the authenticated user has the required roles/authorities).
  • When the Bearer token arrives, BearerTokenAuthenticationFilter extracts it and creates a BearerTokenAuthenticationToken. It passes this to the AuthenticationManager, which delegates to JwtAuthenticationProvider. This provider uses a JwtDecoder (typically NimbusJwtDecoder) to validate the token’s signature against the issuer’s public key (fetched from the JWKS endpoint and cached), check expiration, and parse claims.
  • If validation succeeds, the provider creates a fully populated Authentication object (with authorities extracted from JWT claims like scope or roles) and stores it in the SecurityContextHolder. The SecurityContextHolder uses a ThreadLocal by default, which means the authentication is available anywhere in the same thread for the rest of the request.
  • The AuthorizationFilter then checks whether the authenticated user’s authorities satisfy the endpoint’s requirements (from authorizeHttpRequests configuration or @PreAuthorize annotations). If not, it throws AccessDeniedException, which is caught by ExceptionTranslationFilter further up the chain and translated into a 403 response.
Follow-up: In a microservices setup, should each service validate the JWT independently, or should only the gateway validate it?Both, but they validate different things. The gateway should validate the token’s structural integrity and expiration — is this a valid, non-expired JWT signed by our identity provider? This rejects obviously bad requests before they consume downstream resources. Each downstream service should also validate the token for defense in depth (what if someone bypasses the gateway via direct network access in a misconfigured Kubernetes cluster), and for fine-grained authorization. The gateway does not know that /admin/reports on Order Service requires ROLE_ADMIN — only Order Service knows its own authorization rules. In practice, share the JWKS endpoint configuration across services via Spring Cloud Config so all services validate against the same key material. The validation is just a signature check and expiration comparison — it adds microseconds, not milliseconds.
Strong Answer:
  • @Controller marks a class as a Spring MVC controller, but its methods return view names by default. When a method returns the string "user-profile", Spring’s ViewResolver looks for a template file (Thymeleaf, JSP) with that name and renders HTML. This is the traditional server-side rendering model.
  • @RestController is a composed annotation: @Controller + @ResponseBody. The @ResponseBody annotation tells Spring to skip the ViewResolver entirely and instead write the method’s return value directly to the HTTP response body using an HttpMessageConverter.
  • Here is what happens at the converter level: when a controller method returns an object (say, a UserDto), Spring iterates through its registered HttpMessageConverter list. For each converter, it asks “can you write this Java type for the requested media type?” The Accept header from the client determines the target media type. MappingJackson2HttpMessageConverter handles application/json, MappingJackson2XmlHttpMessageConverter handles application/xml, StringHttpMessageConverter handles text/plain.
  • The matching converter serializes the Java object. For Jackson, this means calling ObjectMapper.writeValueAsBytes(), which walks the object’s getters (or fields if configured) and produces JSON. The result is written to the HttpServletResponse output stream with the appropriate Content-Type header.
  • This same mechanism works in reverse for @RequestBody: Spring checks the Content-Type header of the incoming request, finds a converter that can read that media type into the target Java type, and calls its read() method. This is why sending Content-Type: text/plain to an endpoint expecting a JSON @RequestBody fails with a 415 Unsupported Media Type.
Follow-up: How do you customize Jackson serialization globally in Spring Boot, and when would you use @JsonView instead?For global customization, define a Jackson2ObjectMapperBuilderCustomizer bean — this is the Spring Boot way. You can configure property naming strategy (snake_case), date format (ISO 8601 vs. timestamps), null handling (NON_NULL inclusion), and module registration (Java 8 date/time module). Avoid creating your own ObjectMapper bean directly because it disables all of Spring Boot’s auto-configuration for Jackson. @JsonView is a different tool: it lets you define multiple serialization “views” of the same entity. A UserDto might have a Summary view (name, avatar) and a Detail view (name, avatar, email, phone, address). The controller method annotated with @JsonView(Summary.class) only serializes fields marked with that view, avoiding the need for multiple DTO classes.
Strong Answer:
  • This is a classic “silent failure” pattern that usually points to one of three root causes. First, check whether the controller method returns void or ResponseEntity<Void> — the method signature might have been changed during a refactor without updating the return statement.
  • Second, check content negotiation. If the client sends an Accept header that Spring cannot satisfy (e.g., Accept: application/xml but you only have Jackson JSON on the classpath), Spring 6+ returns 406 Not Acceptable. But older versions or misconfigured setups might return 200 with an empty body. Enable spring.mvc.throw-exception-if-no-handler-found=true and spring.web.resources.add-mappings=false to make these fail loudly.
  • Third and most subtle: check if a HandlerInterceptor.preHandle() is returning false silently. When preHandle() returns false, the request processing stops, but the response status is whatever the interceptor set (or 200 by default if it set nothing). No controller executes, no exception handler fires, and no logs appear. I have seen security interceptors do this — they reject the request by returning false and writing directly to the response, but forget to set a status code.
  • To debug systematically: enable logging.level.org.springframework.web=DEBUG. This logs the full handler mapping resolution, which converter was selected, and what the DispatcherServlet is doing at each step. You will see entries like “Resolved handler method” or “No suitable HttpMessageConverter found” that pinpoint the exact failure point.
Follow-up: How would you prevent this class of bug from reaching production?Write integration tests with MockMvc that assert both the status code and the response body structure. The pattern mockMvc.perform(get("/users/1")).andExpect(status().isOk()).andExpect(jsonPath("$.id").exists()) catches the empty body case because jsonPath("$.id").exists() fails on an empty response. Also use contract testing (Spring Cloud Contract or Pact) to verify that your API’s actual responses match the documented contract. The key principle: never assert only the status code in API tests. Always assert the shape of the response body.