1. java
  2. /spring
  3. /spring-mvc

Master Spring MVC Web Framework

Spring MVC

Spring MVC is Spring's web framework that implements the Model-View-Controller design pattern. It provides a flexible and powerful way to build web applications and RESTful web services. Spring MVC separates the concerns of handling requests, processing business logic, and rendering responses, making applications more maintainable and testable.

MVC Architecture Overview

Spring MVC follows the front controller pattern with DispatcherServlet as the central component:

Request → DispatcherServlet → HandlerMapping → Controller → Service → Repository
    ↓                                                      ↓
Response ← ViewResolver ← View ← Model ← Controller ← Business Logic

Core Components:

  • DispatcherServlet: Front controller that handles all requests
  • HandlerMapping: Maps requests to appropriate controllers
  • Controller: Handles requests and returns model and view
  • ViewResolver: Resolves logical view names to actual views
  • View: Renders the model data (JSP, Thymeleaf, JSON, etc.)

Basic Controller Setup

// Simple REST Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        Optional<User> user = userService.findById(id);
        return user.map(ResponseEntity::ok)
                  .orElse(ResponseEntity.notFound().build());
    }
    
    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
        User user = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<User> updateUser(@PathVariable Long id, 
                                          @Valid @RequestBody UpdateUserRequest request) {
        try {
            User updated = userService.updateUser(id, request);
            return ResponseEntity.ok(updated);
        } catch (UserNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        try {
            userService.deleteUser(id);
            return ResponseEntity.noContent().build();
        } catch (UserNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }
}

// Traditional MVC Controller (returns views)
@Controller
@RequestMapping("/web/users")
public class UserWebController {
    
    private final UserService userService;
    
    public UserWebController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping
    public String listUsers(Model model) {
        List<User> users = userService.findAll();
        model.addAttribute("users", users);
        return "users/list"; // Returns view name
    }
    
    @GetMapping("/{id}")
    public String viewUser(@PathVariable Long id, Model model) {
        User user = userService.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
        model.addAttribute("user", user);
        return "users/detail";
    }
    
    @GetMapping("/new")
    public String newUserForm(Model model) {
        model.addAttribute("user", new CreateUserRequest());
        return "users/form";
    }
    
    @PostMapping
    public String createUser(@Valid @ModelAttribute CreateUserRequest request, 
                           BindingResult bindingResult, 
                           RedirectAttributes redirectAttributes) {
        if (bindingResult.hasErrors()) {
            return "users/form";
        }
        
        User created = userService.createUser(request);
        redirectAttributes.addFlashAttribute("message", "User created successfully");
        return "redirect:/web/users/" + created.getId();
    }
}

Request Mapping and Parameters

Spring MVC provides flexible request mapping options:

Path Variables and Request Parameters

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    private final ProductService productService;
    
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
    
    // Path variables
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }
    
    @GetMapping("/category/{categoryId}/products/{productId}")
    public Product getProductInCategory(@PathVariable Long categoryId, 
                                      @PathVariable Long productId) {
        return productService.findByIdInCategory(productId, categoryId);
    }
    
    // Request parameters
    @GetMapping
    public List<Product> searchProducts(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(defaultValue = "name") String sortBy) {
        
        ProductSearchCriteria criteria = ProductSearchCriteria.builder()
            .name(name)
            .category(category)
            .page(page)
            .size(size)
            .sortBy(sortBy)
            .build();
            
        return productService.search(criteria);
    }
    
    // Matrix variables
    @GetMapping("/filter/{filters}")
    public List<Product> filterProducts(@MatrixVariable Map<String, String> filters) {
        return productService.filterProducts(filters);
    }
    // Usage: /api/products/filter/color=red;size=large;brand=nike
    
    // Headers and cookies
    @GetMapping("/recommendations")
    public List<Product> getRecommendations(
            @RequestHeader("User-Agent") String userAgent,
            @CookieValue(name = "userId", required = false) String userId) {
        return productService.getRecommendations(userId, userAgent);
    }
}

Request Body and Validation

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    private final OrderService orderService;
    
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }
    
    @PostMapping
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }
    
    @PatchMapping("/{id}")
    public ResponseEntity<Order> updateOrderStatus(
            @PathVariable Long id,
            @RequestBody @Valid UpdateOrderStatusRequest request) {
        Order updated = orderService.updateStatus(id, request.getStatus());
        return ResponseEntity.ok(updated);
    }
}

// Request DTOs with validation
public class CreateOrderRequest {
    
