Skip to content
IntermediateBackend Notes

REST API Design: DTOs, Exception Handling, and CORS

Three unglamorous decisions that separate a REST API a frontend team can actually use from one they'll keep filing bugs against: what you return, how you fail, and who's allowed to call you.

Hen HeangJuly 10, 20268 min read
Spring BootREST APIJava

Getting a REST endpoint to return 200 OK is the easy part. What actually determines whether an API is pleasant to build a frontend against is what shape it returns, what it does when something goes wrong, and whether the browser is even allowed to call it.

DTOs exist to hide fields, not just rename them

java
class User {
    private int id;
    private String username;
    private String email;
    private String password; // never leaves the server
}

class UserDto {
    private String username;
    private String email;
}

The entity has a password field because the database needs it. The DTO doesn't, because the client never should see it. Manual field-by-field copying works but doesn't scale past a couple of DTOs โ€” that's what MapStruct and ModelMapper exist to automate.

MapStruct vs ModelMapper

MapStruct โ€” compile-time
@Mapper
public interface UserMapper {
    UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);

    @Mapping(source = "email", target = "emailAddress")
    @Mapping(source = "name", target = "fullName")
    UserDTO toUserDTO(UserApp userApp);
}
ModelMapper โ€” runtime
modelMapper.typeMap(UserApp.class, UserDTO.class).addMappings(mapper -> {
    mapper.map(UserApp::getEmail, UserDTO::setEmailAddress);
    mapper.map(UserApp::getName, UserDTO::setFullName);
});
UserDTO userDTO = modelMapper.map(user, UserDTO.class);

Info

MapStruct generates real mapping code at compile time โ€” a mismatched field name is a compile error, and there's no reflection cost at runtime. ModelMapper resolves mappings via reflection at runtime โ€” faster to wire up for a quick prototype, but a renamed field fails silently until you notice a null in production.

Exception handling with @ControllerAdvice + ProblemDetail

java
public class StudentNotFoundException extends RuntimeException {
    public StudentNotFoundException(String message) { super(message); }
}

@ControllerAdvice
public class CustomizedExceptionHandling {

    @ExceptionHandler(StudentNotFoundException.class)
    ProblemDetail handleExceptions(StudentNotFoundException exception, WebRequest webRequest) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, exception.getMessage());
        problemDetail.setType(URI.create("http://localhost:8080/errors/not-found"));
        problemDetail.setTitle("Not Found!");
        return problemDetail;
    }
}

One handler, every controller

@ControllerAdvice centralizes error formatting so every endpoint returns the same error shape instead of each controller inventing its own. ProblemDetail is Spring's built-in implementation of RFC 7807 (application/problem+json) โ€” a standard error format a frontend can parse generically instead of guessing your API's custom error schema.

CORS: an allowlist, not a formality

CorsFilterConfiguration.java
@Bean
public CorsFilter corsFilter() {
    final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    final CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.setAllowedOriginPatterns(Collections.singletonList("*"));
    config.setAllowedHeaders(Arrays.asList("Origin", "Content-Type", "Accept", "Authorization"));
    config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS", "DELETE", "PATCH"));
    source.registerCorsConfiguration("/**", config);
    return new CorsFilter(source);
}

Warning

setAllowedOriginPatterns("*") combined with setAllowCredentials(true) is convenient in a course demo and dangerous in production โ€” it lets any origin send credentialed requests. In a real deployment, list your actual frontend origins explicitly instead of wildcarding them.