🌐 REST Assured: Designing APIs Developers Actually Want to Use

A perfectly engineered microservice is useless if developers hate the API. This article covers the CLEAR framework for REST design — consistent naming, multi-layer validation, RFC 7807 error contracts, and OpenAPI documentation that turns your endpoints into a product people want to use.

REST API design — validation, RFC 7807 errors, and OpenAPI documentation

📚 Series Navigation:
Previous: Part 2 - Spring Boot Alchemy
👉 You are here: Part 3 - REST Assured
Next: Part 4 - The Data Foundation →


📋 Introduction

You've built an incredible microservice. It processes weather data with the speed of a caffeinated cheetah. Your architecture is layered like a fine pastry. Your database schema is a work of art. And then someone tries to use your API.

"What parameters does this endpoint take?"
"Why did I get a 500 error when I sent an invalid latitude?"
"Is it /api/weather/location/42 or /api/locations/42/weather?"

The reality is blunt: the most perfectly engineered microservice is useless if developers hate the API. Your REST endpoints are the front door to your application. If they're confusing, inconsistent, or undocumented, nobody will want to walk through them — no matter how beautiful the architecture behind the door.

In this article, we'll explore how the Weather Microservice designs APIs that are intuitive, well-validated, thoroughly documented, and consistent. We'll see real code patterns for multi-layer validation, custom composed annotations, and OpenAPI documentation that generates itself from your code. Let's build APIs that developers actually want to use. ☕


🧩 The CLEAR Framework: Five Principles of Great API Design

I call it CLEAR — five principles that guide REST API design:

Letter Principle What It Means
C Consistent Naming URLs follow predictable patterns, resources are plural nouns
L Levels of Validation Input is validated at every layer — controller, service, domain
E Error Contracts Errors follow RFC 7807, always structured, always helpful
A API Documentation OpenAPI/Swagger docs generated from code, always in sync
R Resource-oriented URLs Endpoints model resources and relationships, not actions

🏗️ Resource-Oriented URL Design

The Weather Microservice organizes its endpoints around three resources:

URL Hierarchy

/api/weather/          → Current weather & history
/api/locations/        → Location CRUD
/api/forecast/         → Forecast data

Each resource follows standard REST conventions:

HTTP Method URL Pattern Description Status Code
GET /api/locations List all locations 200
GET /api/locations/{id} Get a single location 200 / 404
GET /api/locations/search?name=London Search locations 200
POST /api/locations Create a location 201
PUT /api/locations/{id} Update a location 200 / 404
DELETE /api/locations/{id} Delete a location 204 / 404

Notice the consistency:

  • Plural nouns for resource names (locations, not location)
  • Path variables for specific resources (/{id})
  • Query parameters for filtering and options (?name=, ?save=)
  • Appropriate HTTP methods for each operation
  • Correct status codes (201 for creation, 204 for deletion)

The LocationController: Full CRUD

@RestController
@RequestMapping("/api/locations")
@Tag(name = "Location Management", description = "APIs for managing weather locations")
@RequiredArgsConstructor
@Validated
public class LocationController {

  private final LocationService locationService;

  @PostMapping
  public ResponseEntity<LocationDto> createLocation(
      @Valid @RequestBody CreateLocationRequest request) {
    LocationDto location = locationService.createLocation(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(location);
  }

  @GetMapping("/{id}")
  public ResponseEntity<LocationDto> getLocationById(
      @PathVariable @Positive Long id) {
    LocationDto location = locationService.getLocationById(id);
    return ResponseEntity.ok(location);
  }

  @GetMapping
  public ResponseEntity<List<LocationDto>> getAllLocations() {
    List<LocationDto> locations = locationService.getAllLocations();
    return ResponseEntity.ok(locations);
  }

  @PutMapping("/{id}")
  public ResponseEntity<LocationDto> updateLocation(
      @PathVariable @Positive Long id,
      @Valid @RequestBody CreateLocationRequest request) {
    LocationDto location = locationService.updateLocation(id, request);
    return ResponseEntity.ok(location);
  }

  @DeleteMapping("/{id}")
  public ResponseEntity<Void> deleteLocation(
      @PathVariable @Positive Long id) {
    locationService.deleteLocation(id);
    return ResponseEntity.noContent().build();
  }
}

Key patterns:

  • @Validated on the class — Enables method-level validation for @Positive, @NotBlank, etc.
  • @Valid on @RequestBody — Triggers Bean Validation on the request DTO
  • @Positive on path variables — Rejects invalid IDs before they reach the service layer
  • ResponseEntity.status(HttpStatus.CREATED) — Returns 201 for resource creation (not 200)
  • ResponseEntity.noContent().build() — Returns 204 with no body for deletion

Pagination with Spring Data

@GetMapping("/search/page")
public ResponseEntity<Page<LocationDto>> searchLocationsPaginated(
    @RequestParam
    @NotBlank(message = "Location name cannot be blank")
    @Size(min = 1, max = ValidationConstants.LOCATION_NAME_MAX_LENGTH)
    String name,
    @PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC)
    Pageable pageable) {
  Page<LocationDto> locations = locationService.searchLocationsByName(name, pageable);
  return ResponseEntity.ok(locations);
}

The @PageableDefault annotation provides sensible defaults when clients don't specify pagination parameters. The client can override them:

GET /api/locations/search/page?name=London&page=0&size=10&sort=name,asc

Spring automatically parses page, size, and sort query parameters into a Pageable object. No manual parsing needed.


🛡️ Multi-Layer Validation: Defense in Depth

Validation isn't something you do once. The Weather Microservice validates at three distinct layers, each catching different types of errors:

Layer 1: Controller Validation (HTTP Boundary)

// ForecastController.java
@GetMapping
public ResponseEntity<List<ForecastDto>> getForecast(
    @RequestParam
    @NotBlank(message = "Location cannot be blank")
    String location,

    @RequestParam(defaultValue = "3")
    @Min(value = ValidationConstants.FORECAST_DAYS_MIN,
         message = "Days must be at least " + ValidationConstants.FORECAST_DAYS_MIN)
    @Max(value = ValidationConstants.FORECAST_DAYS_MAX,
         message = "Days cannot exceed " + ValidationConstants.FORECAST_DAYS_MAX)
    int days,

    @RequestParam(defaultValue = "true")
    boolean save) {
  List<ForecastDto> forecast = forecastService.getForecast(location, days, save);
  return ResponseEntity.ok(forecast);
}

Controller validation catches invalid HTTP input:

  • @NotBlank — Rejects empty or whitespace-only strings
  • @Min / @Max — Constrains numeric ranges (forecast days: 1-14)
  • @Positive — Rejects zero or negative IDs
  • @Size — Limits string lengths
  • @NotNull — Rejects missing required parameters

Layer 2: Request Object Validation (Domain Boundary)

// CreateLocationRequest.java
public record CreateLocationRequest(
    @ValidLocationName String name,
    @ValidCountryName String country,
    @ValidLatitude Double latitude,
    @ValidLongitude Double longitude,
    String region) {}

This request object uses custom composed annotations — but before we explore those, notice that the region field has no validation. It's optional. Validation annotations are only on fields that have constraints.

Layer 3: Service Validation (Business Logic)

// WeatherService.java
public List<WeatherDto> getWeatherHistoryByDateRange(
    @NotNull Long locationId,
    @NotNull LocalDateTime startDate,
    @NotNull LocalDateTime endDate) {
  DateRangeValidator.validateWeatherHistoryRange(startDate, endDate);
  // ...
}

Service-level validation catches business rule violations that can't be expressed with annotations. The DateRangeValidator enforces rules like:

  • Date ranges can't exceed 90 days
  • Queries can't go back more than 1 year
  • Start date must be before end date

Why Three Layers?

Layer What It Catches Example
Controller Invalid HTTP input Blank location name, negative ID
Request DTO Invalid domain objects Latitude of 999, name of 1 char
Service Business rule violations Date range > 90 days, duplicate location

Each layer rejects different types of invalid data. Without controller validation, invalid requests reach the service layer (wasteful). Without service validation, business rules can be violated by internal callers (dangerous).


