Full Stack • Java • System Design • Cloud • AI Engineering

Spring MVC2026-06-17

Spring MVC REST

Master Spring MVC REST APIs with visual diagrams covering URI design, annotations, request mapping, content negotiation, and complete implementation examples

Spring MVC REST

Spring MVC provides powerful support for building RESTful web services. Understanding URI conventions, request mapping, and Spring annotations is essential for creating robust REST APIs.

RESTful URI Structure

flowchart LR

    URI["https://api.example.com:8080/accounting/v1/forecasting/accounts/123/transactions"]

    URI --> P["Protocol<br/>https://"]
    URI --> D["Domain<br/>api.example.com"]
    URI --> PORT["Port<br/>8080"]
    URI --> APP["Application<br/>accounting"]
    URI --> V["Version<br/>v1"]
    URI --> DC["Domain Context<br/>forecasting"]
    URI --> R["Resource<br/>accounts"]
    URI --> ID["Resource ID<br/>123"]
    URI --> SR["Sub Resource<br/>transactions"]

    style R fill:#4CAF50
    style SR fill:#FF9800

Key Points:

  • Protocol: HTTPS for secure communication
  • Versioning: Include API version (/v1, /v2)
  • Domain Context: Business domain grouping
  • Resources: Use plural nouns for collections
  • Hierarchy: Organize resources hierarchically

URI Design Examples

// Base URL Pattern
// https://api.example.com:8080/app-name/v1/domain/resources

// Collection Resource (Plural)
GET /api/v1/accounting/accounts
// Returns: List of all accounts

// Single Resource (Singular with ID)
GET /api/v1/accounting/accounts/123
// Returns: Single account with ID 123

// Sub-Resource Collection
GET /api/v1/accounting/accounts/123/transactions
// Returns: All transactions for account 123

// Single Sub-Resource
GET /api/v1/accounting/accounts/123/transactions/567
// Returns: Transaction 567 for account 123

// Search/Filter with Query Parameters
GET /api/v1/accounting/accounts/123/transactions?date=2026-01-01&status=COMPLETED
// Returns: Filtered transactions

// Pagination
GET /api/v1/accounting/accounts?page=0&size=20&sort=name,asc
// Returns: Paginated accounts

Spring MVC Request Mapping

sequenceDiagram
    participant Client
    participant DispatcherServlet
    participant HandlerMapping
    participant Controller
    participant Service
    
    Client->>DispatcherServlet: HTTP Request
    DispatcherServlet->>HandlerMapping: Find Handler
    HandlerMapping->>DispatcherServlet: Return Controller Method
    DispatcherServlet->>Controller: Invoke Method
    Controller->>Service: Business Logic
    Service->>Controller: Result
    Controller->>DispatcherServlet: ModelAndView/ResponseBody
    DispatcherServlet->>Client: HTTP Response
    
    Note over Controller: @RequestMapping<br/>@PathVariable<br/>@RequestParam

Key Points:

  • DispatcherServlet: Front controller handling all requests
  • HandlerMapping: Maps URLs to controller methods
  • @RequestMapping: Maps HTTP requests to handler methods
  • @PathVariable: Extracts URI template variables
  • @RequestParam: Extracts query parameters

Complete Spring MVC REST Controller

@RestController
@RequestMapping("/api/v1/accounting")
@Validated
public class AccountController {
    
    @Autowired
    private AccountService accountService;
    