    @NotNull(message = "Customer ID is required")
    private Long customerId;
    
    @NotEmpty(message = "Order items cannot be empty")
    @Valid
    private List<OrderItemRequest> items;
    
    @NotBlank(message = "Shipping address is required")
    @Size(max = 500, message = "Address cannot exceed 500 characters")
    private String shippingAddress;
    
    @Email(message = "Invalid email format")
    private String email;
    
    // Getters and setters
    public Long getCustomerId() { return customerId; }
    public void setCustomerId(Long customerId) { this.customerId = customerId; }
    
    public List<OrderItemRequest> getItems() { return items; }
    public void setItems(List<OrderItemRequest> items) { this.items = items; }
    
    public String getShippingAddress() { return shippingAddress; }
    public void setShippingAddress(String shippingAddress) { this.shippingAddress = shippingAddress; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

public class OrderItemRequest {
    
    @NotNull(message = "Product ID is required")
    private Long productId;
    
    @Min(value = 1, message = "Quantity must be at least 1")
    @Max(value = 100, message = "Quantity cannot exceed 100")
    private Integer quantity;
    
    // Getters and setters
    public Long getProductId() { return productId; }
    public void setProductId(Long productId) { this.productId = productId; }
    
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

Exception Handling

Spring MVC provides powerful exception handling mechanisms:

Controller-Level Exception Handling

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
    }
    
    // Controller-specific exception handler
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            "USER_NOT_FOUND",
            ex.getMessage(),
            System.currentTimeMillis()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
        ErrorResponse error = new ErrorResponse(
            "INVALID_REQUEST",
            ex.getMessage(),
            System.currentTimeMillis()
        );
        return ResponseEntity.badRequest().body(error);
    }
}

Global Exception Handling

@ControllerAdvice
public class GlobalExceptionHandler {
    
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
        logger.warn("User not found: {}", ex.getMessage());
        
        ErrorResponse error = new ErrorResponse(
            "USER_NOT_FOUND",
            ex.getMessage(),
            System.currentTimeMillis()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex) {
        
        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
        Map<String, String> errors = fieldErrors.stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                FieldError::getDefaultMessage,
                (existing, replacement) -> existing
            ));
        
        ValidationErrorResponse errorResponse = new ValidationErrorResponse(
            "VALIDATION_FAILED",
            "Request validation failed",
            errors,
            System.currentTimeMillis()
        );
        
        return ResponseEntity.badRequest().body(errorResponse);
    }
    
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(
            DataIntegrityViolationException ex) {
        
        logger.error("Data integrity violation", ex);
        
        String message = "Data integrity constraint violated";
        if (ex.getMessage().contains("unique")) {
            message = "Resource already exists";
        }
        
        ErrorResponse error = new ErrorResponse(
            "DATA_INTEGRITY_VIOLATION",
            message,
            System.currentTimeMillis()
        );
        
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        logger.error("Unexpected error occurred", ex);
        
        ErrorResponse error = new ErrorResponse(
            "INTERNAL_SERVER_ERROR",
            "An unexpected error occurred",
            System.currentTimeMillis()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

// Error response DTOs
public class ErrorResponse {
    private String code;
    private String message;
    private long timestamp;
    
    public ErrorResponse(String code, String message, long timestamp) {
        this.code = code;
        this.message = message;
        this.timestamp = timestamp;
    }
    
    // Getters
    public String getCode() { return code; }
    public String getMessage() { return message; }
    public long getTimestamp() { return timestamp; }
}

public class ValidationErrorResponse extends ErrorResponse {
    private Map<String, String> fieldErrors;
    
    public ValidationErrorResponse(String code, String message, 
                                 Map<String, String> fieldErrors, long timestamp) {
        super(code, message, timestamp);
        this.fieldErrors = fieldErrors;
    }
    
    public Map<String, String> getFieldErrors() { return fieldErrors; }
}

Content Negotiation and Response Formats

Spring MVC supports multiple content types and formats:

Content Negotiation

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    private final ProductService productService;
    
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
    
    // Supports both JSON and XML based on Accept header
    @GetMapping(value = "/{id}", produces = {
        MediaType.APPLICATION_JSON_VALUE,
        MediaType.APPLICATION_XML_VALUE
    })
    public Product getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }
    
    // Accepts both JSON and XML
    @PostMapping(consumes = {
        MediaType.APPLICATION_JSON_VALUE,
        MediaType.APPLICATION_XML_VALUE
    })
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        Product created = productService.create(product);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
    
    // Returns CSV format
    @GetMapping(value = "/export", produces = "text/csv")
    public ResponseEntity<String> exportProducts() {
        String csv = productService.exportToCsv();
        return ResponseEntity.ok()
            .header("Content-Disposition", "attachment; filename=products.csv")
            .body(csv);
    }
    
    // Returns file download
    @GetMapping("/{id}/image")
    public ResponseEntity<Resource> getProductImage(@PathVariable Long id) {
        Resource image = productService.getProductImage(id);
        return ResponseEntity.ok()
            .contentType(MediaType.IMAGE_JPEG)
            .header("Content-Disposition", "inline; filename=product-" + id + ".jpg")
            .body(image);
    }
}

