Module 50 - API Design
What this module covers
OpenAPI 3 documentation via springdoc, URI-based REST versioning, and consumer-driven contract testing with Pact JVM. The Pact workflow demonstrates how a consumer defines its expectations, generates a pact file, and how the provider verifies against it - all within a single Maven build.
Project structure
src/main/java/com/javatraining/apidesign/
├── ApiDesignApplication.java
├── product/
│ ├── Product.java # internal domain record
│ └── ProductRepository.java # in-memory store
└── api/
├── v1/
│ ├── ProductSummary.java # V1 response: id, name, price
│ └── ProductControllerV1.java # GET /v1/products/{id}
└── v2/
├── ProductDetail.java # V2 response: + category, inStock
└── ProductControllerV2.java # GET /v2/products/{id}
REST versioning
URI versioning embeds the version in the path. Two separate controllers own their response shape - V2 adds category and inStock without modifying V1.
GET /v1/products/1 → {"id":1,"name":"Widget","price":9.99}
GET /v2/products/1 → {"id":1,"name":"Widget","price":9.99,"category":"Tools","inStock":true}
Breaking changes ship in a new version; existing consumers continue hitting the old endpoint unaffected.
OpenAPI 3 / Swagger
springdoc-openapi-starter-webmvc-ui auto-generates /v3/api-docs and exposes Swagger UI at /swagger-ui.html.
Annotations add human-readable metadata without changing runtime behaviour:
@Tag(name = "Products V1", description = "Basic product catalogue - id, name, price")
@RestController
@RequestMapping("/v1/products")
public class ProductControllerV1 {
@Operation(summary = "Get product by ID")
@ApiResponses({
@ApiResponse(responseCode = "200",
content = @Content(schema = @Schema(implementation = ProductSummary.class))),
@ApiResponse(responseCode = "404", description = "Product not found")
})
@GetMapping("/{id}")
public ResponseEntity<ProductSummary> getById(@PathVariable Long id) { ... }
}
@Schema on the response record documents individual fields:
@Schema(description = "Basic product information")
public record ProductSummary(
@Schema(description = "Unique product identifier") Long id,
@Schema(description = "Product name") String name,
@Schema(description = "Unit price") BigDecimal price
) {}
Consumer-driven contract testing with Pact
What problem Pact solves
Integration tests verify that a provider works, but they do not verify that the provider satisfies what each consumer actually needs. Pact makes the consumer’s expectations explicit and verifiable on the provider side.
Workflow
- Consumer test - defines a contract (interactions), validates consumer code against a mock server, and writes
target/pacts/<consumer>-<provider>.json. - Provider test - starts the real Spring Boot application, reads the pact file, and replays each interaction against the running server.
Maven Surefire is configured with runOrder=alphabetical, which ensures ProductApiConsumerPactTest (C < P) runs before ProductApiProviderPactTest.
Consumer
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "ProductProvider", pactVersion = PactSpecVersion.V3)
class ProductApiConsumerPactTest {
@Pact(consumer = "ProductConsumer")
RequestResponsePact productSummaryShape(PactDslWithProvider builder) {
return builder
.given("product with id 1 exists")
.uponReceiving("a GET request for product 1 via v1 API")
.path("/v1/products/1")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.numberType("id", 1L)
.stringType("name", "Widget")
.decimalType("price", 9.99))
.toPact();
}
@Test
@PactTestFor(pactMethod = "productSummaryShape")
void consumer_expects_product_summary_with_id_name_price(MockServer mockServer) {
Map<String, Object> body = new RestTemplate()
.getForObject(mockServer.getUrl() + "/v1/products/1", Map.class);
assertThat(body).containsKeys("id", "name", "price");
}
}
PactDslJsonBody type matchers (.numberType, .stringType, .decimalType) record the expected types, not exact values - the contract survives changes to the actual data as long as the field names and types stay the same.
pactVersion = PactSpecVersion.V3 keeps the RequestResponsePact + PactDslWithProvider API. Pact JVM 4.6.x defaults to V4 which requires a different builder signature (V4Pact + PactBuilder).
Provider
@Provider("ProductProvider")
@PactFolder("target/pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductApiProviderPactTest {
@LocalServerPort int port;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("product with id 1 exists")
void productWithId1Exists() {
// ProductRepository pre-loads product 1 - no setup needed
}
}
@TestTemplate + PactVerificationInvocationContextProvider generates one JUnit test invocation per interaction in the pact file. @State methods set up the server-side precondition described by the consumer’s given(...) clause.
Controller unit tests
@WebMvcTest slices only load the specified controller, keeping tests fast.
@WebMvcTest(ProductControllerV1.class)
class ProductControllerV1Test {
@Autowired MockMvc mockMvc;
@MockBean ProductRepository productRepository;
@Test
void getById_returns_summary_with_id_name_price_only() throws Exception {
when(productRepository.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Widget", new BigDecimal("9.99"), "Tools", true)));
mockMvc.perform(get("/v1/products/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.category").doesNotExist()) // V1 hides category
.andExpect(jsonPath("$.inStock").doesNotExist()); // V1 hides inStock
}
}
Tests
| Class | Type | Count |
|---|---|---|
ProductControllerV1Test | @WebMvcTest | 2 |
ProductControllerV2Test | @WebMvcTest | 2 |
ProductApiConsumerPactTest | Pact consumer | 1 |
ProductApiProviderPactTest | Pact provider | 1 |
Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 6/6 pass
Key decisions
| Decision | Reason |
|---|---|
URI versioning (/v1/, /v2/) over header versioning | Explicit in URLs, easy to bookmark, visible in logs and proxies |
| Separate controller classes per version | Each version owns its mapping logic; no shared code to accidentally break |
pactVersion = PactSpecVersion.V3 | Pact 4.6.x defaults to V4; V3 keeps the familiar RequestResponsePact + PactDslWithProvider API |
runOrder=alphabetical in Surefire | Guarantees consumer test (file generation) runs before provider test (file reading) in the same Maven build |
Type matchers in pact DSL (numberType, stringType) | Contract survives value changes - only field existence and type are enforced |
In-memory ProductRepository | Module focuses on API concerns; no JPA/H2 overhead |