🎨 Custom Composed Annotations: DRY Validation

The Weather Microservice creates domain-specific validation annotations that combine multiple standard constraints:

@ValidLatitude: A Composed Constraint

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@NotNull(message = "Latitude is required")
@Min(value = ValidationConstants.LATITUDE_MIN, message = ValidationConstants.LATITUDE_RANGE_MESSAGE)
@Max(value = ValidationConstants.LATITUDE_MAX, message = ValidationConstants.LATITUDE_RANGE_MESSAGE)
@Constraint(validatedBy = {})
public @interface ValidLatitude {
  String message() default "Invalid latitude coordinate";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

This single annotation replaces three separate annotations everywhere latitude is validated. Instead of writing:

// Without composed annotation (repeated everywhere)
@NotNull(message = "Latitude is required")
@Min(value = -90, message = "Latitude must be between -90 and 90")
@Max(value = 90, message = "Latitude must be between -90 and 90")
Double latitude;

You write:

// With composed annotation (clean and reusable)
@ValidLatitude
Double latitude;

Benefits:

  • DRY — Validation rules defined once, used everywhere
  • Consistent messages — All latitude validation uses the same error messages
  • Single source of truth — Change the range once, it updates everywhere
  • Readable@ValidLatitude is more expressive than three separate annotations

Centralized Validation Constants

public final class ValidationConstants {

  private ValidationConstants() {
    throw new UnsupportedOperationException("Utility class cannot be instantiated");
  }

  // Location Name Validation
  public static final int LOCATION_NAME_MIN_LENGTH = 2;
  public static final int LOCATION_NAME_MAX_LENGTH = 100;
  public static final String LOCATION_NAME_SIZE_MESSAGE =
      "Name must be between " + LOCATION_NAME_MIN_LENGTH
          + " and " + LOCATION_NAME_MAX_LENGTH + " characters";

  // Latitude Validation
  public static final long LATITUDE_MIN = -90;
  public static final long LATITUDE_MAX = 90;
  public static final String LATITUDE_RANGE_MESSAGE =
      "Latitude must be between " + LATITUDE_MIN + " and " + LATITUDE_MAX;

  // Forecast Days Validation
  public static final int FORECAST_DAYS_MIN = 1;
  public static final int FORECAST_DAYS_MAX = 14;
}

All validation constants live in one place. When the weather API adds support for 21-day forecasts, you change one constant. Every controller, every validator, every error message updates automatically.

💡 Pro Tip: The private constructor with throw new UnsupportedOperationException prevents accidental instantiation of utility classes. It's a Java best practice that ArchUnit could also enforce.


📖 OpenAPI Documentation: Self-Documenting APIs

The Weather Microservice generates comprehensive API documentation from code annotations:

OpenAPI Configuration

@Configuration
public class OpenApiConfig {

  @Value("${openapi.server.url}")
  private String serverUrl;

  @Value("${openapi.contact.email}")
  private String contactEmail;

  @Bean
  public OpenAPI weatherServiceOpenAPI() {
    Server localServer = new Server();
    localServer.setUrl(serverUrl);
    localServer.setDescription("Local development server");

    Contact contact = new Contact();
    contact.setName("Weather Service Team");
    contact.setEmail(contactEmail);

    Info info = new Info()
        .title("Weather Microservice API")
        .version("1.0.0")
        .description("Weather microservice providing current weather data, "
            + "forecasts, and historical weather records.")
        .contact(contact)
        .license(new License().name("MIT License"));

    return new OpenAPI().info(info).servers(List.of(localServer));
  }
}

Controller-Level Documentation

@RestController
@RequestMapping("/api/weather")
@Tag(name = "Weather Data", description = "APIs for current weather and historical weather data")
public class WeatherController {