Custom Message Converters

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        // Add custom CSV converter
        converters.add(new CsvMessageConverter());
        
        // Configure Jackson for JSON
        Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder()
            .indentOutput(true)
            .dateFormat(new SimpleDateFormat("yyyy-MM-dd"))
            .simpleDateFormat("yyyy-MM-dd");
        
        converters.add(new MappingJackson2HttpMessageConverter(builder.build()));
    }
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000", "https://myapp.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

// Custom CSV message converter
public class CsvMessageConverter implements HttpMessageConverter<Object> {
    
    @Override
    public boolean canRead(Class<?> clazz, MediaType mediaType) {
        return false; // We only write CSV, not read
    }
    
    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        return MediaType.valueOf("text/csv").includes(mediaType);
    }
    
    @Override
    public List<MediaType> getSupportedMediaTypes() {
        return List.of(MediaType.valueOf("text/csv"));
    }
    
    @Override
    public Object read(Class<?> clazz, HttpInputMessage inputMessage) {
        throw new UnsupportedOperationException("CSV reading not supported");
    }
    
    @Override
    public void write(Object object, MediaType contentType, HttpOutputMessage outputMessage) 
            throws IOException {
        
        outputMessage.getHeaders().setContentType(MediaType.valueOf("text/csv"));
        
        if (object instanceof List<?> list) {
            writeCsv(list, outputMessage.getBody());
        } else {
            writeCsv(List.of(object), outputMessage.getBody());
        }
    }
    
    private void writeCsv(List<?> objects, OutputStream outputStream) throws IOException {
        // CSV writing implementation
        try (PrintWriter writer = new PrintWriter(outputStream)) {
            if (!objects.isEmpty()) {
                // Write header
                Object first = objects.get(0);
                writeHeader(first.getClass(), writer);
                
                // Write data
                for (Object obj : objects) {
                    writeRow(obj, writer);
                }
            }
        }
    }
    
    private void writeHeader(Class<?> clazz, PrintWriter writer) {
        // Implementation to write CSV header
    }
    
    private void writeRow(Object obj, PrintWriter writer) {
        // Implementation to write CSV row
    }
}

Interceptors and Filters

Spring MVC supports request/response interception:

Interceptors

@Component
public class LoggingInterceptor implements HandlerInterceptor {
    
    private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class);
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                           Object handler) throws Exception {
        
        String requestURI = request.getRequestURI();
        String method = request.getMethod();
        String userAgent = request.getHeader("User-Agent");
        
        logger.info("Incoming request: {} {} from {}", method, requestURI, userAgent);
        
        // Add request start time
        request.setAttribute("startTime", System.currentTimeMillis());
        
        return true; // Continue processing
    }
    
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, 
                          Object handler, ModelAndView modelAndView) throws Exception {
        
        Long startTime = (Long) request.getAttribute("startTime");
        long duration = System.currentTimeMillis() - startTime;
        
        logger.info("Request processing completed in {} ms", duration);
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
                              Object handler, Exception ex) throws Exception {
        
        if (ex != null) {
            logger.error("Request completed with exception", ex);
        } else {
            logger.info("Request completed successfully with status: {}", 
                       response.getStatus());
        }
    }
}

@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
    
    private final TokenService tokenService;
    
    public AuthenticationInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                           Object handler) throws Exception {
        
        // Skip authentication for public endpoints
        if (isPublicEndpoint(request.getRequestURI())) {
            return true;
        }
        
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }
        
        String token = authHeader.substring(7);
        if (!tokenService.isValidToken(token)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }
        
        // Add user context to request
        User user = tokenService.getUserFromToken(token);
        request.setAttribute("currentUser", user);
        
        return true;
    }
    
    private boolean isPublicEndpoint(String uri) {
        return uri.startsWith("/api/public/") || 
               uri.equals("/api/auth/login") || 
               uri.equals("/api/auth/register");
    }
}