    // GET - All accounts (Collection)
    @GetMapping("/accounts")
    public ResponseEntity<Page<Account>> getAllAccounts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "id") String sortBy) {
        
        Pageable pageable = PageRequest.of(page, size, 
            Sort.by(sortBy).ascending());
        Page<Account> accounts = accountService.findAll(pageable);
        
        return ResponseEntity.ok(accounts);
    }
    
    // GET - Single account by ID
    @GetMapping("/accounts/{accountId}")
    public ResponseEntity<Account> getAccount(
            @PathVariable Long accountId) {
        
        return accountService.findById(accountId)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // GET - Account with special header
    @GetMapping(value = "/accounts/{accountId}",
                headers = "X-Operation=special")
    public ResponseEntity<Account> getAccountSpecial(
            @PathVariable Long accountId,
            @RequestHeader("X-Operation") String operation) {
        
        Account account = accountService.findByIdWithSpecialHandling(accountId);
        return ResponseEntity.ok(account);
    }
    
    // GET - Transactions for account
    @GetMapping("/accounts/{accountId}/transactions")
    public ResponseEntity<List<Transaction>> getAccountTransactions(
            @PathVariable Long accountId) {
        
        List<Transaction> transactions = 
            accountService.findTransactions(accountId);
        return ResponseEntity.ok(transactions);
    }
    
    // GET - Single transaction
    @GetMapping("/accounts/{accountId}/transactions/{txnId}")
    public ResponseEntity<Transaction> getTransaction(
            @PathVariable Long accountId,
            @PathVariable Long txnId) {
        
        return accountService.findTransaction(accountId, txnId)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // GET - Search transactions with query parameters
    @GetMapping("/accounts/{accountId}/transactions/search")
    public ResponseEntity<List<Transaction>> searchTransactions(
            @PathVariable Long accountId,
            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date,
            @RequestParam(required = false) String status) {
        
        List<Transaction> transactions = 
            accountService.searchTransactions(accountId, date, status);
        return ResponseEntity.ok(transactions);
    }
    
    // POST - Create new account
    @PostMapping("/accounts")
    public ResponseEntity<Account> createAccount(
            @Valid @RequestBody AccountRequest request) {
        
        Account account = accountService.create(request);
        
        URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(account.getId())
            .toUri();
        
        return ResponseEntity.created(location).body(account);
    }
    
    // POST - Create transaction
    @PostMapping("/accounts/{accountId}/transactions")
    public ResponseEntity<Transaction> createTransaction(
            @PathVariable Long accountId,
            @Valid @RequestBody TransactionRequest request) {
        
        Transaction transaction = 
            accountService.createTransaction(accountId, request);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(transaction);
    }
    
    // PUT - Update account (full update)
    @PutMapping("/accounts/{accountId}")
    public ResponseEntity<Account> updateAccount(
            @PathVariable Long accountId,
            @Valid @RequestBody AccountRequest request) {
        
        if (!accountService.exists(accountId)) {
            return ResponseEntity.notFound().build();
        }
        
        Account updated = accountService.update(accountId, request);
        return ResponseEntity.ok(updated);
    }
    
    // PATCH - Partial update
    @PatchMapping("/accounts/{accountId}")
    public ResponseEntity<Account> patchAccount(
            @PathVariable Long accountId,
            @RequestBody Map<String, Object> updates) {
        
        return accountService.findById(accountId)
            .map(account -> {
                Account patched = accountService.patch(account, updates);
                return ResponseEntity.ok(patched);
            })
            .orElse(ResponseEntity.notFound().build());
    }
    
    // DELETE - Remove account
    @DeleteMapping("/accounts/{accountId}")
    public ResponseEntity<Void> deleteAccount(
            @PathVariable Long accountId) {
        
        if (!accountService.exists(accountId)) {
            return ResponseEntity.notFound().build();
        }
        
        accountService.delete(accountId);
        return ResponseEntity.noContent().build();
    }
}

Content Negotiation

graph TB
    A[Client Request] --> B{Accept Header}
    B -->|application/json| C[JSON Response]
    B -->|application/xml| D[XML Response]
    B -->|text/csv| E[CSV Response]
    B -->|application/pdf| F[PDF Response]
    
    C --> G[Jackson Converter]
    D --> H[JAXB Converter]
    E --> I[Custom Converter]
    F --> J[Custom Converter]
    
    style C fill:#4CAF50
    style D fill:#FF9800

Key Points:

  • Accept Header: Client specifies desired format
  • Produces: Server declares supported formats
  • Message Converters: Transform objects to/from formats
  • Content Type: Response includes Content-Type header
  • Multiple Formats: Support JSON, XML, CSV, etc.

Content Negotiation Implementation

@RestController
@RequestMapping("/api/v1/accounts")
public class AccountContentController {
    
    @Autowired
    private AccountService accountService;
    
    // JSON Response (default)
    @GetMapping(value = "/{id}", 
                produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Account> getAccountJson(
            @PathVariable Long id) {
        
        return accountService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // XML Response
    @GetMapping(value = "/{id}", 
                produces = MediaType.APPLICATION_XML_VALUE)
    public ResponseEntity<Account> getAccountXml(
            @PathVariable Long id) {
        
        return accountService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // CSV Download
    @GetMapping(value = "/export", 
                produces = "text/csv")
    public void exportAccountsCsv(HttpServletResponse response) 
            throws IOException {
        
        response.setHeader("Content-Disposition", 
            "attachment; filename=accounts.csv");
        
        List<Account> accounts = accountService.findAll();
        
        PrintWriter writer = response.getWriter();
        writer.println("ID,Name,Balance,Status");
        
        for (Account account : accounts) {
            writer.printf("%d,%s,%.2f,%s%n",
                account.getId(),
                account.getName(),
                account.getBalance(),
                account.getStatus());
        }
    }
    
    // Multiple formats with content negotiation
    @GetMapping(value = "/{id}",
                produces = {
                    MediaType.APPLICATION_JSON_VALUE,
                    MediaType.APPLICATION_XML_VALUE
                })
    public ResponseEntity<Account> getAccount(
            @PathVariable Long id,
            @RequestHeader(value = "Accept", 
                          defaultValue = "application/json") String accept) {
        
        return accountService.findById(id)
            .map(account -> {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.parseMediaType(accept));
                return new ResponseEntity<>(account, headers, HttpStatus.OK);
            })
            .orElse(ResponseEntity.notFound().build());
    }
}

// Configuration for content negotiation
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void configureContentNegotiation(
            ContentNegotiationConfigurer configurer) {
        
        configurer
            .favorParameter(false)
            .ignoreAcceptHeader(false)
            .defaultContentType(MediaType.APPLICATION_JSON)
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml", MediaType.APPLICATION_XML)
            .mediaType("csv", MediaType.parseMediaType("text/csv"));
    }
    
    @Override
    public void configureMessageConverters(
            List<HttpMessageConverter<?>> converters) {
        
        // JSON converter
        MappingJackson2HttpMessageConverter jsonConverter = 
            new MappingJackson2HttpMessageConverter();
        jsonConverter.setObjectMapper(objectMapper());
        converters.add(jsonConverter);
        
        // XML converter
        MappingJackson2XmlHttpMessageConverter xmlConverter = 
            new MappingJackson2XmlHttpMessageConverter();
        converters.add(xmlConverter);
    }
    
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        mapper.registerModule(new JavaTimeModule());
        return mapper;
    }
}

Exception Handling

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    // Handle resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex) {
        
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            LocalDateTime.now()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    // Handle validation errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        
        List<FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(error -> new FieldError(
                error.getField(),
                error.getDefaultMessage()))
            .collect(Collectors.toList());
        
        ValidationErrorResponse response = new ValidationErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation failed",
            fieldErrors,
            LocalDateTime.now()
        );
        
        return ResponseEntity.badRequest().body(response);
    }
    
    // Handle generic exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex) {
        
        ErrorResponse error = new ErrorResponse(
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            "An unexpected error occurred",
            LocalDateTime.now()
        );
        
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(error);
    }
}

@Data
@AllArgsConstructor
class ErrorResponse {
    private int status;
    private String message;
    private LocalDateTime timestamp;
}

@Data
@AllArgsConstructor
class ValidationErrorResponse {
    private int status;
    private String message;
    private List<FieldError> errors;
    private LocalDateTime timestamp;
}

REST Best Practices

  1. Use Nouns, Not Verbs: /accounts not /getAccounts
  2. Plural for Collections: /accounts not /account
  3. HTTP Methods for Actions: GET, POST, PUT, DELETE
  4. Query Parameters for Filtering: ?status=active&page=0
  5. Versioning: Include version in URL (/v1/accounts)
  6. Status Codes: Return appropriate HTTP status codes
  7. No State in URI: Use query params for filtering, not state
  8. Idempotency: PUT and DELETE should be idempotent