  @Operation(
      summary = "Get current weather by location name",
      description = "Fetches current weather data for a location from external API")
  @ApiResponses(value = {
      @ApiResponse(responseCode = "200", description = "Weather data retrieved successfully"),
      @ApiResponse(responseCode = "400", description = "Invalid location name"),
      @ApiResponse(responseCode = "503", description = "External API unavailable")
  })
  @GetMapping("/current")
  public ResponseEntity<WeatherDto> getCurrentWeather(
      @Parameter(description = "Location name (e.g., 'London', 'New York')", required = true)
      @RequestParam @NotBlank String location,
      @Parameter(description = "Whether to save weather data to database")
      @RequestParam(defaultValue = "true") boolean save) {
    // ...
  }
}

Annotation breakdown:

Annotation Level Purpose
@Tag Class Groups endpoints in Swagger UI
@Operation Method Summary and description for the endpoint
@ApiResponses Method Documents possible HTTP status codes
@ApiResponse Per status Describes each response scenario
@Parameter Parameter Describes each input parameter
@Schema DTO field Describes data model fields with examples

DTO-Level Documentation

@Schema(description = "Current weather information")
public record WeatherDto(
    @Schema(description = "Weather record ID", example = "1") @Nullable Long id,
    @Schema(description = "Location name", example = "London") String locationName,
    @Schema(description = "Temperature in Celsius", example = "15.5") Double temperature,
    @Schema(description = "Humidity percentage", example = "65") Integer humidity,
    // ...
    @Schema(description = "Timestamp", example = "2024-01-15T14:30:00") LocalDateTime timestamp
) {}

The example values in @Schema annotations appear in Swagger UI, giving developers real-world samples without needing to make actual API calls.

Accessing the Documentation

springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    enabled: true

Navigate to http://localhost:8080/swagger-ui.html for an interactive UI, or http://localhost:8080/api-docs for the raw OpenAPI JSON spec. The documentation stays in sync with the code because it's generated from the code.


🔄 Request/Response Patterns

Consistent Response Wrapping

The Weather Microservice returns different response types depending on the operation:

// Single resource → 200 OK
return ResponseEntity.ok(weatherDto);

// Resource creation → 201 Created
return ResponseEntity.status(HttpStatus.CREATED).body(locationDto);

// Resource deletion → 204 No Content
return ResponseEntity.noContent().build();

// Paginated list → 200 OK with Page metadata
return ResponseEntity.ok(pageOfLocations);

The Page response includes metadata that pagination consumers need:

{
  "content": [...],
  "totalElements": 42,
  "totalPages": 3,
  "size": 20,
  "number": 0,
  "first": true,
  "last": false
}

Default Values That Make Sense

@RequestParam(defaultValue = "true") boolean save
@RequestParam(defaultValue = "3") int days
@PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC) Pageable pageable

Every optional parameter has a sensible default. Developers can start making API calls immediately without reading the documentation — the defaults do the right thing.


🔍 Search and Filtering Patterns

The Weather Microservice implements search in a clean, consistent way:

@GetMapping("/search")
public ResponseEntity<List<LocationDto>> searchLocations(
    @RequestParam
    @NotBlank(message = "Location name cannot be blank")
    @Size(min = 1, max = ValidationConstants.LOCATION_NAME_MAX_LENGTH)
    String name) {
  List<LocationDto> locations = locationService.searchLocationsByName(name);
  return ResponseEntity.ok(locations);
}
@GetMapping("/search/page")
public ResponseEntity<Page<LocationDto>> searchLocationsPaginated(
    @RequestParam @NotBlank @Size(min = 1, max = 100) String name,
    @PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC) Pageable pageable) {
  Page<LocationDto> locations = locationService.searchLocationsByName(name, pageable);
  return ResponseEntity.ok(locations);
}

Date Range Queries

@GetMapping("/history/location/{locationId}/range")
public ResponseEntity<List<WeatherDto>> getWeatherHistoryByDateRange(
    @PathVariable @Positive Long locationId,
    @RequestParam @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
        LocalDateTime startDate,
    @RequestParam @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
        LocalDateTime endDate) {
  List<WeatherDto> history =
      weatherService.getWeatherHistoryByDateRange(locationId, startDate, endDate);
  return ResponseEntity.ok(history);
}