// Register interceptors
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    private final LoggingInterceptor loggingInterceptor;
    private final AuthenticationInterceptor authInterceptor;
    
    public WebConfig(LoggingInterceptor loggingInterceptor, 
                    AuthenticationInterceptor authInterceptor) {
        this.loggingInterceptor = loggingInterceptor;
        this.authInterceptor = authInterceptor;
    }
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // Logging interceptor for all requests
        registry.addInterceptor(loggingInterceptor);
        
        // Authentication interceptor for API endpoints
        registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/**")
            .excludePathPatterns("/api/public/**", "/api/auth/**");
    }
}

File Upload and Download

Spring MVC provides excellent support for file handling:

File Upload

@RestController
@RequestMapping("/api/files")
public class FileController {
    
    private final FileStorageService fileStorageService;
    
    public FileController(FileStorageService fileStorageService) {
        this.fileStorageService = fileStorageService;
    }
    
    @PostMapping("/upload")
    public ResponseEntity<FileUploadResponse> uploadFile(
            @RequestParam("file") MultipartFile file,
            @RequestParam(required = false) String description) {
        
        // Validate file
        if (file.isEmpty()) {
            throw new IllegalArgumentException("File cannot be empty");
        }
        
        if (file.getSize() > 10 * 1024 * 1024) { // 10MB limit
            throw new IllegalArgumentException("File size cannot exceed 10MB");
        }
        
        String contentType = file.getContentType();
        if (!isAllowedContentType(contentType)) {
            throw new IllegalArgumentException("File type not allowed: " + contentType);
        }
        
        try {
            FileMetadata metadata = fileStorageService.store(file, description);
            
            FileUploadResponse response = new FileUploadResponse(
                metadata.getId(),
                metadata.getOriginalName(),
                metadata.getStoredName(),
                metadata.getSize(),
                metadata.getContentType(),
                metadata.getUploadTime()
            );
            
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
            
        } catch (IOException e) {
            throw new RuntimeException("Failed to upload file", e);
        }
    }
    
    @PostMapping("/upload/multiple")
    public ResponseEntity<List<FileUploadResponse>> uploadMultipleFiles(
            @RequestParam("files") MultipartFile[] files) {
        
        if (files.length > 5) {
            throw new IllegalArgumentException("Cannot upload more than 5 files at once");
        }
        
        List<FileUploadResponse> responses = new ArrayList<>();
        
        for (MultipartFile file : files) {
            try {
                FileMetadata metadata = fileStorageService.store(file, null);
                responses.add(new FileUploadResponse(
                    metadata.getId(),
                    metadata.getOriginalName(),
                    metadata.getStoredName(),
                    metadata.getSize(),
                    metadata.getContentType(),
                    metadata.getUploadTime()
                ));
            } catch (IOException e) {
                throw new RuntimeException("Failed to upload file: " + file.getOriginalFilename(), e);
            }
        }
        
        return ResponseEntity.status(HttpStatus.CREATED).body(responses);
    }
    
    @GetMapping("/{fileId}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String fileId) {
        try {
            FileMetadata metadata = fileStorageService.getFileMetadata(fileId);
            Resource resource = fileStorageService.loadAsResource(fileId);
            
            return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(metadata.getContentType()))
                .header(HttpHeaders.CONTENT_DISPOSITION, 
                       "attachment; filename=\"" + metadata.getOriginalName() + "\"")
                .body(resource);
                
        } catch (FileNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    @GetMapping("/{fileId}/info")
    public ResponseEntity<FileMetadata> getFileInfo(@PathVariable String fileId) {
        try {
            FileMetadata metadata = fileStorageService.getFileMetadata(fileId);
            return ResponseEntity.ok(metadata);
        } catch (FileNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    @DeleteMapping("/{fileId}")
    public ResponseEntity<Void> deleteFile(@PathVariable String fileId) {
        try {
            fileStorageService.delete(fileId);
            return ResponseEntity.noContent().build();
        } catch (FileNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    private boolean isAllowedContentType(String contentType) {
        List<String> allowedTypes = List.of(
            "image/jpeg", "image/png", "image/gif",
            "application/pdf",
            "text/plain", "text/csv",
            "application/vnd.ms-excel",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
        );
        return allowedTypes.contains(contentType);
    }
}

// File upload configuration
@Configuration
public class FileUploadConfig {
    
    @Bean
    public MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        factory.setMaxFileSize(DataSize.ofMegabytes(10));
        factory.setMaxRequestSize(DataSize.ofMegabytes(50));
        return factory.createMultipartConfig();
    }
}

Testing Spring MVC Controllers

Comprehensive testing strategies for Spring MVC:

Unit Testing Controllers

@ExtendWith(MockitoExtension.class)
class UserControllerTest {
    
    @Mock
    private UserService userService;
    
    @InjectMocks
    private UserController userController;
    
    private MockMvc mockMvc;
    private ObjectMapper objectMapper;
    
    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.standaloneSetup(userController)
            .setControllerAdvice(new GlobalExceptionHandler())
            .build();
        objectMapper = new ObjectMapper();
    }
    
    @Test
    void shouldReturnUserWhenFound() throws Exception {
        // Given
        Long userId = 1L;
        User user = new User(userId, "John Doe", "[email protected]");
        when(userService.findById(userId)).thenReturn(Optional.of(user));
        
        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(userId))
            .andExpect(jsonPath("$.name").value("John Doe"))
            .andExpect(jsonPath("$.email").value("[email protected]"));
    }
    
    @Test
    void shouldReturn404WhenUserNotFound() throws Exception {
        // Given
        Long userId = 1L;
        when(userService.findById(userId)).thenReturn(Optional.empty());
        
        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpected(status().isNotFound());
    }
    
    @Test
    void shouldCreateUserWithValidRequest() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest("John Doe", "[email protected]");
        User createdUser = new User(1L, "John Doe", "[email protected]");
        
        when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser);
        
        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpected(jsonPath("$.id").value(1))
            .andExpected(jsonPath("$.name").value("John Doe"));
    }
    
    @Test
    void shouldReturn400ForInvalidRequest() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest("", "invalid-email");
        
        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest())
            .andExpected(jsonPath("$.code").value("VALIDATION_FAILED"))
            .andExpected(jsonPath("$.fieldErrors.name").exists())
            .andExpected(jsonPath("$.fieldErrors.email").exists());
    }
}

Integration Testing

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserControllerIntegrationTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }
    
    @Test
    void shouldCreateAndRetrieveUser() {
        // Given
        CreateUserRequest request = new CreateUserRequest("John Doe", "[email protected]");
        
        // When - Create user
        ResponseEntity<User> createResponse = restTemplate.postForEntity(
            "/api/users", request, User.class);
        
        // Then - Verify creation
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        User createdUser = createResponse.getBody();
        assertThat(createdUser.getId()).isNotNull();
        assertThat(createdUser.getName()).isEqualTo("John Doe");
        
        // When - Retrieve user
        ResponseEntity<User> getResponse = restTemplate.getForEntity(
            "/api/users/" + createdUser.getId(), User.class);
        
        // Then - Verify retrieval
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        User retrievedUser = getResponse.getBody();
        assertThat(retrievedUser.getId()).isEqualTo(createdUser.getId());
        assertThat(retrievedUser.getName()).isEqualTo("John Doe");
    }
    
    @Test
    void shouldReturn404ForNonExistentUser() {
        // When
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/users/999", String.class);
        
        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

Summary

Spring MVC provides a comprehensive web framework:

Key Features:

  • MVC Architecture: Clear separation of concerns
  • Flexible Routing: Powerful request mapping capabilities
  • Content Negotiation: Support for multiple formats (JSON, XML, etc.)
  • Exception Handling: Global and controller-specific error handling
  • Validation: Comprehensive validation support
  • File Handling: Built-in file upload/download capabilities

Core Components:

  • Controllers: Handle HTTP requests and responses
  • Request Mapping: Map URLs to controller methods
  • Data Binding: Automatic request parameter binding
  • Validation: Bean validation with custom validators
  • Interceptors: Cross-cutting concerns like authentication
  • Message Converters: Handle different content types

Best Practices:

  • Use @RestController for REST APIs
  • Implement proper exception handling with @ControllerAdvice
  • Validate input with @Valid and custom validators
  • Use DTOs for request/response objects
  • Test thoroughly with both unit and integration tests
  • Handle file uploads securely with proper validation

Testing Strategies:

  • Unit Tests: Test controllers in isolation with MockMvc
  • Integration Tests: Test full stack with TestRestTemplate
  • Use test containers for database integration tests
  • Mock external dependencies appropriately

Spring MVC is a mature, flexible framework that provides everything needed to build robust web applications and RESTful services.