The @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) annotation tells Spring how to parse the date string from the query parameter. Clients send ISO 8601 format: 2024-01-15T14:30:00.


📐 URL Design Patterns

The Weather Microservice's URL structure follows a consistent pattern for nested resources:

# Resource-based
/api/weather/current                           → Current weather by name
/api/weather/current/location/{locationId}     → Current weather by location ID
/api/weather/history/location/{locationId}     → Weather history for a location

# Forecast endpoints
/api/forecast                                  → Forecast by name
/api/forecast/location/{locationId}            → Forecast by location ID
/api/forecast/stored/location/{locationId}     → Stored forecasts
/api/forecast/future/location/{locationId}     → Future-only forecasts
/api/forecast/range/location/{locationId}      → Date range query

# Location CRUD
/api/locations                                 → List/Create
/api/locations/{id}                            → Get/Update/Delete
/api/locations/search                          → Search by name
/api/locations/search/page                     → Paginated search

Patterns to notice:

  • Nested resources use the parent ID in the path: /weather/history/location/{locationId}
  • Filtering uses query parameters: ?name=London, ?days=7
  • Sub-resources use descriptive path segments: /stored/, /future/, /range/
  • Pagination variants add /page to the base search URL

✅ API Design Checklist

  • [ ] Plural nouns for resource names (/locations, not /location)
  • [ ] Consistent HTTP methods — GET reads, POST creates, PUT updates, DELETE removes
  • [ ] Correct status codes — 201 for creation, 204 for deletion, 400 for validation errors
  • [ ] Multi-layer validation — Controller, DTO, and Service layers each validate appropriately
  • [ ] Custom composed annotations@ValidLatitude > three separate annotations
  • [ ] Centralized constants — Validation rules in one place (ValidationConstants)
  • [ ] Sensible defaults — Every optional parameter has a reasonable default value
  • [ ] OpenAPI annotations@Tag, @Operation, @ApiResponse, @Parameter, @Schema
  • [ ] Pagination support@PageableDefault with Spring Data Page responses
  • [ ] Date formatting — ISO 8601 with @DateTimeFormat
  • [ ] Documentation accessible — Swagger UI at /swagger-ui.html

🎓 Conclusion: APIs Are Products

Your API is the product — and these are the design principles that make it usable:

  1. The CLEAR framework (Consistent naming, Levels of validation, Error contracts, API documentation, Resource-oriented URLs) guides REST API design
  2. Resource-oriented URLs with plural nouns, path variables, and query parameters create predictable, intuitive APIs
  3. Multi-layer validation catches different types of errors at controller, DTO, and service levels
  4. Custom composed annotations like @ValidLatitude consolidate multiple validation constraints into reusable, domain-specific annotations
  5. Centralized ValidationConstants ensure consistency across the entire codebase
  6. OpenAPI annotations generate interactive documentation that stays in sync with code
  7. Pagination with @PageableDefault gives clients control over result sets with sensible defaults
  8. Proper HTTP status codes (201 Created, 204 No Content) communicate operation results correctly

Your API is a product. Treat it like one. Document it like someone's job depends on understanding it — because someday, it will.

Coming Next Week:
Part 4: The Data Foundation - JPA, Hibernate, and the Database Migration Playbook 🗄️


📚 Series Progress

✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy
✅ Part 3: REST Assured ← You just finished this!
⬜ Part 4: The Data Foundation
⬜ Part 5: When the World Breaks
⬜ Part 6: Cache Me If You Can
⬜ Part 7: Guarding the Gates
⬜ Part 8: Fail Gracefully
⬜ Part 9: 10,000 Threads and a Dream
⬜ Part 10: Can You See Me Now?
⬜ Part 11: Trust, But Verify
⬜ Part 12: Ship It
⬜ Part 13: To Production and Beyond


Happy coding, and remember — the best API is the one developers figure out without reading the docs (but document it anyway).


Robert Marcel Saveanu

Robert Marcel Saveanu

Software engineer with 15 years in testing, architecture, and the art of surviving corporate dysfunction. Writing about code, quality, and the humans behind both.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Codyssey.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.